Repository navigation
Description
Problem
The extraction child (python -I -m processor._child, spec §3) parses untrusted documents with pypdf, lxml / libxml2, bs4 and openpyxl. v1 already treats it as untrusted in two ways:
- Scrubbed environment: the child's environment holds only
PATHandLANG, so the broker credential andGOOGLE_APPLICATION_CREDENTIALSare not in it. - Safe result channel: the parent decodes the child's result with an unpickler that resolves no global but
ExtractOutcome. A compromised child cannot make the parent run code (test:test_a_compromised_child_cannot_run_code_in_the_parent).
Neither measure contains a child that gets code execution through a parser bug. The child runs as the same Unix user as the consumer, so it can:
- read whatever that user can: the service's env file and GCS service-account key, and the operator/agent files in that user's home;
- read the parent's environment through
/proc/<parent>/environ; - open network connections, the broker included.
This was found in the v1 code review (CR 2, fe62727) and accepted for the MVP on 2026-09-30. The input is pages of known watched items that Replicator fetches, not arbitrary uploads. That narrows the exposure but does not close it.
Proposed containment (in layers)
-
Landlock in the child. This VM's kernel offers ABI 6, and Landlock needs no privilege and no unit change. After
RLIMIT_ASand before importing the extractors, the child restricts itself:- Filesystem: read-only on the interpreter, venv and system library paths; no writes; nothing under
/etc/processor, the service user's home, or/proc/<ppid>. - Network: no TCP connect or bind (ABI ≥ 4).
- Scoping: no signals to other processes and no abstract unix sockets (ABI 6).
A setting decides whether a kernel without Landlock fails closed (the production default) or open (dev).
- Filesystem: read-only on the interpreter, venv and system library paths; no writes; nothing under
-
Parent
PR_SET_DUMPABLE=0. This closes/proc/<pid>/environand ptrace to same-uid processes. -
A dedicated service user (
processor, not the operator/agent user):/etc/processorowned by root and read by systemd (EnvironmentFile=);- the GCS key through
LoadCredential=.
This separates the service from the agent's own credentials, even though the child still shares the service's uid (so layer 1 is still needed). It needs an operator step at install.
-
Optional: a seccomp filter for the child.
Done when
- Tests show the child cannot open the service env file, the key file, the parent's
/proc/<pid>/environ, or a file in the service user's home. - Tests show the child cannot connect to
127.0.0.1:6379. - The whole parity corpus still passes through the contained child.
- Spec §3 and
docs/DEPLOYMENT.mddescribe the containment, and the known-limitation note in spec §3 is removed.
Refs: #1, spec §3 (the child process), src/processor/_child.py, src/processor/child.py.
Activity
#1 fix: CR 2 decisions — escapes dead-letter at the cap, DataCorruption
gregoryfoster commentedon Oct 5, 2026
Hand-off from the burn-down epic #24, 2026-10-05. This is next. #23 shipped in #32.
Why now. It's the last item before cutover (watcher#326). Landing it during shadow lets Watcher's comparator check the contained child against real traffic, not only the parity fixtures.
Operator decisions (2026-10-05, at the check-in):
sudo systemctl disable --now docker.socket. Contain the extraction child: it runs as the service user and can reach the service's secrets #2 adds a test that the child can't reach/run/docker.sock, and Index processor on co-index's shared SocratiCode store (init-socraticode, STORE=external) #5's box is then done.Measured on
co-processor, 2026-10-05T23:15Z:landlock_create_ruleset(NULL, 0, VERSION)returns 6); LSMscapability,landlock,selinux.exedev, which has passwordless sudo and is in groupdocker.NoNewPrivileges=yesalready blockssudofrom the service and its children. It does not block/run/docker.sock(root:docker 0660,docker.socketenabled and active), which is the worst exposure today: code execution in the child means root through Docker./run/tailscale/tailscaled.sockisroot:root 0666: any local uid can query tailscaled's LocalAPI (read-only for a non-operator)./usr/bin/python3.12(system).sys.pathunder-Iis/usr/lib/python3.12*,.venv/lib/python3.12/site-packages, and/home/exedev/processor/src(an editable install via_editable_impl_processor.pth)./home/exedevis 0750. Secrets in reach of the service user:/etc/processor/.env(dir 700), the GCS writer key it points to, and the repo's.env(GH tokens, 600).hosts: files dns, so droppingAF_UNIXdoesn't affect name resolution.Touchpoints (checked against
mainat80165dd):src/processor/_child.pymain(), in order today:oom_score_adj→RLIMIT_AS→ move the result channel →pickle.load→ import the transform → run.src/processor/child.pyrun_in_child(-I,env=_child_env(),cwd="/") and its docstring (lines 3–20).deploy/processor.service:User/Group,WorkingDirectory,EnvironmentFile,ExecStart,NoNewPrivileges,ProtectSystem=full.mainis the deployed code".Traps found while drafting:
Layer 3 breaks the deploy model.
/home/exedevis 0750, so aprocessoruser can't reach the checkout,.venvorsrc/(the editable install). Today's invariant "this checkout is the deployed code" (AGENTS.md, DEPLOYMENT, and step 3 of the epic's verify recipe) can't survive as it is. Options:/opt/processor: agit worktreeorgit archiveofmain, a non-editable install, its own venv), and change "Deploy a change" to build it. Recommended: the service can then read nothing ofexedev's.chmod o+x /home/exedev(traverse only). A smaller change, but every world-readable file underexedev's home whose path is known becomes readable, and the editablesrc/stays agent-writable code the service executes.This changes the operator's deploy command, so the plan states the new deploy and install steps and the operator approves them before building. The epic's verify recipe will need a new step 3 (e.g. the deployed tree's commit equals
origin/main). Say what it is.Landlock on 6.12 probably doesn't restrict
connect()to a pathname unix socket. Its filesystem rights coverMAKE_SOCK(creating one), not connecting to one that exists.[stated from Landlock's docs; not run here, so prove it with a test]. So layer 1 alone leaves the child able to reachtailscaled.sock, anddocker.sockuntil it's disabled. The child needs no sockets at all. Options:socket()/socketpair()(the issue's optional layer 4; a small classic-BPF program throughprctl(PR_SET_SECCOMP));RestrictAddressFamilies=AF_INET AF_INET6on the unit, which covers the whole service; check that nothing in the parent needsAF_UNIX, since journald gets an inherited fd and NSS has noresolvemodule.Recommendation: the seccomp filter, scoped to the child. Either way, a test that the child can't connect to a unix socket the service user could otherwise reach.
Derive Landlock's allowlist; don't hard-code it. The paths move with trap 1. Allow read and execute on
sys.base_prefix's library tree, each non-emptysys.pathentry, the dynamic loader and/etc/ld.so.cache, and whatever else the parity corpus shows it needs (6.12 has no Landlock audit log, so run the corpus and see what it refuses). Never allow the repo root, which holds.env. Write nothing. Leave/procout (theoom_score_adjwrite happens before the restriction).Things to weigh:
_childimports only the stdlib at module level (its docstring). Landlock is 3 syscalls (444–446) plusprctl(PR_SET_NO_NEW_PRIVS), throughctypes. Loadctypesand libc before restricting. A PyPI binding would mean a wheelhouse and supply-chain change.PR_SET_DUMPABLE=0on the parent closes/proc/<parent>/environto same-uid processes. With layer 3, the child shares the service's uid, so this still matters.LoadCredential=for the GCS key:Environment=GOOGLE_APPLICATION_CREDENTIALS=%d/<name>(%dis the credentials directory). Check that pydantic-settings and co-core read it unchanged.EnvironmentFile=is read before the user switch). The service user doesn't need to read it.Done when (the issue's, plus):
/proc/<parent>/environ, a file in the service user's home, or the repo's.env.127.0.0.1:6379, and can't connect to a unix socket.tests/fixtures/parity/real/, passes through the contained child.docker.socket, and a test or check shows the child can't reach/run/docker.sock.ack: completewith Outcome log records no digests: add input_digest, output_digest, size, empty and stored to every command outcome #26's digests, and Watcher still reports matches.Gates (operator):
docker.socketoff (decided; any time).LoadCredential=, the first deploy to the new tree.Deploy: this changes
src/anddeploy/. The restart is the new install, so the plan gives the rollback (the old unit and tree) in case the first shadow command fails. During shadow the risk is bounded: a failed command stays pending and Watcher doesn't act on it.Out of scope: #5 (except its
docker.socketbox), #29, #21.Ship: issue → plan in
docs/plans/(operator review: gate 2) → branch in a worktree → TDD → PR → FF merge → install (gate 3). The closing comment gives each box with its evidence (one live record), the known limits, and the follow-ups (or "none").Kickoff prompt:
gregoryfoster commentedon Oct 6, 2026
Gate 1 done:
docker.socketis off (2026-10-06T00:01:57Z, the coordinator, with the operator's authorization).systemctl is-enabled docker.socketreturnsdisabled, anddocker.socket,docker.serviceandcontainerdare all inactive. Nothing depended on them. A same-uidconnect('/run/docker.sock')now returnsECONNREFUSED. The socket file is still there (RemoveOnStop=no), with nothing listening, and it isn't recreated at boot.processorwas unaffected (NRestarts=0). The Done-when check "the child can't reach/run/docker.sock" should test that the connect fails, not that the file is missing.#2 docs: plan — Layer 3 follows the cohort deploy standard (status#9 …
#2 feat: _contain — Landlock, seccomp socket filter, undumpable paren…
#2 feat: the child contains itself; processor run is undumpable, fail…
#2 feat: releases — scripts/deploy.sh and the unit as user processor …
#2 docs: spec §2/§3/§7, DEPLOYMENT, AGENTS, SKILLS — containment, the…
#2 fix: the allowlist's library dirs come from the loader's mapped ob…
#2 fix: CR 1 — a boot canary: one real extraction through the contain…
#2 fix: CR 2 — grant read only: the contained child executes nothing,…
#2 fix: CR 3 — a failed first deploy restarts the service on the unit…
#2 fix: CR 4 — contain() refuses a process with a second thread, whic…
#2 fix: CR 5 — the deploy's verify reads the new MainPID's records fr…
#2 fix: CR 6 — prctl and syscall arguments reach the kernel as full-w…
#2 docs: CR 8 — the undumpable parent's debugging cost, and deploy.sh…
#2 fix: CR 9 — check the key as the unit receives it: ACL-readable by…
#2 docs: CR 10 — the plan records what build and review amended
#2 fix: CR 11 — releases are copied, never hardlinked, from the uv cache
#2 fix: CR 12 — the install chmods /etc/processor's files by name, no…
#2 fix: CR 11 — wrap the uv stub's log line (E501; CR 11's commit ski…
#2 fix: CR 13 — a failed archive or wheelhouse copy says so, rather t…
#2 fix: CR 14 — releases/ is created 755 explicitly, so the service u…
gregoryfoster commentedon Oct 6, 2026
Reopened. The merge of #33 closed this through "Closes #2". The code is merged and Stage A is live, but the Done-when still needs Stage B: the service running as
processor, plus live evidence.Merged: PR #33, FF to
mainatfe19a2b(2026-10-06 16:55:48Z). The commits:_contain(Landlock, seccomp, undumpable parent);scripts/deploy.sh, and the unit asprocessor;Stage A, live since 16:55:51Z. The old deploy path:
uv sync --frozen --no-devand a restart, still asexedevfrom~/processor.starting:child_containment: required,landlock_abi: 6,build: dev. The boot canary passed: one real extraction through the contained child.scripts/smoke_scratch_bus.pypassed, with"child_containment": "required".NoNewPrivs: 1, and a same-uid read of/proc/<pid>/environis refused (undumpable).docker.socketisdisabled(gate 1).Stage B (gate 3, the operator): DEPLOYMENT.md § Install, then the checks in § Containment, which include the key-as-credential pair. Rollback is in § Rollback. The closing comment will give each box with its evidence.
Follow-ups, to file at close: processor's CI gate and its drift check (status#11 and status#12). The feedback for the cohort skill is in the plan and linked from Provisioner#23.
gregoryfoster commentedon Oct 6, 2026
Stage A, the first live shadow command under containment:
01M492PB6W3P8N0EEA85HZX2EDat 2026-10-06T17:03:02.704Z,ack: complete.input_digest2e38aa5e…,output_digestsha256:b8f6d0f1…(17,327 bytes, Watcher's recorded fingerprint for that input),processor_version0.19.7+1.total_ms838.Stage B (gate 3) is next.
gregoryfoster commentedon Oct 6, 2026
Done, 2026-10-06. Shipped in #33 (FF,
main=fe19a2b). Stage A was live at 16:55:51Z; Stage B was installed at 19:05:30Z. The service runs asprocessorfrom releasefe19a2b6a06f.Each box, with its evidence
/proc/<parent>/environ, or a file in the service user's hometests/test_child.py, underrequired, every denial paired with an uncontained control. Onco-processorbefore the install, against the real/etc/processor/.envandco-gcs-processor-writer.json. Live after the install: the key as the unit receives it,/run/credentials/processor.service/gcs-writer-key, is readable byprocessor(the control) and givesPermissionErrorto a contained process. The parent is undumpable:sudo -u processor cat /proc/<pid>/environis refused.envtest_the_child_cannot_read_the_real_secrets[…/.env], run from the main checkout after the install: passed127.0.0.1:6379, or connect to a unix socketsocket()from seccomp.tests/test_contain.pypins that Landlock alone lets a pathname unix socket through (trap 2)real/includedtest_the_whole_parity_corpus_is_identical_through_the_child: 22 inputs (cases.jsonplus 9 real). It also passed from a real release build, and CI ran it underrequiredon Landlock ABI 7docker.socketoff, and the child can't reach/run/docker.socksystemctl is-enabled docker.socketsaysdisabled(gate 1, 2026-10-05).test_the_child_cannot_reach_the_hosts_sockets[/run/docker.sock]gets EPERM, andprocessorisn't in groupdocker/etc/processor/, releases,scripts/deploy.sh, Containment, Install, Rollback. AGENTS.mdack: completewith #26's digests; Watcher still matchesprocessor,ack: complete, and itsoutput_digestequals Watcher's recorded fingerprint for that input (tests/fixtures/parity/real/export.json)The live record, the first command handled as
processor:{"timestamp":"2026-10-06T19:21:02.698838Z","command_id":"01M49AK0VC91BHX7V70XS9JJ4W","action":"ack","reason":"complete","input_digest":"6abb3bf14eba7131833a56a2c9e2d8afa2a00d753273945dc51f09de1e5567bd","output_digest":"sha256:4cc814c794ec78b4c9c9d707065ab625a2f15b2d28825c24281fde394e0dfd04","output_size_bytes":11791,"empty":false,"processor_version":"0.19.7+1","total_ms":1128.5}Its start record:
{"build":"fe19a2b6a06f","child_containment":"required","landlock_abi":6}.Under Stage A there were two more
ack: completerecords, with Watcher's fingerprint, at 17:03:02Z and 19:05:02Z. The second came after/etc/processorwas made root-only, and was served on the old process's in-memory credentials.What it is
processor._contain, stdlibctypes). Landlock gives it read-only access to an allowlist derived fromsys.pathand the loaded libraries, with noexecve, no TCP, and scoped signals and abstract sockets. seccomp blocks every socket andio_uring. It refuses to contain a process with a second thread.processor;/etc/processorroot-only; the key throughLoadCredential=;ProtectSystem=strictandProtectHome=yes. Releases follow the cohort standard (broker#22 / status#9):scripts/deploy.shwith verify, the smoke run as the service, switch-back on failure, and copied rather than hardlinked from the uv cache.Known limits
RLIMIT_AS.exedevowns the releases and has sudo, so that boundary is the service against its code, not the operator against production (as Status's R2).Follow-ups
origin/main(status#12).docker.socketbox, which waits on the operator.