Skip to content

docs: feasibility spike — Claude Code 2.1.263 as the E01 pilot host - #416

Merged
TheAmericanMaker merged 3 commits into
mainfrom
spike/claude-code-elicitation
Sep 18, 2026
Merged

TheAmericanMaker merged 3 commits into
mainfrom
spike/claude-code-elicitation

Conversation

@TheAmericanMaker

Copy link
Copy Markdown
Member

Bounded feasibility spike for E01 (#399) against one host/client: Claude Code 2.1.263 on Linux as the MCP client. Registers nothing — VERIFIED_ACCEPTANCE_INTEGRATIONS stays empty, cooperative stays unauthorized, no E02/E05/E06/E07 code. Docs + disposable fixtures only; nothing under core/, tests/, mcp-server/, or extensions/ changes, and spikes/ is outside tsconfig and the npm files list. Do not merge until reviewed.

Report: docs/engineering/spike-claude-code-2026-09-18.md. Fixtures: spikes/claude-code-2.1.263/ (parametrized; no home or scratch paths committed).

What was tested and what actually happened

All runs headless (claude -p) in a disposable directory with --settings <file> and --mcp-config … --strict-mcp-config; no user/managed settings, hooks, MCP registrations, or sandbox state on the machine were changed. Each claim is marked observed / bundle / docs in the report.

Requirement Result
1. Presentation + accept/reject/cancel Client declares elicitation.form at initialize even headless; headless elicitInput → { action: "cancel" }, nothing minted. An interactive dialog exists (bundle strings). An Elicitation hook can answer for the user (bundle schema) — hook config is part of the boundary; not exercised (classifier refused; would be a simulated acceptance). The live human check was not performed; exact steps are written down.
2. Protected namespace + history permissions.deny on Write/Edit alone: python3 -c "open(…).write()" wrote the file (observed). OS sandbox + filesystem.denyWrite: redirect, python, cp, mkdir all EROFS (observed); config files are sandbox- and permission-protected except in bypassPermissions (docs). No host mechanism attests continuity → every acceptance on this host is cooperative under the contract until D3 settles a mechanism; a candidate is proposed.
3. Tool-result path the model can't fabricate PostToolUse/PostToolUseFailure hooks run outside the sandbox and delivered tool_use_id, command, stdout/stderr, and Exit code N into a denyWrite inbox; the model's Bash could neither write the inbox nor make the hook script write (observed). Limitation: failures arrive on PostToolUseFailure with the exit code as text.
4. Honest unsupported behaviour Headless → cancel; unregistered pair → needs-human-acceptance; missing sandbox deps → hard failure with failIfUnavailable (docs); unprotected hook path → caller; unknown history → cooperative.

Smallest feasible pilot configuration (proposal)

User-level or managed settings, never only a project file, never bypassPermissions: sandbox enabled with allowUnsandboxedCommands: false, failIfUnavailable: true, filesystem.denyWrite on the namespace; Write/Edit deny rules on it; PostToolUse + PostToolUseFailure hooks (matcher Bash) into a host-side ingestion entry writing to the protected inbox; no Elicitation/ElicitationResult hooks; interactive sessions only for acceptance. Provides per session storage_boundary: host-enforced and tool_result_path: protected; provides human_acceptance: mcp-elicitation unverified until the live check; does not provide protection: continuous-since-initialization.

Still unresolved

  • D1: the live check (a person at an interactive session; steps in the report).
  • D3: continuity attestation — no host mechanism; candidate design is an E02 decision at the maintainer's call.
  • D4: demonstrated here; needs the maintainer's acceptance of "user/managed settings + no bypassPermissions" as protected configuration.
  • Not covered: macOS/WSL2 (docs only), Windows native, other clients, the desktop app's dialog.

Verification: npm run build exit 0; npm test 971 pass / 0 fail (unchanged code); git diff --check clean; staged content scanned for usernames, home paths, and scratch paths — none.

Refs #399, #398

🤖 Generated with Claude Code

One host, one client, disposable fixtures, no global configuration
changed, no external provider spend, nothing registered. Evidence for
the maintainer's D1–D5, per requirement:

1. Presentation: the client declares elicitation.form even headless;
   elicitInput headless returns cancel (nothing minted); an interactive
   dialog exists (bundle); an Elicitation hook can answer for the user
   (bundle; not exercised). The live human check is still required and
   its exact steps are written down.
2. Namespace: permissions.deny on Write/Edit alone is bypassed by
   python3 (observed); the OS sandbox with filesystem.denyWrite refuses
   every shell path with EROFS (observed). No host mechanism attests
   protection continuity — every acceptance on this host is cooperative
   under the contract until D3 settles a mechanism (candidate given).
3. Tool results: PostToolUse/PostToolUseFailure hooks run outside the
   sandbox and delivered tool_use_id, command, stdout/stderr, and
   "Exit code N" into a denyWrite inbox the model could neither write
   nor make the hook script write (observed). Hook config protection
   comes from the sandbox's protected paths and the permission layer's
   protected paths, except in bypassPermissions.
4. Unsupported behaviour is honest at each layer.

Smallest feasible pilot configuration proposed (user/managed settings,
no bypassPermissions, no Elicitation hooks, PostToolUse+Failure hooks
into the protected inbox). Fixtures under spikes/, outside core, tests,
tsconfig, and the npm files list.

Refs #399

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…onfiguration

Maintainer review of the spike found that the proposed pilot configuration
denied writes to the namespace but not to the hook script, which runs
unsandboxed on every Bash call: a sandboxed model could rewrite it and
forge observations while escaping the sandbox. E4 had tested running the
script and rewriting settings, not editing its body.

Corrected: the hook is hardened (absolute interpreter, PATH=/usr/bin:/bin,
absolute command paths, nothing sourced) and settings-E5 puts the hook
directory and the settings file itself in denyWrite with matching
Write/Edit deny rules. E5 (observed, 13 Bash calls): append, replace,
create, chmod, rename, and symlink attacks on the hook all refused
(EROFS / EBUSY); the settings file, /usr/bin, the interpreter, and every
PATH directory refused; hashes unchanged; the hook still fired 13 times.
Finding: the parent directory, under the per-user temp root, IS writable
from the sandbox — only the explicit denyWrite protected the siblings, so
the pilot configuration now requires <ingest> and the settings file in
denyWrite explicitly and never under /tmp or the working directory.

tool_result_path: protected is claimed only for the E5 configuration; the
D3 candidate is reworked so the initialization marker is written only by
a script that is itself protected, with the SessionStart visibility left
as an E02 investigation. README path mismatch fixed; fixtures verified to
reproduce via envsubst. No production code; registry empty; cooperative
unauthorized.

Refs #399

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TheAmericanMaker

Copy link
Copy Markdown
Member Author

Revision: E5 (protect the ingestion hook itself)

Maintainer review found the first proposed configuration denied writes to the namespace but not to the hook script, which runs unsandboxed on every Bash call — a rewritten script would forge observations and escape the sandbox in one step. E4 had tested running the script and editing settings, not editing the script body. E4's configuration is withdrawn.

Corrected fixture settings-E5-protected-hook.json: denyWrite on the namespace, on the hook directory, and on the settings file itself, plus matching Write/Edit deny rules. Hook hardened: #!/usr/bin/sh, PATH=/usr/bin:/bin, every command by absolute path, nothing sourced.

E5 (observed, 13 Bash calls, hashes compared before/after):

Attempt Result
append / replace / create in / chmod / symlink over the hook Read-only file system
rename the hook directory Device or resource busy (read-only bind mount)
overwrite the settings file Read-only file system
write /usr/bin, append to /usr/bin/sh Read-only file system / Permission denied
plant a file in every PATH directory (incl. ~/.bun/bin, ~/.cargo/bin, ~/.local/bin, ~/bin, /usr/local/bin) all Read-only file system or not mounted; nothing planted
touch the hook directory's parent succeeded — it is under Claude Code's per-user temp root, which the sandbox allows; only the explicit denyWrite protected the siblings
node --version succeeded; the hook fired for all 13 calls

Script and settings hashes unchanged after the run.

Consequences in the report: tool_result_path: protected is claimed only for the E5 configuration; the pilot configuration now requires <ingest> and the settings file in denyWrite explicitly, never under /tmp, the per-user temp root, or the working directory, with the hardened script; ~/.claude/hooks/ is noted as documented-protected but untested. The D3 candidate is reworked: the initialization marker is written only by a script that is itself protected (E5-passing config first), with SessionStart visibility of effective settings left as an E02 investigation. README path mismatch fixed; fixtures verified to reproduce via envsubst.

Head: 6b34c17. npm run build exit 0; npm test 971/971; npm pack --dry-run shows zero spikes/ or docs/ entries; git diff --check clean; no private paths in the diff. Still: registry empty, cooperative unauthorized, no E02/E05/E06/E07 code, #399 open.

…cted

Second maintainer finding of the same class: the E5 hook still wrote its
error and status logs into the parent directory the model can write, and
the hook runs outside the sandbox, so a planted symlink turned its own
redirect into an unsandboxed write primitive.

E6a (observed, separate disposable root, against the E5 hook): with
hook-err.log symlinked to the script, the hook truncated its own script
to 0 bytes on the next Bash call (ingestion DoS); with hook-status.log
symlinked into the inbox, three "hook-wrote-0" lines landed at an
attacker-chosen path inside the evidence namespace. The E5 hook is
withdrawn.

Corrected hook: every write — payload, stderr, status — inside the
namespace, one fresh uniquely named file per event, set -C so each ">"
is O_CREAT|O_EXCL and refuses any symlink (proved locally under this
host's /usr/bin/sh for dangling and existing links), no ">>" anywhere.
E6b (observed): symlinks at every path the hook writes refused with
EROFS; symlinks at the old paths planted but never opened; a symlink from
the writable cwd into the namespace is refused on the resolved path
(confirmed with bwrap --ro-bind directly); script intact; 10 inbox and
20 log files, all hook-written and uniquely named; no symlink inside
the namespace.

Report: the requirement is generalized — tool_result_path: protected
needs every path the hook reads, executes, AND writes to be protected —
and carried into the D4 answer, the pilot configuration, and the E05
note. Still: registry empty, cooperative unauthorized, no production
code, #399 open.

Refs #399

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TheAmericanMaker

Copy link
Copy Markdown
Member Author

Revision: E6 (every path the unsandboxed hook writes)

Second finding of the same class: the E5 hook still wrote hook-err.log and hook-status.log into the parent directory E5 item 7 proved writable; the hook runs outside the sandbox, so a planted symlink turned its own redirect into a write primitive. The E5 hook is withdrawn.

E6a — the primitive, against the E5 hook (separate disposable root):

Planted symlink Observed
hook-err.log → hooks/observe.sh on the next Bash call the hook truncated its own script to 0 bytes — ingestion denial of service
hook-status.log → <namespace>/inbox/forged.json (alone) inbox/forged.json created inside the evidence namespace with three hook-wrote-0 lines — attacker-chosen path, written by the trusted process

Corrected hook: every write (payload, stderr, status) inside the namespace; one fresh uniquely named file per event; set -C so each > is O_CREAT|O_EXCL and refuses any symlink, dangling or not (proved locally under this host's /usr/bin/sh); no >> anywhere; absolute interpreter, fixed PATH, absolute commands, nothing sourced.

E6b — against the corrected hook (observed): symlinks at inbox/planted.json, hook-logs, hook-logs/planted.err → Read-only file system; symlinks at the old log paths planted (parent writable) but never opened — targets untouched; a symlink from the writable cwd into the namespace is refused on the resolved path (confirmed directly with bwrap --ro-bind); script hash intact; 10 inbox files + 20 log files, all hook-written and uniquely named; zero symlinks inside the namespace.

Requirement, generalized (report § requirement 3, § D4, pilot configuration, E05 note): tool_result_path: protected requires that every path the unsandboxed hook reads, executes, and writes is protected — the script, its directory, its interpreter, every PATH directory, the settings naming it, and every file it writes: payload, error log, status log, temp and lock files — written as fresh uniquely named files with O_CREAT|O_EXCL (or O_NOFOLLOW fixed paths), never through a symlink it did not create. The real E05 ingestion entry has the same exposure for its own error/status output and is bound by the same list.

Head: 50b45b3. npm run build exit 0; npm test 971/971; npm pack --dry-run shows zero spikes//docs/ entries; git diff --check clean; no private paths. Registry empty; cooperative unauthorized; no E02/E05/E06/E07 code; #399 open; E02/E03 blocked; E09 on hold. Not merged.

@TheAmericanMaker
TheAmericanMaker merged commit ddb4f36 into main Sep 18, 2026
6 checks passed
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