Skip to content

Compiling the artifact silently disabled half of enforcement - #87

Merged
OsherElhadad merged 2 commits into
eranra:mainfrom
OsherElhadad:pr/terminal-path
Sep 4, 2026
Merged

Compiling the artifact silently disabled half of enforcement#87
OsherElhadad merged 2 commits into
eranra:mainfrom
OsherElhadad:pr/terminal-path

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Make the terminal path work, proven by a real run

Base ss/learned-yellow. Branch pr/terminal-path on fork.

The ask was to prove Session Sitter works from a bare claude terminal session and from the
session-sitter command, with no VS Code anywhere — and to fix what did not. A real run is the
deliverable.
It happened, on 2026-09-04, against claude 2.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 check green: 111 files / 2,741 tests, up from 106 / 2,722.


What the run proved, step by step

# Step Result
1 Install into an isolated config, hooks register claude plugin marketplace add + install are plain CLI subcommands. Hooks fire from a bare claude
2 SessionStart pins a revision ⚠️ Premise corrected. SessionStart registers a session; the revision is stamped per decision, and only on the compiled-artifact path — which was unreachable from a terminal (defect 2)
3 Green at rung 1, no model call env | wc -lallow / deterministic, 4 ms in-hook, p50 62.4 ms whole-process, 0 model calls
4 Rung 2 rewrite, agent proceeds ✅ Real transcript below. git push --force--force-with-lease, 10 ms, the colleague's commit survived
5 Denial citing a clause denied — practices §team-fs-004: A generated directory is rebuilt… — a citation, not "Blocked by classifier"
6 Rung 7 in a terminal, both ways Cannot hang. Three bounded outcomes, all measured — see below
7 log / digest / export --html ✅ All three read what the hooks just wrote. HTML is genuinely self-contained (zero external refs)
8 learn over the trail ✅ Honest "nothing cleared the bar": 0 proposals, 28 shapes below the floor, 3 retirements, 0 model calls
9 daemon with no IDE ✅ 403 passes, 3 records transitioned, clean SIGTERM
10 explain agrees with the hook ✅ 7 of 9 exactly. The 2 that differ are the human-answered calls, and the difference is now stated (fix 5)

The headline, verbatim from the real session

The agent was never told about the rewrite. It worked it out from git's own error:

⏺ I ran the command exactly as given. It failed. Full output:
    To /tmp/ss-term/remote.git
     ! [rejected]        main -> main (stale info)

  (stale info) is the lease-check rejection, not the ordinary non-fast-forward one. A plain
  git push --force doesn't produce that message […] So something in this environment is
  turning the bare --force into a leased force push.

mul.js — the commit a blind --force would 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:

Configuration Behaviour Measured
SESSION_SITTER_ESCALATE=off (default) Denies at once, naming three ways to resolve it 111 ms wall, 4 ms in-hook
escalate=on, no daemon Denies at once. Refuses to queue a question nobody can receive, and names the daemon command 109 ms wall, 5 ms in-hook. No ask written
escalate=on, daemon live Writes an ask, polls, denies at the deadline 45 s default, 55 s hard ceiling

The 55 s ceiling exists because hooks.json allows PermissionRequest 60 s, and being killed
mid-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 not
answer. Silence is never approval.

And the wait is not a dead end even with no Telegram. The default stub channel is a pair of
directories. 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 returned allow at 3.16 s,
recorded actor: "human". Deliberately not marked settled: one human saying yes once is not a
standing rule.


The defects

1. CLAUDE_CONFIG_DIR was read nowhere in src/

session-sitter status walked the real session store however the variable was set. It exited 0
and 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() in hooks/paths.ts now 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 $HOMECLAUDE_CONFIG_DIR says nothing about where other tools keep their sessions,
and relocating them would be inventing a convention.

2. 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. Consequence: a
terminal user could not publish the versioned artifact at all, so every record carried rev: null,
explain --rev had nothing to resolve, and the prompt-cache stability the artifact exists for was
IDE-only. Both forwarded unparsed now, the same contract explain already had.

3. PreToolUse read a different policy from PermissionRequest — so compiling turned half of enforcement off

The one that matters most. 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. But it loaded clauses through
loadClauses, which never consults the compiled artifact.

Measured in a live session, before the fix:

CALL  Bash "cat .env"
  -> API_KEY=placeholder DB_URL=localhost

The read succeeded, the credentials reached the model, and no decision record was written at
all
— while policy explain said WOULD DENY · rung 3 · practices §team-sec-003. So publishing
the reviewed, cache-stable artifact made enforcement strictly weaker, silently. After the fix,
same call, same config:

CALL  Bash "cat .env"
  -> [ERROR] denied — practices §team-sec-003: Secrets are never read into the transcript

Both hooks use one loader now. Two loaders were two possible answers to "what is the policy".

4. rewritten recorded a human's plain "allow" as a correction

rewritten: verdict.decision.updatedInput !== undefined asked whether the verdict carried an
input, not whether the input changed. Rung 7's human-allow returns the original input, so a human
answering "allow" was recorded rewritten: true and rendered by log as correct / 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 learn mines
this 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.isDeepStrictEqual at the single place the flag is computed, rather than by stripping
updatedInput from one caller — the wrong question would otherwise still be asked of every future
verdict that echoes its input back.

5–7. Three honesty bugs

  • policy explain did not say when escalation was configured. It cannot ask a human, so it
    correctly reports what silence produces (deny) — but its note read as a flat refusal for a call a
    live session would put to a person. would deliberately still stays deny.
  • scripts/time-permission-hook.js expected actor: 'policy' for the correction lane, printing
    ✗ against correct behaviour. Pre-existing; fixed because this PR reports its numbers.
  • docs/evidence/setup.sh resolved dirname $0 after cd-ing away, so it only worked when
    invoked 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.

Test Fails before Mutations proving non-vacuity
claudeConfigDir.test.ts 4 of 6 (the 2 fallback halves pass) A claudeDir ignores the variable → 3 fail · B store stops routing through it → 1 fail · C whitespace not treated as unset → 1 fail
cli/policySubcommands.test.ts 4 of 4 A drop compile route → 1 · B drop ablate route → 1 · C revert help text only → 1 · D revert the unknown-subcommand message only → 1
hooks/preToolUseArtifact.test.ts 3 of 4 (fail-open passes) revert to loadClauses → 3 fail
hooks/rewrittenFlag.test.ts 1 of 3 — isolates the bug exactly A revert to the presence test → 1 · B comparison without the presence guard → 1 (a plain deny would report rewritten)
policy/explainEscalate.test.ts 1 of 2 A drop the conditional → 1 · B make it unconditional → 1

Assertions are behavioural throughout: the artifact under test is compiled through the same
gatherCorpus the CLI uses, and the escalation test drives the real askHuman poll loop with a real
verdict file rather than asserting on a string literal.


Documentation

docs/TERMINAL.md (new, 526 lines), indexed first in docs/README.md under "Start here, with no
IDE" — because gap #7 was that the terminal is second-class in the docs.

I chose a new file over extending CLI.md or PLUGIN.md: both are references and thorough ones, and
this 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.md gains reference sections for the two newly-reachable subcommands.


Verification

typecheck  ✓
lint       ✓ (1 pre-existing warning in SessionManager.test.ts, untouched)
tests      111 files / 2741 tests passed   (base: 106 / 2722)
guards     no-python ✓ · settings ✓ (38 settings, 0 problems) · naming ✓
           · links ✓ (154 checked, 0 broken) · plugin-lib ✓ (make plugin run last)

plugin/lib is committed and current. Latency re-measured after every source change, 50 process
spawns per rung, all five verdicts ✓.

Safety

No ~/.claude/settings.json edit, no /plugin install against the real config — an isolated
CLAUDE_CONFIG_DIR, --plugin-dir, a scratch /tmp project 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 serves
aws/claude-opus-5. No content of ~/.claude/projects, ~/.claude/sessions or history.jsonl was
read — 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 create was 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

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.
Copilot AI lite review requested due to automatic review settings September 4, 2026 18:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings September 4, 2026 18:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OsherElhadad
OsherElhadad merged commit c50f5fd into eranra:main Sep 4, 2026
7 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.

2 participants