Repository navigation
Host-observed proof ingestion: Claude Code hook, inbox reader, MCP surface (E05, #409) - #446
Merged
Merged
Conversation
…rface (E05, #409) A PostToolUse/PostToolUseFailure hook (hosts/claude-code/observe.mjs) that runs outside the model's sandbox and writes one observation per Bash call into <namespace>/inbox/ as a fresh O_CREAT|O_EXCL|O_NOFOLLOW file, and a reader (core/engineering/host-observations.ts) that turns each observation into a proof through ingestProof. - attested_by comes only from attestationForHostObservation(capabilities): host-tool-result for tool_result_path=protected, caller otherwise. - The reader parses only inbox/*.json regular files with strict validation; symlinks, directories, oversized files, .err/.status are opaque or reported, never trusted, never deleted. Processed files rotate into inbox/processed/. - Commands match obligations byte-equal; no match or an ambiguous match yields no proof. Proof id and idempotency key derive from (attempt, host, tool_use_id) so a re-read replays rather than duplicates. - ProofObligation gains an optional `command`. - MCP `change` action ingest_observations: no host-configuration channel exists yet, so it passes unprotected and says so; a tool_result_path or capabilities request field is rejected. - settings.template.json puts namespace, hook dir, interpreter and the settings file in denyWrite with matching Write/Edit deny rules. Layer map: host-observations sits above proofs and may import node:fs and node:crypto in addition to the store's allowance.
) Install steps with placeholders, what the sandbox protects versus what the operator attests, retention, and the benign live check on Claude Code 2.1.283. States plainly that the adversarial E5/E6 re-runs have not been done for this hook placement and are required before any proof from it is treated as host-tool-result in a pilot.
…e tree; hook refuses symlinked ancestors (#409) Review of #446, findings 2, 4, 5, 6. Reader (core/engineering/host-observations.ts): - observation-predates-attempt: ended_at before attempt.created_at is refused. - observation-outside-workspace: cwd must realpath to the workspace root the store was opened from (<root>/../..; overridable with workspaceRoot). - tree-activity-after-run: an activity entry or a later Bash observation in the same session, ended strictly after the check and at or before the candidate's captured_at, refuses the check naming the later tool_use_id. Run-then-capture with nothing between is unchanged (E01's valid proof fixture timings still discharge); the contract's signal is stability, not timestamp order. No session binding: nothing ties an attempt to a session. - Activity entries ({schema, kind, host, event, tool_name, tool_use_id, session_id, cwd, ended_at}) parse with the same closed-shape strictness, never become proofs, rotate with observations, and are kept while an observation they gate is still in the inbox. - readInboxFile exported with an injectable lstat so a test can make the O_NOFOLLOW open matter (an lstat that lies about a symlink; ELOOP refused). Hook (hosts/claude-code/observe.mjs): - Before any write: realpath(namespace) === namespace, and after mkdir the inbox and hook-logs realpaths equal join(namespace, ...). A symlinked ancestor (.codecarto -> elsewhere) now fails closed: nothing written, one stderr line, exit 0. The operator creates the namespace as a real directory before the first session. - Edit/Write/MultiEdit/NotebookEdit PostToolUse/PostToolUseFailure record an activity entry with nothing from tool_input; Bash unchanged. Template: matcher Bash|Edit|Write|MultiEdit|NotebookEdit; .codecarto parent in denyWrite and permissions.deny; interpreter and settings file mirrored into permissions.deny. Docs: install from a reviewed checkout with the hash computed at install time; node path must be a real file, not a shim or symlink; the sandbox echo paragraph no longer claims what the truncated echo did not show; cross-session and out-of-band edits are stated as unobserved and operator-attested. Tests: 13 -> 28 in engineering-host-observations; reviewer mutants M3b, M9 now killed; new mutants for each added check killed.
…igations (#409) Second review of #446 found two documentation gaps and no code defect. The template denies the model every write under .codecarto/, and the analysis pipeline writes its phase output under .codecarto/findings/ with the model's Write tool. The docs said only that the MCP server is unaffected, which read as "CodeCartographer is unaffected". They now say the settings are for engineering-pilot sessions only, and why the deny must stay on the parent rather than be narrowed back. Every Bash call counts as tree activity, check commands included, since they can modify the tree. So run O1, run O2, capture refuses O1. The docs now say to capture first and then run the checks. Runs after the capture are covered by the gate's re-read of the tree. Also notes that a refused non-check Bash call keeps its session's later records in the inbox until an operator removes it.
…409) test-windows failed 11 tests on this branch, and 4 on the first head. It never passed. Three reader tests reduced reported paths with split("/").pop(). On Windows the reader reports backslash paths, so the keys never matched. They now use path.basename. The remaining eight need POSIX. The hook refuses any namespace that is not an absolute POSIX path, and it needs O_NOFOLLOW, which Windows does not have, so on Windows it writes nothing. That is the intended behaviour, not a bug to route around. The reader's O_NOFOLLOW test has the same dependency. These eight are skipped on win32 with the reason stated once, following the existing skip in pi-command-handlers. The doc now says the hook is POSIX-only and that on Windows only the reader's lstat check guards the inbox.
TheAmericanMaker
added a commit
that referenced
this pull request
Sep 28, 2026
…dapter Both PRs added a codecarto_change action and a new core/engineering module, so four files conflicted. Each resolution keeps both sides: - core/engineering/index.ts re-exports acceptance.ts and host-observations.ts. - mcp-server/engineering.ts: CHANGE_ACTIONS has request_acceptance and ingest_observations. The dispatch keeps gate's working-tree re-read (cwd) and adds both cases. The attempt_id description names all three actions. The requestAcceptanceAction body kept its closing brace, which the conflict hunk had dropped. - tests/engineering-contract.test.mjs: acceptance and host-observations both sit at layer 5, both are listed as impure, and both are allowed the fs/path/crypto imports. Build clean, npm test 1347/1347 twice, packed smoke 12/12.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The
host-tool-resultpath for Claude Code (E05):hosts/claude-code/observe.mjs— PostToolUse/PostToolUseFailure hook for Bash. Runs outside the sandbox; writes one observation (identity, command, exit code, output digests — never output text) per call into<namespace>/inbox/as a freshO_CREAT|O_EXCL|O_NOFOLLOW0600 file; refuses symlinked namespace dirs; executes nothing from its payload; always exits 0.hosts/claude-code/settings.template.json— placeholders only; namespace, hook dir, interpreter and settings file indenyWrite+Write/Editdeny rules; sandbox enabled,allowUnsandboxedCommands: false,failIfUnavailable: true.core/engineering/host-observations.ts—ingestHostObservations: reads onlyinbox/*.jsonregular files (lstat + O_NOFOLLOW open + fstat), strict closed-shape validation, byte-equal command → obligation match (none/ambiguous → reported, no proof), deterministic proof id + idempotency key from(attempt, host, tool_use_id), attestation only viaattestationForHostObservation(capabilities),verifiedpolicy, processed files rotate toinbox/processed/(never deleted).ProofObligation.command?(types + validation).changeactioningest_observations: no host-config channel exists, so it passesunprotected— every proof it ingests iscallerand discharges nothing, stated in the result;tool_result_path/capabilitiesrequest fields are rejected.docs/engineering/host-observations.md.assurance_policystaysverified;cooperativeunreachable;VERIFIED_ACCEPTANCE_INTEGRATIONSuntouched. No version bump.Tests (
tests/engineering-host-observations.test.mjs, 13 tests; suite 1308/1308 x2;npm run buildclean)protected → host-tool-result/observed/discharges · unprotected → caller/claimed/no discharge · missing capabilities never protected · malformed JSON / wrong schema / symlink /
dir.json/ oversized /.err+.status→ skipped+reported, no proof, nothing moved · authority/discharges/attested_by/provenance in payload → refused · no matching obligation (incl.cmd; trueand prefix) → no proof · two matching → ambiguous, no proof · replay → one proof; same id different bytes → conflict refused · exit 7 → failed, no discharge; no exit code → blocked · hook: one payload → exactly one inbox file + one status, 0600, nothing outside · hook O_EXCL: pre-created name not overwritten, dangling symlink not followed, symlinked inbox refused, garbage stdin → no inbox file · hook+reader end to end.Mutation (all bite): attestation gate forced to host-tool-result; inbox-only filter removed; symlink/regular-file check removed; exact match → substring; idempotency key ignores tool_use_id.
Benign live check (Claude Code 2.1.283, throwaway project, template instantiated with tmp paths,
claude -p --settings)Model ran
/usr/bin/trueand/usr/bin/false. Hook fired twice:inbox/got two records whosetool_use_ids match the session'stool_useblocks —/usr/bin/truePostToolUseexit_code: 0,/usr/bin/falsePostToolUseFailureexit_code: 1; two.status=wrote; no.err; nothing outside the namespace. Reader underprotected:true→passed,observed, discharges;false→failed,observed, no discharge. Sandbox echo in the transcript: bubblewrap active,dangerouslyDisableSandboxdisabled, the instantiated settings file listed indenyWithinAllow.Not done — read before any pilot use
The adversarial E5/E6 re-runs (D4) have NOT been performed for this hook placement. The live check attempted no writes, symlink plants, or tamper against the namespace, hook dir, interpreter, or settings; whether the model's Bash can reach them from this layout is unverified. Until a maintainer-supervised session repeats E5/E6 against this exact layout,
tool_result_pathmust stayunprotectedand every proof from this hook iscaller. Also open: a host-configuration channel fortool_result_path(MCP iscallerby construction until then);~/.claude/hooks/as hook dir untested.Refs #409