Deny-by-Default Sandbox Profile — Phase-2 Offline Packet MVP (reproducible spec; B4′ substrate)
Deny-by-Default Sandbox Profile — Phase-2 Offline Packet MVP
Reproducible specification of the rev4 §12.1 L1 deny-by-default execution sandbox — the primary enforcement boundary for the offline packet MVP. Macro:
PROGRAM_MACRO_PROVISION_AND_ATTEST_DENY_BY_DEFAULT_SANDBOX_FOR_PHASE2_OFFLINE_MVP_2026_06_09Date: 2026-06-09 · Status:SANDBOX_PROFILE_SPECIFIED_NOT_ATTESTEDProduction mutation: NO · Install: NO · Container created by Claude: NO · Codex: NOThis document is a design/output artifact, not authority. It is a reproducible recipe an operator runs on an approved venue. It does not itself provision, run, or attest anything. Until the operator runs §6 on an approved venue and returns the §7 evidence, B4′ stays BLOCKED and the MVP build must not run.
0. Authority & provenance (Article 13)
- Source of the requirement: KB
designs/implementation-package-dot-v0-1-gap-only-scope-spec-rev4-2026-06-09.md§12.1 (deny-by-default sandbox) andcheckpoints/operator-action-packet-sandbox-host-for-phase2-mvp-2026-06-09.md§2 (the 6-row minimum bar). Read KB-first. - Runtime evidence: governed-native
mcp__claude_ai_Incomex_VPS__list_docker(read-only) — Docker runtime live, 11 containers — recorded inreports/sandbox-host-attestation-for-phase2-offline-mvp-2026-06-09.{md,json}. - This profile is derived from those KB sources; it is local/output content, not a competing authority. If it conflicts with rev4 §12.1, rev4 wins and this profile must be repaired.
1. Deny-by-default posture (what "deny-by-default" means here)
The sandbox grants nothing unless explicitly listed. Every capability below is absent by construction, not promised by the application:
| Boundary | Enforced by (load-bearing) | NOT by |
|---|---|---|
| No network egress | Absent network namespace (--network none) — there is no interface to reach anything |
not a client-side "don't connect" promise; not seccomp alone |
| No host filesystem | Mount namespace = read-only rootfs + exactly two binds (/in:ro, /out:rw) |
not path-string filtering in the app |
| No secrets/credentials | Scrubbed env (--env-file /dev/null, no -e) + no secret mounts |
not redaction in the app |
| No privilege escalation | --cap-drop ALL + --security-opt no-new-privileges |
not "we run as non-root and behave" |
| No subprocess / no socket / no ptrace | seccomp targeted-deny (execve/execveat/socket/connect/bind/ptrace → EPERM) + distroless image without a shell |
not an import denylist alone (that is L2, secondary) |
| No output-path escape | rootfs read-only; only /out is writable; /in is ro |
not app-level path checks alone |
In-process guards (L2 static-build-guard, L3 runtime self-check) are secondary / fail-fast and bypassable in principle — they do not replace this structural boundary (this is the direct answer to Codex's "static scans are bypassable" objection).
2. Minimum bar (rev4 §12.1 / operator-packet §2) → realization
| # | Requirement | Docker/Podman realization (Option B, primary) | bwrap realization (Option C, fallback) |
|---|---|---|---|
| 1 | No network | --network none |
--unshare-net |
| 2 | Read-only input mount only | -v <input>:/in:ro |
--ro-bind <input> /in |
| 3 | Write-only/output mount only | -v <output>:/out:rw (sole writable path) |
--bind <output> /out |
| 4 | No home/project/etc/secret mounts | minimal image; no extra -v; --read-only rootfs |
--unshare-all; no extra binds; --ro-bind /usr /usr etc. minimal |
| 5 | Scrubbed env | --env-file /dev/null (no -e/--env) |
--clearenv |
| 6 | Syscall restriction | --security-opt seccomp=seccomp-deny-by-default.json + --security-opt no-new-privileges + --cap-drop ALL |
--seccomp <fd> + --new-session --die-with-parent --cap-drop ALL |
3. Image (Dockerfile)
Use a distroless base: no shell, no package manager → reinforces "no subprocess" structurally (there is no /bin/sh to exec). The inspector is copied in read-only.
# Dockerfile.sandbox — deny-by-default offline inspector image
# Build: docker build -f Dockerfile.sandbox -t tki-inspector:attest .
# Distroless: no shell, no apt, nonroot uid 65532 by default.
FROM gcr.io/distroless/python3-debian12:nonroot
# Inspector code is read-only application content (built in Phase 2; placeholder dir here).
# At attestation time, a tiny probe module is sufficient; the real inspector replaces it later.
WORKDIR /app
COPY --chown=root:root inspector/ /app/
# No ENTRYPOINT that spawns a shell. Direct python invocation only.
USER 65532:65532
ENTRYPOINT ["python", "/app/main.py"]
Note: the
inspector/payload is not built by this macro (building the MVP is out of scope). For attestation, a ≤30-line probemain.py/probe.pyexercising §6 is sufficient and is the operator's to place. The real inspector replaces it in the Phase-2 build.
4. seccomp profile (seccomp-deny-by-default.json)
Two valid realizations; both make execve/execveat/socket/connect/bind/ptrace return EPERM, which is what rev4 §12.1 names. The structural --network none already blocks egress regardless of seccomp; the socket denies are defense-in-depth.
4a. Targeted-deny (concrete, attestable as-is) — recommended for the first attestation
Starts from the standard allow posture and explicitly denies the named dangerous syscalls. This is robust to Python's syscall needs (no risk of accidentally breaking the runtime), so it can be attested without an iterate-the-allowlist loop.
{
"defaultAction": "SCMP_ACT_ALLOW",
"archMap": [
{ "architecture": "SCMP_ARCH_X86_64",
"subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"] },
{ "architecture": "SCMP_ARCH_AARCH64",
"subArchitectures": ["SCMP_ARCH_ARM"] }
],
"syscalls": [
{ "names": [
"socket", "socketpair", "connect", "bind", "listen", "accept", "accept4",
"sendto", "sendmsg", "recvfrom", "recvmsg", "getpeername", "getsockname",
"execve", "execveat",
"ptrace", "process_vm_readv", "process_vm_writev",
"mount", "umount2", "pivot_root", "chroot",
"setns", "unshare", "clone3",
"init_module", "finit_module", "delete_module",
"kexec_load", "reboot", "swapon", "swapoff",
"bpf", "perf_event_open"
],
"action": "SCMP_ACT_ERRNO",
"errnoRet": 1
}
]
}
errnoRet: 1 = EPERM. (Network calls may also surface EAFNOSUPPORT/ENETUNREACH from --network none; either is an acceptable proof-of-block — see §7.)
4b. Default-deny allowlist (hardened target) — attest after 4a, with an iterate step
"defaultAction": "SCMP_ACT_ERRNO" plus an explicit allowlist of the syscalls a Python read/write workload needs (read, write, openat, close, fstat, mmap, brk, rt_sigaction, futex, exit_group, getdents64, lseek, pread64, pwrite64, clock_gettime, epoll_*, poll, getrandom, arch_prctl, set_robust_list, madvise, …) and omitting socket/execve/ptrace. Honest caveat: the exact minimal allowlist is workload-specific and cannot be finalized without running the real inspector; treat 4b as a hardening step to validate iteratively (run → add the one syscall it EPERMs on that is provably benign → repeat) only after 4a passes. Do not present 4b as attested until it actually runs the inspector clean.
5. Run command (exact)
# Variables the operator sets (must be throwaway dirs, NOT prod paths):
INPUT_DIR=/abs/path/to/readonly/packet # contains the governed export packet
OUTPUT_DIR=/abs/path/to/writable/out # empty throwaway dir
SECCOMP=/abs/path/to/seccomp-deny-by-default.json
docker run --rm \
--name tki-sandbox-attest \
--network none \
--user 65532:65532 \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m \
--cap-drop ALL \
--security-opt no-new-privileges \
--security-opt seccomp="$SECCOMP" \
--pids-limit 64 \
--memory 256m --memory-swap 256m --cpus 1 \
--env-file /dev/null \
-v "$INPUT_DIR":/in:ro \
-v "$OUTPUT_DIR":/out:rw \
tki-inspector:attest <probe-or-inspector-args>
Explicitly NOT present (deny-by-default): no --privileged, no --network host, no --pid host, no --ipc host, no -v /:/..., no -v $HOME, no -v /var/run/docker.sock, no -e SECRET=..., no --cap-add. Their absence is the boundary.
Rootless Podman equivalent (preferred for least privilege where available): replace docker with podman, keep all flags; rootless adds user-namespace isolation for free.
6. Attestation probes (exact operations the operator runs)
Run each inside the §5 container (e.g. as <probe args> → a probe main.py, or via a one-shot python -c). Each probe captures operation identity + exit code + stderr/errno + artifact path. Distroless has no shell, so probes are Python one-liners through the image's python entrypoint.
| Probe | Operation (inside sandbox) | Expected proof-of-block | Boundary / rev4 L1 test |
|---|---|---|---|
| PR-NET-1 | socket.socket(); s.connect(("1.1.1.1",53)) |
PermissionError/OSError EPERM or ENETUNREACH/EAFNOSUPPORT |
process-level egress — #27 |
| PR-NET-2 | read /proc/net/dev |
only lo present (empty net ns) |
net namespace empty — #27 sibling |
| PR-SOCK-1 | socket.socket(AF_INET,SOCK_STREAM) |
PermissionError EPERM (seccomp) |
socket() denial — #25 |
| PR-ENV-1 | sorted(os.environ.keys()) |
no *TOKEN*/*SECRET*/*PASSWORD*/*_DSN*/*CONN*/PG*/KB* keys |
scrubbed env — #28 |
| PR-FS-RO-IN | open("/in/__probe","w") |
OSError EROFS |
input mount read-only — #33 |
| PR-FS-ESC-1 | open("/etc/__probe","w") |
OSError EROFS (read-only rootfs) |
no host write / path escape — #29 |
| PR-FS-ESC-2 | open("/root/__probe","w") / open("/app/__probe","w") |
OSError EROFS/EACCES |
no escape outside /out — #29/#33 |
| PR-FS-OUT-OK | open("/out/report.md","w").write("ok") |
success (positive control: the one writable path) | output mount works — control for #33 |
| PR-EXEC-1 | os.execv("/bin/ls",["ls"]) / subprocess.run(["/bin/ls"]) |
PermissionError EPERM (seccomp) and/or FileNotFound (no shell) |
no subprocess — #34 |
| PR-MOUNT-1 | read /proc/self/mountinfo |
exactly: rootfs ro, /in ro, /out rw, /tmp tmpfs noexec; nothing else |
mount table = 2 binds only — #29/#33 |
| PR-SOCK-DOCKER | os.path.exists("/var/run/docker.sock") |
False |
no docker socket — #36 |
| PR-PTRACE-1 (opt) | ptrace(PTRACE_TRACEME) via ctypes |
EPERM | no ptrace — #37 |
The rev4 acceptance matrix enumerates the L1 structural-bypass tests as #24–#37 (
designs/acceptance-test-matrix-…-rev4-2026-06-09.md). The probe→test bindings above name the specific cases the build prompt cited (#25/#27/#28/#29/#33/#34/#35/#37); the final per-number binding is fixed when the harness is built in Phase 2. Probe PR-DYNIMPORT (dynamic-import outside L2 allowlist → #35) is an L2 concern and is asserted by the build-time guard, not this OS probe — noted so #35 is not silently dropped.
7. Evidence the operator returns (so Claude can verify read-only later)
A single bundle (text/JSON, deposited where Claude can read it: a KB doc under tool-kiem-thu/reports/… or the governed VPS write area /opt/incomex/docs/mcp-writes/…) containing, for every probe in §6:
{ "probe_id", "operation", "expected", "actual_stderr_or_value",
"errno_or_exit", "verdict": "PASS|FAIL", "artifact_path" }
plus the raw /proc/self/mountinfo, the env keyset (names only), the /proc/net/dev dump, the seccomp profile sha256, the image digest, the venue (host/CI), and the runtime+version. Claude will then bind each to matrix #24–#37 and only then can B4′ acceptance be asserted — by a later run, not this one.
8. Venue & honesty bound (Article 14)
- Approved venues: the operator's chosen deny-by-default host (Option B on the already-deployed runtime, isolated throwaway container) or a CI deny-by-default ephemeral runner (Option D). A local developer Mac attestation proves only a local/ephemeral venue and is not accepted as a substitute for the operator/CI venue (it would risk an Article-14 venue-confusion) — per owner direction 2026-06-09.
- No fake-green: this document attests nothing. It is a recipe. PASS exists only after §6 runs and §7 evidence is verified. Until then: B4′ = BLOCKED, MVP build = must not run (P1 self-check fails closed to
BLOCKED/exit 3 by design).
Authored read-only. No build, install, container creation, sandbox run, production mutation, or Codex call was performed. The profile is reproducible and waiting for the operator to execute §5–§6 on an approved venue and return §7 evidence.