Compiling the artifact silently disabled half of enforcement - #87
Merged
Conversation
Drove the whole product from a bare `claude` in a terminal and the `session-sitter` command, with no VS Code anywhere, against an isolated CLAUDE_CONFIG_DIR and a bare on-disk remote. Four defects fell out of the run; each is fixed at the one place every caller routes through, with a test that fails when either half of the fix is reverted. `CLAUDE_CONFIG_DIR` was read nowhere in src/. Every Claude path resolved against os.homedir(), so `session-sitter status` walked the REAL session store however the variable was set — exiting 0 and printing a plausible table, which is the worst way to ignore an isolation request. Every doc that tells you to isolate a test run with it was wrong for half the CLI. One `claudeDir()` in hooks/paths.ts now owns the question, and the session store, the window registry, the cross-window bus, the corpus uploader and the data-dir fallback route through it. Bob and Codex stay anchored to the home directory: CLAUDE_CONFIG_DIR says nothing about where other tools keep their sessions. `policy compile` and `policy ablate` were unreachable from the shipped command. They existed only as `node .../lib/policy/cli.js compile` — while that file's own usage text prints "session-sitter policy", naming an entry point that rejected the subcommand. So a terminal user could not publish the versioned artifact at all: every decision record carried `rev: null`, `explain --rev` had nothing to resolve, and the prompt-cache stability the artifact exists for was IDE-only. Both are forwarded unparsed now, the same contract `explain` already had. PreToolUse read a different policy from PermissionRequest, and compiling the artifact silently turned half of enforcement off. PermissionRequest fires only when Claude Code was already going to prompt, and it does not prompt for reads — PreToolUse is the only hook that sees them, and its own header names `cat .env` as the hole it exists to close. It loaded clauses through `loadClauses`, which never consults the artifact. Measured in a live session before the fix: `cat .env` ran, returned API_KEY and DB_URL to the model, and wrote no decision record at all. Publishing the reviewed, cache-stable artifact made enforcement strictly weaker and said nothing. Both hooks now use one loader. `rewritten` asked whether the verdict carried an `updatedInput`, not whether the input changed. Rung 7's human-allow returns the original input, so a human answering plain "allow" was recorded `rewritten: true` and rendered by `log` as a correction. `learn` mines that field, so it asserted the correction lane had produced a safer form of a call it never touched — the mirror of the wave-2 defect where a corrected call was recorded as an allow of the original. Compared with util.isDeepStrictEqual at the one place the flag is computed. Also: `policy explain` now says when escalation is configured, because it cannot ask a human and was reporting a flat refusal for a call a live session would put to a person — the only place explain and the hook legitimately differ, now visible instead of a surprise. `scripts/time-permission-hook.js` expected `actor: 'policy'` for the correction lane, printing a failure against correct behaviour. `docs/evidence/setup.sh` resolved its own directory after cd-ing away from it, so it only worked when invoked by absolute path. docs/TERMINAL.md is the terminal-only path end to end — install into an isolated config, confirm the hooks fire, compile the artifact, watch it decide a real session, read the trail, what rung 7 does when nobody can answer, and troubleshooting. Every command in it was run; every number measured. It also states plainly what genuinely needs an IDE (applying a decision into an already-paused agent, and the panel) rather than leaving that to be discovered. Rung 7 cannot hang in a non-interactive terminal, and this is the measurement: escalation off denies in 111 ms; on with no daemon denies in 109 ms without queueing a question nobody can receive; on with a daemon waits to a 45 s deadline under a 55 s hard ceiling, below the event's own 60 s budget, and a deadline that passes is a deny.
# Conflicts: # docs/README.md
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.
Make the terminal path work, proven by a real run
Base
ss/learned-yellow. Branchpr/terminal-pathonfork.The ask was to prove Session Sitter works from a bare
claudeterminal session and from thesession-sittercommand, with no VS Code anywhere — and to fix what did not. A real run is thedeliverable. It happened, on 2026-09-04, against
claude2.1.257 / macOS 24.6.0 / Node v25.1.0,into an isolated
CLAUDE_CONFIG_DIR, a scratch project under/tmp, and a bare on-disk git remote.Nothing read or modified a real configuration.
It surfaced four defects, one of them a silent weakening of enforcement, plus three smaller
honesty bugs. All fixed.
make checkgreen: 111 files / 2,741 tests, up from 106 / 2,722.What the run proved, step by step
claude plugin marketplace add+installare plain CLI subcommands. Hooks fire from a bareclaudeSessionStartpins a revisionSessionStartregisters a session; the revision is stamped per decision, and only on the compiled-artifact path — which was unreachable from a terminal (defect 2)env | wc -l→allow / deterministic, 4 ms in-hook, p50 62.4 ms whole-process, 0 model callsgit push --force→--force-with-lease, 10 ms, the colleague's commit surviveddenied — practices §team-fs-004: A generated directory is rebuilt…— a citation, not "Blocked by classifier"log/digest/export --htmllearnover the traildaemonwith no IDEexplainagrees with the hookThe headline, verbatim from the real session
The agent was never told about the rewrite. It worked it out from git's own error:
mul.js— the commit a blind--forcewould have destroyed — is still on the remote.Rung 7 in a non-interactive terminal — the highest-risk unknown
It cannot hang. Bounded three ways, and the ceiling is not advisory:
SESSION_SITTER_ESCALATE=off(default)escalate=on, no daemonescalate=on, daemon liveThe 55 s ceiling exists because
hooks.jsonallowsPermissionRequest60 s, and being killedmid-wait returns no JSON — which Claude Code reports as a hook error rather than a decision. A
deadline that passes is recorded
actor: 'timeout'with a note saying a human was asked and did notanswer. Silence is never approval.
And the wait is not a dead end even with no Telegram. The default
stubchannel is a pair ofdirectories. Measured end to end: hook wrote the ask → daemon posted it after 1.19 s → a reply
dropped as
echo allow > .supervisor-state/inbox/<id>.txt→ hook returnedallowat 3.16 s,recorded
actor: "human". Deliberately not marked settled: one human saying yes once is not astanding rule.
The defects
1.
CLAUDE_CONFIG_DIRwas read nowhere insrc/session-sitter statuswalked the real session store however the variable was set. It exited 0and printed a plausible table — the worst possible way to ignore an isolation request, and it means
every doc telling you to isolate a test run with it was wrong for half the CLI. It also breaks any
user legitimately running Claude Code with a non-default config dir: they get a silently empty
worklist.
One
claudeDir()inhooks/paths.tsnow owns the question; the session store, window registry,cross-window bus, corpus uploader and the data-dir fallback route through it. Bob and Codex stay
anchored to
$HOME—CLAUDE_CONFIG_DIRsays nothing about where other tools keep their sessions,and relocating them would be inventing a convention.
2.
policy compileandpolicy ablatewere unreachable from the shipped commandThey existed only as
node .../lib/policy/cli.js compile— while that file's own usage text prints"session-sitter policy", naming an entry point that rejected the subcommand. Consequence: a
terminal user could not publish the versioned artifact at all, so every record carried
rev: null,explain --revhad nothing to resolve, and the prompt-cache stability the artifact exists for wasIDE-only. Both forwarded unparsed now, the same contract
explainalready had.3. PreToolUse read a different policy from PermissionRequest — so compiling turned half of enforcement off
The one that matters most.
PermissionRequestfires only when Claude Code was already going toprompt, and it does not prompt for reads.
PreToolUseis the only hook that sees them, and itsown header names
cat .envas the hole it exists to close. But it loaded clauses throughloadClauses, which never consults the compiled artifact.Measured in a live session, before the fix:
The read succeeded, the credentials reached the model, and no decision record was written at
all — while
policy explainsaidWOULD DENY · rung 3 · practices §team-sec-003. So publishingthe reviewed, cache-stable artifact made enforcement strictly weaker, silently. After the fix,
same call, same config:
Both hooks use one loader now. Two loaders were two possible answers to "what is the policy".
4.
rewrittenrecorded a human's plain "allow" as a correctionrewritten: verdict.decision.updatedInput !== undefinedasked whether the verdict carried aninput, not whether the input changed. Rung 7's human-allow returns the original input, so a human
answering "allow" was recorded
rewritten: trueand rendered bylogascorrect/rewritten.The command was byte-identical to the one asked.
This is the fifth instance of the shape wave 3 asked us to hunt.
session-sitter learnminesthis field, so the trail asserted the correction lane had produced a safer form of a call it never
touched — the exact mirror of the wave-2 defect where a corrected call was recorded as an allow of
the original. Both make the trail misdescribe what the safety mechanism did. Fixed with
util.isDeepStrictEqualat the single place the flag is computed, rather than by strippingupdatedInputfrom one caller — the wrong question would otherwise still be asked of every futureverdict that echoes its input back.
5–7. Three honesty bugs
policy explaindid not say when escalation was configured. It cannot ask a human, so itcorrectly reports what silence produces (
deny) — but its note read as a flat refusal for a call alive session would put to a person.
woulddeliberately still staysdeny.scripts/time-permission-hook.jsexpectedactor: 'policy'for the correction lane, printing✗ against correct behaviour. Pre-existing; fixed because this PR reports its numbers.
docs/evidence/setup.shresolveddirname $0aftercd-ing away, so it only worked wheninvoked by absolute path. Documented in EVIDENCE.md as the way to build the world, and it failed on
the obvious invocation.
Tests, mutation-proven
Every fix has a test written first, watched fail, then each half of the fix reverted independently to
confirm only that half's test fails. Both states reported.
claudeConfigDir.test.tsclaudeDirignores the variable → 3 fail · B store stops routing through it → 1 fail · C whitespace not treated as unset → 1 failcli/policySubcommands.test.tshooks/preToolUseArtifact.test.tsloadClauses→ 3 failhooks/rewrittenFlag.test.tspolicy/explainEscalate.test.tsAssertions are behavioural throughout: the artifact under test is compiled through the same
gatherCorpusthe CLI uses, and the escalation test drives the realaskHumanpoll loop with a realverdict file rather than asserting on a string literal.
Documentation
docs/TERMINAL.md(new, 526 lines), indexed first indocs/README.mdunder "Start here, with noIDE" — because gap #7 was that the terminal is second-class in the docs.
I chose a new file over extending
CLI.mdorPLUGIN.md: both are references and thorough ones, andthis is a path (install → verify → use → troubleshoot). Folding a walkthrough into either would
swell a reference; TERMINAL.md links into them instead of restating them.
Every command in it was run by me, every number measured. It also states plainly what genuinely needs
an IDE — applying a decision into an already-paused agent, and the panel — rather than leaving that to
be discovered at 02:00.
CLI.mdgains reference sections for the two newly-reachable subcommands.Verification
plugin/libis committed and current. Latency re-measured after every source change, 50 processspawns per rung, all five verdicts ✓.
Safety
No
~/.claude/settings.jsonedit, no/plugin installagainst the real config — an isolatedCLAUDE_CONFIG_DIR,--plugin-dir, a scratch/tmpproject and a bare on-disk remote throughout.Every force push in the run went to
/tmp/ss-term/remote.git. No credential is printed or committed;the isolated config was pointed at
https://ete-litellm.ai-models.vpc.res.ibm.com, which servesaws/claude-opus-5. No content of~/.claude/projects,~/.claude/sessionsorhistory.jsonlwasread — and the first thing this PR fixes is the bug that made an isolated run touch that store at
all. All fixtures invented (
widget-lab,dev); home paths and the hostname redacted.Authored as Osher Elhadad. Pushed as an ordinary create to a fresh branch — no force-push, no
history rewrite. No
gh pr createwas run; the command is handed back:gh pr create --repo eranra/session-sitter --base main \ --head OsherElhadad:pr/terminal-path \ --title "Make the terminal path work, proven by a real run" \ --body-file /tmp/ss-pr/terminal.md