Skip to content

fix(runtime,bash-write): CONFIRM recourse, python3 -c content gate, Kimi prompt normalization - #97

Merged
fusengine merged 8 commits into
mainfrom
fix/design-gate-apply-patch
Aug 3, 2026
Merged

fix(runtime,bash-write): CONFIRM recourse, python3 -c content gate, Kimi prompt normalization#97
fusengine merged 8 commits into
mainfrom
fix/design-gate-apply-patch

Conversation

@fusengine

Copy link
Copy Markdown
Owner

Summary

  • CONFIRM <code> recourse: Codex/Kimi degrade permissionDecision: "ask" to a hard deny with no recourse; a short code now lets the human retype CONFIRM <code> to authorize once (guardrails G0-G5, scope documented honestly: discipline guard, not a security control against an adversarial agent).
  • python3 -c is now judged on content (like node -e), not blocked outright.
  • Normalizes Kimi's array-shaped prompt field (previously read as empty under Kimi, byte-identical elsewhere).
  • Also closes prior design-gate fixes already on this branch (apply_patch gaps, cache dir keyed by session id).

Test plan

  • tsc --noEmit clean
  • bun test: 1094 pass / 1 skip / 0 fail
  • Deployed and exercised live under ~/.claude and ~/.codex, no regression observed
  • Challenger review: "Claude Code untouched" CONFIRMED; "an agent can't self-approve" REFUTED (known bypass, documented in README/docs/adapters.md, accepted scope)

…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.
@fusengine
fusengine merged commit 7956b3d into main Aug 3, 2026
1 check 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