Skip to content

Host-observed proof ingestion: Claude Code hook, inbox reader, MCP surface (E05, #409) - #446

Merged
TheAmericanMaker merged 5 commits into
mainfrom
feat/proof-ingestion
Sep 28, 2026
Merged

TheAmericanMaker merged 5 commits into
mainfrom
feat/proof-ingestion

Conversation

@TheAmericanMaker

Copy link
Copy Markdown
Member

What

The host-tool-result path 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 fresh O_CREAT|O_EXCL|O_NOFOLLOW 0600 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 in denyWrite + Write/Edit deny rules; sandbox enabled, allowUnsandboxedCommands: false, failIfUnavailable: true.
  • core/engineering/host-observations.ts — ingestHostObservations: reads only inbox/*.json regular 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 via attestationForHostObservation(capabilities), verified policy, processed files rotate to inbox/processed/ (never deleted).
  • ProofObligation.command? (types + validation).
  • MCP change action ingest_observations: no host-config channel exists, so it passes unprotected — every proof it ingests is caller and discharges nothing, stated in the result; tool_result_path/capabilities request fields are rejected.
  • docs/engineering/host-observations.md.

assurance_policy stays verified; cooperative unreachable; VERIFIED_ACCEPTANCE_INTEGRATIONS untouched. No version bump.

Tests (tests/engineering-host-observations.test.mjs, 13 tests; suite 1308/1308 x2; npm run build clean)

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; true and 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/true and /usr/bin/false. Hook fired twice: inbox/ got two records whose tool_use_ids match the session's tool_use blocks — /usr/bin/true PostToolUse exit_code: 0, /usr/bin/false PostToolUseFailure exit_code: 1; two .status = wrote; no .err; nothing outside the namespace. Reader under protected: true → passed, observed, discharges; false → failed, observed, no discharge. Sandbox echo in the transcript: bubblewrap active, dangerouslyDisableSandbox disabled, the instantiated settings file listed in denyWithinAllow.

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_path must stay unprotected and every proof from this hook is caller. Also open: a host-configuration channel for tool_result_path (MCP is caller by construction until then); ~/.claude/hooks/ as hook dir untested.

Refs #409

…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
TheAmericanMaker merged commit 05e2021 into main Sep 28, 2026
6 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant