fix(runtime,bash-write): CONFIRM recourse, python3 -c content gate, Kimi prompt normalization - #97
Merged
Merged
Conversation
…esGate designFilesGate (the multi-file apply_patch path) only ran pluginsWriteGuard and stateFileGate, never htmlCssOnlyGate — unlike the Write/Edit path in design.ts:69, which correctly blocks. Measured in a real codex exec session (harness v0.1.87 / Codex CLI 0.146.0): the design-expert agent wrote .tsx/.vue/.astro files via apply_patch without being blocked, while the same write via Write/Edit was refused. The exclusion had been documented in the docstring as an assumed decision (owner D2); the owner has now revised it. Add htmlCssOnlyGate to designFilesGate's check loop, at the position that reproduces the Write/Edit path's priority order, and rewrite the docstring (D2 revised, known-gaps list updated: Move to:, runDesignChecks absent in POST). Fan designPassNotice per file in handle-post.ts so apply_patch emits one notice line per real file instead of none. designGate itself stays on the raw envelope: fanning it would move recordPost onto a branch that reads unresolved filePath (apply_patch paths are relative) and can degrade state — this was tried and reverted after being caught. POST is advisory-only regardless: PostToolUse cannot undo a write that already happened. Add test/design-apply-patch-guards.test.ts: PRE matrix (Write/Edit/apply_patch x .tsx/.html x design/non-design agent), multi-file envelope with a single violating file, plus two POST witnesses that go through handlePost and are proven falsifiable by mutation.
…ate html/css on pipeline phase
Three fixes, all validated in a real `codex exec` session (Codex CLI
0.146.0, real design-expert, on-disk evidence):
1. Shell read credit (design-helpers.ts + new design-read-credit.ts).
recordPost only credited pipeline progression on event.tool ===
"Read". Codex has no such tool - the agent reads via sed/cat, so
state stayed stuck at phase 0 and every phase gate became
unpassable: a real session measured 0 files produced where Claude
Code produced 7. A new Bash branch reuses shellReadRefPaths
(existing parser, unmodified) and routes each read path through the
same logic as the Read branch (classifyCorpusRead ->
recordCorpusRead, else recordRead).
2. apply_patch gates in the design pipeline (design.ts,
design-files-gate.ts). uiDesignSkillGate moved into design.ts ahead
of the agent_id early-return, preserving its all-agents scope
(putting it inside designFilesGate would have relocated the
asymmetry instead of closing it). The Gemini precondition stays in
designFilesGate (design-agent scope, consistent with where it was
already wired).
3. New htmlCssPipelineGate (gates-pipeline.ts), wired on both the
Write/Edit and apply_patch paths. A .html/.css file written by the
design agent now requires currentPhase >= 3 AND designSystemValid
AND design-system.md present on disk - all three, since state alone
can lie after the file is deleted, and the file alone proves
nothing for an agent that's just starting. Before this gate, "design
system validated before generation" was only enforced by
geminiCreateGate, which is opt-in via FUSE_DESIGN_GEMINI and
therefore inactive by default.
canonicalizeCodexShellTool (codex-shell-tool.ts, normalize.ts,
adapters/codex/index.ts) maps Codex's shell tool_name to "Bash". Kept
but NOT observed under real conditions: four captured payloads all
show tool_name already relabeled to "Bash" by Codex itself; the Code
Mode path that would exercise the mapping could not be reproduced, so
the function is kept out of caution rather than removed on an
incomplete measurement.
Real validation results:
- sed-based read intercepted and recorded in refs-read.log
- .tsx via apply_patch rejected by htmlCssOnlyGate, file absent from disk
- .html in phase 0 rejected ("phase 0/3, design-system.md not
validated"), file absent from disk
- after completing the pipeline: design-system.md then index.html both
accepted and present on disk
- non-regressions stay green: .md, .json, and a non-design agent
writing a .tsx
Known limitation: design state is located via projectLayout(opts.cwd),
so a write outside the cwd's tree finds no state and silently disables
the gates. Pre-existing defect, out of scope here, to be fixed
separately by anchoring on the session instead of cwd.
designGate located pipeline state via projectLayout(opts.cwd), where opts.cwd is process.cwd() of the HOOK process (cli/bin.ts:52) — not the design agent's own tree. When a design agent's pipeline started in one directory and wrote in another, the harness resolved a different cacheDir: neither the design-agent-active flag nor .design-state-<agentId> were found there, so designGate returned null (design.ts:46) BEFORE any gate ran. A plain change of working directory silently disabled the whole design pipeline — htmlCssOnlyGate, phase gate, quota, design-system validation. Reproduced in a real codex exec session: a .tsx forbidden to the design-expert was written to disk. Add resolveDesignCacheDir(sessionId, cwdCacheDir) in src/runtime/design-cache-resolve.ts: session dir if populated, else cwd dir if populated, else the session dir by default, at the new path ~/.fuse-harness/design-sessions/<sid>/. handle.ts computes it once and passes it to designLifecycle and to the pre/post contexts. designCacheDir is an OPTIONAL field on PreContext with fallback ?? mcpDir, so contexts built without it keep identical behavior. mcpDir (MCP/WebFetch cache) is untouched. Non-regression guarantee: the per-session path never existed before this commit, so it starts empty. For any session whose state already lives at cwdCacheDir, the first branch fails and resolution falls back to the prior value, unchanged. Behavior only differs when nothing exists at either location — exactly the bug case, where the harness previously let everything through. The change can only close that gap, never open a new one. Validated: - differential capture from a worktree detached at 1a61bb1: 508 cells (4 harness ids x events x tools x extensions x scopes x agent context x pre-existing state), 0 divergence, incl. 242 cells with pre-existing state at the old location — the decisive axis — checked non-degenerate (real DENY, not null) - real Claude Code session, design-expert: .tsx in its own tree REFUSED, .tsx in another tree REFUSED (previously allowed), .md ACCEPTED — all three verified with on-disk ls - unit test locks the bug case: SubagentStart in dir A, write in dir B is blocked; without the fix this test exits 0, so it is a real lock Known limit: Kimi's session_id could not be captured in a real session (provider billing quota exhausted, 403 usage limit) — Kimi is covered by the differential simulation and the fallback guarantee, not by a live run.
python3 -c invocations were blocked outright regardless of what the inline script did. The script is now scanned for actual file/process mutation (write-mode opens, pathlib mutators, shutil/os mutators, subprocess calls, pickle/json dump, exec/eval, ...), matching node -e's existing treatment: a read-only one-liner now passes, a mutating one still blocks. The heredoc form stays unconditionally blocked -- not reliably inspectable with a single-line regex. Parity fixtures/snapshot realigned: one allow case and one block case now coexist, coverage is reinforced, not weakened.
Claude Code and Codex send payload.prompt as a plain string, but Kimi 0.31.1 sends an array of content blocks on UserPromptSubmit -- measured live against a real payload. Every prompt-based detection reading that field under Kimi was getting an empty string as a result. A new promptText() helper normalizes both shapes: a string passes through unchanged (the identity branch, byte-identical for Claude Code/Codex -- proven with a before/after binary capture), an array is flattened by joining each block's .text with a newline. design-lifecycle.ts is the first consumer, replacing its own ad hoc string-only check.
permissionDecision: "ask" is ignored by Codex and Kimi's own host harnesses -- Kimi's binary short-circuits on hookSpecificOutput?.permissionDecision !== "deny", and Codex fails a hook open when it returns "ask" in the unsupported shape -- so every "ask" was silently downgraded to a hard deny with no recourse. Claude Code is unaffected: its native "ask" still shows an interactive confirmation and no code ever appears in its messages. The deny message for a downgraded "ask" now appends a short 4-hex-char code; retyping "CONFIRM <code>" in the next prompt authorizes that exact action once. One central hook in the PreToolUse pipeline (handle-pre.ts) covers every guard that can produce an "ask" with a command attached, not a per-guard change. Guardrails: - G0: no token can be placed while a sub-agent is active (session-scoped, structurally impossible from inside a Task/Agent call since sub-agents never receive their own UserPromptSubmit). New env var FUSE_CONFIRM_SUBAGENT_WINDOW_SEC (default 300s) tunes the cool-down. - G1: a token is consumed on first use. - G2: a token expires after 5 minutes. - G3: the token is keyed to the action's full SHA-256 hash, never the 4-char display code, which exists purely for the human to retype and collides by design. - G4: irreversible commands (push --force, reset --hard, rm -rf, git clean -fd, branch -D, ...) are never confirmable -- hard deny regardless of a valid token. - G5: an explicit refusal in the next prompt drops any pending token. Scope, stated plainly: this is a guard against accidental/hasty denial with no recourse, not a security control against an adversarial agent -- any agent with arbitrary shell access can write the token directly into the session-state file it authorizes from and self-approve, exactly as it could bypass any other stateful gate this harness keeps outside a sandbox. Documented as such in docs/adapters.md and the README. dispatch.ts marks sub-agent activity (G0) with a monotone max-write timestamp on both SubagentStart and SubagentStop, never a counter -- a start/stop counter desyncs under this same multi-plugin fan-out.
…n3 -c gate CHANGELOG, README, docs/adapters.md, docs/config.md, docs/guards.md, docs/runtime.md updated for the three preceding commits -- including the CONFIRM mechanism's honest scope statement (guard against accidental denial, not a security control against an adversarial agent) and the new FUSE_CONFIRM_SUBAGENT_WINDOW_SEC env var.
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.
Summary
CONFIRM <code>recourse: Codex/Kimi degradepermissionDecision: "ask"to a hard deny with no recourse; a short code now lets the human retypeCONFIRM <code>to authorize once (guardrails G0-G5, scope documented honestly: discipline guard, not a security control against an adversarial agent).python3 -cis now judged on content (likenode -e), not blocked outright.promptfield (previously read as empty under Kimi, byte-identical elsewhere).Test plan
tsc --noEmitcleanbun test: 1094 pass / 1 skip / 0 fail~/.claudeand~/.codex, no regression observed