diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d98958..7d1ab4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.88] - 2026-08-03 + +### Added + +- **`CONFIRM ` recourse for `ask` prompts degraded to deny on Codex/Kimi** (`src/runtime/confirm/`) — `permissionDecision: "ask"` is ignored by both host harnesses: Kimi Code's own binary shortcuts on `if (hookSpecificOutput?.permissionDecision !== "deny") return result`, and Codex fails a hook open when it returns `ask` in the unsupported shape, so the harness was downgrading every `ask` 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 ` in the next prompt authorizes that exact action once. Covers every guard that can produce an `ask` with a command attached (git routine ops, install, bash-write, security) via one central hook in the PreToolUse pipeline (`src/runtime/handle-pre.ts`), not a per-guard change. Guardrails: no token can be placed while a sub-agent is active (G0, session-scoped, structurally impossible from inside a Task/Agent call since sub-agents never receive their own `UserPromptSubmit`); a token is consumed on first use (G1); a token expires after 5 minutes (G2); 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 (G3); irreversible commands (`push --force`, `reset --hard`, `rm -rf`, `git clean -fd`, `branch -D`, …) are never confirmable — they stay a hard deny regardless of a valid token (G4); an explicit refusal in the next prompt drops any pending token (G5). New env var `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC` (default `300`, seconds) tunes the G0 cool-down without a rebuild. **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. + +### Changed + +- **`python3 -c` is now judged on its content, like `node -e` already was** (`src/policy/guards/bash-write-patterns.ts`, `bash-write.ts`) — previously every `python3 -c '...'` invocation was blocked outright, regardless of what the script did. The inline script is now scanned for actual file/process mutation (`open(..., 'w'/'x'/'a'/...)`, `pathlib.Path` mutators, `shutil`/`os` mutators, `subprocess.*`, `pickle.dump`/`json.dump`, `exec`/`eval`, …); a read-only one-liner (`python3 -c 'print(1)'`, `json.loads(...)`) now passes, and a mutating one (`python3 -c 'open("x","w").write(...)'`) still blocks. `python3 - <` recourse (see "CONFIRM code" under [Beyond gating](#beyond-gating-memory-receipts-one-shot-metric) below). On the PostToolUse side, an allowed patch is fanned into one synthetic per-file event so the SOLID/tracking/post-edit handlers see every touched file (`runtime/post-fanout.ts`), and a non-blocking security advisory fires on the first qualifying file (`securityAdvisoryForPatch`). | Not wired by `harness init codex` (PreToolUse `Bash\|apply_patch` + PostToolUse only, `src/init/templates.ts:29-38`) — `Stop` (session cleanup + the SOLID/receipt completion check, since Codex never emits `SessionEnd`/`TaskCompleted`, `runtime/lifecycle/stop-core.ts`) and `SessionStart` (plugin agents/commands cache resync, `runtime/lifecycle/codex-resync/`) ARE implemented but, same caveat as claude-code's extra lifecycle above, only fire once the codex-plugins marketplace's own `hooks.json` wires them. | Upstream caveat: Codex itself does not always enforce a correct `apply_patch` deny (openai/codex#27833) — we emit the right verdict; enforcement is theirs. No interactive `ask`. Codex has no native `PostToolUseFailure`; a failure is inferred from the ordinary `PostToolUse` result shape (non-zero exit or an explicit error field only — never a guess) and journaled into the one-shot failure tally (`src/tracking/codex-post-failure.ts`). | | **cursor** | `beforeShellExecution` can deny/ask (shell only, `cursor/index.ts:16-21`) | none | File edits are **advisory only**: `afterFileEdit` always returns `allow` + a `user_message` correction on violation — a `deny` there has no proven effect (hook was "informational only" at launch, and Cursor's deny-enforcement for file ops is confirmed broken upstream, forum.cursor.com/t/154377). Human sees the message; the model is never re-informed. Platform ceiling, documented in `cursor/index.ts`. | | **gemini-cli** | `BeforeTool` denies via `{decision:"deny",reason}` (`gemini/index.ts:22-36`) | none | Thin stateless adapter — no session track, no APEX gates wired through it. | | **cline** | `PreToolUse` only; block → `{cancel:true}`, non-block → `contextModification` (`cline/index.ts:24-36`) | none | Same as gemini-cli: stateless guard only. | | **hermes** | `pre_tool_call` proven: reuses the Claude stdin reader, blocks via `{decision:"block",reason}` (`hermes/index.ts:12-36`) | untested — no lifecycle dispatch wired for Hermes in this repo | `ask`/`inform` degrade to non-blocking `{context}` — Hermes "has no interactive ask state" (`hermes/index.ts:27-28`). | -| **kimi** | `PreToolUse` denies via the camelCase JSON channel `{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"…"}}` on stdout at **exit 0** (verified live against kimi-code v0.27.0 — exit 2 with stderr = reason also blocks, but is not required) — only `deny` is documented. `ask` is **downgraded to deny** prefixed `[downgraded from ask — Kimi Code has no interactive approval]`; `inform` rides plain stdout text at exit 0, never wrapped in JSON. Blocking events: `UserPromptSubmit`, `PreToolUse`, `Stop` (`kimi/index.ts`). | Observation only: `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionResult`, `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `StopFailure`, `Interrupt`, `PreCompact`, `PostCompact`, `Notification` — Kimi delivers them but **ignores any response**, so no verdict can be returned from them. | A **hook** cannot request approval — Kimi's `ask` lives in a parallel, hook-unreachable system (`[[permission.rules]] decision = "ask"` in `config.toml`), hence the ask→deny downgrade. Hooks are configured **only** in the global `~/.kimi-code/config.toml` (no project-local hooks file), so `harness init` writes no kimi wiring — copy the TOML snippet from [docs/adapters.md](docs/adapters.md#kimi-code--manual-wiring), and emit **no field** beyond `event`/`matcher`/`command`/`timeout` or the whole config fails to load. **Fail-open by design**: any exit code other than 0/2, a timeout, or a crash lets the call through. Stdin payload is snake_case and carries only `hook_event_name`, `session_id`, `cwd`, `tool_name`, `tool_input.command` and an undocumented `tool_call_id` (unused here) — no `transcript_path`, no `permission_mode`, no `tool_response`. Verified live against kimi-code v0.27.0 for `PreToolUse`/`Bash`; validate hand-written config with `kimi doctor`. Instructions file is `AGENTS.md`, not `CLAUDE.md`. | +| **kimi** | `PreToolUse` denies via the camelCase JSON channel `{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"…"}}` on stdout at **exit 0** (verified live against kimi-code v0.27.0 — exit 2 with stderr = reason also blocks, but is not required) — only `deny` is documented. `ask` is **downgraded to deny** prefixed `[downgraded from ask — Kimi Code has no interactive approval]`, with a `CONFIRM ` recourse appended (see "CONFIRM code" under [Beyond gating](#beyond-gating-memory-receipts-one-shot-metric) below); `inform` rides plain stdout text at exit 0, never wrapped in JSON. Blocking events: `UserPromptSubmit`, `PreToolUse`, `Stop` (`kimi/index.ts`). | Observation only: `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionResult`, `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `StopFailure`, `Interrupt`, `PreCompact`, `PostCompact`, `Notification` — Kimi delivers them but **ignores any response**, so no verdict can be returned from them. | A **hook** cannot request approval — Kimi's `ask` lives in a parallel, hook-unreachable system (`[[permission.rules]] decision = "ask"` in `config.toml`), hence the ask→deny downgrade. Hooks are configured **only** in the global `~/.kimi-code/config.toml` (no project-local hooks file), so `harness init` writes no kimi wiring — copy the TOML snippet from [docs/adapters.md](docs/adapters.md#kimi-code--manual-wiring), and emit **no field** beyond `event`/`matcher`/`command`/`timeout` or the whole config fails to load. **Fail-open by design**: any exit code other than 0/2, a timeout, or a crash lets the call through. Stdin payload is snake_case and carries only `hook_event_name`, `session_id`, `cwd`, `tool_name`, `tool_input.command` and an undocumented `tool_call_id` (unused here) — no `transcript_path`, no `permission_mode`, no `tool_response`. Verified live against kimi-code v0.27.0 for `PreToolUse`/`Bash`; validate hand-written config with `kimi doctor`. Instructions file is `AGENTS.md`, not `CLAUDE.md`. | ## What it enforces @@ -141,7 +141,7 @@ Guard/gate chain evaluated before a tool runs (`src/policy/guards/index.ts`, |---|---| | security | `rm -rf /`, fork bombs, `curl \| sh`; `sudo` (asks) | | protected-path | edits to `.claude/plugins\|logs\|cache`, `.git/`, the harness's own state dirs | -| bash-write | `python3 -c` / `sed -i` / redirects to code files | +| bash-write | `sed -i` / redirects to code files; `python3 -c` judged on content (mutating script → block, read-only → pass, same as `node -e`) | | interface-separation | top-level interface/type/protocol in a component/controller | | install | `npm/pip/brew/...` installs (asks) | | git | destructive git (`push --force`, `reset --hard`, …) — block; routine git — ask | @@ -161,6 +161,30 @@ through per window without the full APEX gates (`Write` is never trivial). Features shipped since 0.1.44, each with its own test: +- **`CONFIRM ` — recourse for a degraded `ask`** — Codex and Kimi both + ignore `permissionDecision: "ask"` (Kimi's own binary shortcuts on + `hookSpecificOutput?.permissionDecision !== "deny"`; Codex fails a hook open + on the unsupported shape), so the harness downgrades every `ask` there to a + hard `deny`. That deny now appends a short 4-hex-char code; retyping + `CONFIRM ` in the very next prompt authorizes that **exact** action + once (Claude Code is untouched — its native `ask` still shows an interactive + confirmation, no code ever appears there, + `src/runtime/confirm/confirm-gate.ts`). Guardrails, each independently + testable (`test/confirm.test.ts`, `test/confirm-g0.test.ts`): **G0** no token + can be placed while a sub-agent is active for this session (a monotone + max-write timestamp, window tunable via `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC`, + default 300s); **G1** a token is consumed on first use; **G2** a token + expires after 5 minutes; **G3** the token is scoped to the action's full + SHA-256 hash, never the 4-char display code (which collides by design — it + exists only for a human to retype); **G4** irreversible commands + (`push --force`, `reset --hard`, `rm -rf`, `git clean -fd`, `branch -D`, …) + are never confirmable, regardless of a valid token; **G5** an explicit + refusal in the next prompt drops any pending token. **This is a guard + against accidental/hasty denial with no recourse, not a security control + against an adversarial agent** — an agent with arbitrary shell access can + write the token straight into the session-state file it authorizes from and + self-approve, same as it could bypass any other stateful gate this harness + keeps outside a sandbox. - **Deny-loop breaker** — an identical retried call that was already denied gets a rewritten `[REPEAT] … STOP` message forcing a different approach, instead of looping silently (`src/policy/deny-loop.ts`, `test/deny-loop.test.ts`). @@ -291,6 +315,7 @@ non-zero exit is swallowed — a broken or absent player can never break a hook | `FUSE_DESIGN_GEMINI` | **Opt-in (default off), a *different* gate from the one above.** Enables the design-pipeline's own Gemini gates (`create_frontend` validation + "generate before hand-writing HTML/CSS") — inert unless a design agent is active (`src/policy/design/gates.ts:58-60`, see [docs/design.md](docs/design.md)). | | `FUSE_MCP_TTL_SEC` | MCP (Context7/Exa) cache freshness, seconds (default 48h, `src/runtime/mcp-key.ts`). | | `FUSE_WEBFETCH_TTL_SEC` | WebFetch cache freshness, seconds (default 24h — pages stale faster than docs). | +| `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC` | G0 cool-down (seconds, default `300`) for the `CONFIRM ` mechanism above — no token can be placed within this window of the last SubagentStart/Stop seen for the session. | | `RALPH_MODE` | **Opt-in (default off).** Exempts safe git commands (`add`/`commit`/`checkout -b`/`status`/`diff`/`log`) from the confirmation ask and auto-approves project installs. Destructive git (force-push, `reset --hard`) and system installs still gate. | | `CLAUDE_PROJECT_DIR` | Overrides the project root used to hash the out-of-tree state dir (`src/runtime/paths.ts:20`). | | `FUSE_HARNESS_SOUND` | **On by default.** Set to `0` to disable every lifecycle notification sound. | @@ -384,6 +409,12 @@ Run `bun run docs:api` for the generated typedoc API reference. checkpoint, not continuously. - **Hermes coverage beyond `pre_tool_call` is unverified** — no lifecycle events have been proven against a live Hermes install in this repo. +- **`CONFIRM ` is not a security boundary.** It guards against an `ask` + being silently dropped by Codex/Kimi's degrade-to-deny, i.e. against + precipitation and lost recourse — not against an adversarial agent. Any + agent with shell access can write its own confirm token into session state + and self-approve; this is true of every stateful gate this harness keeps + outside a sandbox, not specific to this mechanism. ## Develop diff --git a/docs/adapters.md b/docs/adapters.md index 8f7fab4..9fdb284 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -23,12 +23,58 @@ assuming a gate that works on Claude Code also works elsewhere. | Harness | Adapter file | PreToolUse coverage | Lifecycle events | Known limit | |---|---|---|---|---| | **claude-code** | `adapters/claude/index.ts` | Full: `evaluate` + APEX gates via `handleHook` | 14 event types implemented in `runtime/lifecycle/dispatch.ts` (SessionStart, SessionEnd, SubagentStart/Stop, Stop, PreCompact, PostCompact, TaskCompleted, TeammateIdle, PostToolUseFailure, InstructionsLoaded, UserPromptSubmit, plus Pre/PostToolUse) | Only PreToolUse+PostToolUse are wired by `harness init` (`init/templates.ts:18-27`); the other 12 event types require the consumer's own `.claude/settings.json` to route them. | -| **codex** | `adapters/codex/index.ts` + `adapters/codex/apply-patch.ts` | `Bash \| apply_patch` matcher, PostToolUse (`init/templates.ts:29-38`). **`apply_patch` edits are gated**: the patch text is parsed per file, each hunk runs the file gates (protected-path, file-size, DRY) and one violating hunk denies the whole patch (`runtime/apply-patch-gate.ts`, sim scenarios 22-23). `ask` is downgraded to an explicit deny (`respond.ts`) — Codex fails open on unsupported shapes. | none wired | Upstream: Codex does not always enforce a correct `apply_patch` deny (openai/codex#27833) — the harness emits the right verdict, enforcement is Codex's. Do not add a Codex `PermissionRequest` path until `respond()` emits Codex's own wire shape (`codex/index.ts`). | +| **codex** | `adapters/codex/index.ts` + `adapters/codex/apply-patch.ts` | `Bash \| apply_patch` matcher, PostToolUse (`init/templates.ts:29-38`). **`apply_patch` edits are gated**: the patch text is parsed per file, each hunk runs the file gates (protected-path, file-size, DRY) and one violating hunk denies the whole patch (`runtime/apply-patch-gate.ts`, sim scenarios 22-23). `ask` is downgraded to an explicit deny (`respond.ts`) — Codex fails open on unsupported shapes; the deny now carries a `CONFIRM ` recourse (`runtime/confirm/`, see below). | none wired | Upstream: Codex does not always enforce a correct `apply_patch` deny (openai/codex#27833) — the harness emits the right verdict, enforcement is Codex's. Do not add a Codex `PermissionRequest` path until `respond()` emits Codex's own wire shape (`codex/index.ts`). | | **cursor** | `adapters/cursor/index.ts` | `beforeShellExecution` can deny/ask (shell only, lines 16-21) | none | File edits are **advisory only**: `afterFileEdit` always returns `allow` + a `user_message` correction on violation — a `deny` there has no proven effect (hook launched "informational only"; Cursor's deny-enforcement for file ops is confirmed broken upstream, forum.cursor.com/t/154377). The human sees the message; the model is never re-informed. Platform ceiling, sourced in the adapter JSDoc. | | **gemini-cli** | `adapters/gemini/index.ts` | `BeforeTool` denies via `{decision:"deny",reason}` (lines 22-36) | none | Thin stateless adapter — no session track, no APEX gates reachable through it. | | **cline** | `adapters/cline/index.ts` | `PreToolUse` only; block → `{cancel:true}`, non-block → `contextModification` (lines 24-36) | none | Same as gemini-cli: stateless guard only, `PreToolUse` cannot modify tool parameters (per docs.cline.bot). | | **hermes** | `adapters/hermes/index.ts` | `pre_tool_call` proven: reuses the Claude stdin reader, blocks via `{decision:"block",reason}` (lines 12-36) | untested — no lifecycle dispatch wired for Hermes in this repo | `ask`/`inform` degrade to non-blocking `{context}` — Hermes "has no interactive ask state" (lines 27-28). | -| **kimi** | `adapters/kimi/index.ts` | `PreToolUse` denies via the camelCase JSON channel `{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"…"}}` on stdout at **exit 0** (verified live against kimi-code v0.27.0 — exit 2 with stderr = reason also blocks, but is not required) — only `deny` is documented. `ask` is **downgraded to deny** prefixed `[downgraded from ask — Kimi Code has no interactive approval]`; `inform` rides plain stdout text at exit 0, never wrapped in JSON. Blocking events: `UserPromptSubmit`, `PreToolUse`, `Stop`. | Observation only: `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionResult`, `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `StopFailure`, `Interrupt`, `PreCompact`, `PostCompact`, `Notification` — Kimi delivers them but **ignores any response**, so no verdict can be returned from them. | A **hook** cannot request approval — Kimi's `ask` lives in a parallel, hook-unreachable system (`[[permission.rules]] decision = "ask"` in `config.toml`), hence the ask→deny downgrade. Hooks are configured **only** in the global `~/.kimi-code/config.toml` (no project-local hooks file), so `harness init` writes no kimi wiring — see [Kimi Code — manual wiring](#kimi-code--manual-wiring). **Fail-open by design**: any exit code other than 0/2, a timeout, or a crash lets the call through. Stdin payload is snake_case and carries only `hook_event_name`, `session_id`, `cwd`, `tool_name`, `tool_input.command` and an undocumented `tool_call_id` (unused here) — no `transcript_path`, no `permission_mode`, no `tool_response`. Verified live against kimi-code v0.27.0 for `PreToolUse`/`Bash`. Instructions file is `AGENTS.md`, not `CLAUDE.md`. | +| **kimi** | `adapters/kimi/index.ts` | `PreToolUse` denies via the camelCase JSON channel `{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"…"}}` on stdout at **exit 0** (verified live against kimi-code v0.27.0 — exit 2 with stderr = reason also blocks, but is not required) — only `deny` is documented. `ask` is **downgraded to deny** prefixed `[downgraded from ask — Kimi Code has no interactive approval]`, with a `CONFIRM ` recourse appended (`runtime/confirm/`, see below); `inform` rides plain stdout text at exit 0, never wrapped in JSON. Blocking events: `UserPromptSubmit`, `PreToolUse`, `Stop`. | Observation only: `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionResult`, `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `StopFailure`, `Interrupt`, `PreCompact`, `PostCompact`, `Notification` — Kimi delivers them but **ignores any response**, so no verdict can be returned from them. | A **hook** cannot request approval — Kimi's `ask` lives in a parallel, hook-unreachable system (`[[permission.rules]] decision = "ask"` in `config.toml`), hence the ask→deny downgrade. Hooks are configured **only** in the global `~/.kimi-code/config.toml` (no project-local hooks file), so `harness init` writes no kimi wiring — see [Kimi Code — manual wiring](#kimi-code--manual-wiring). **Fail-open by design**: any exit code other than 0/2, a timeout, or a crash lets the call through. Stdin payload is snake_case and carries only `hook_event_name`, `session_id`, `cwd`, `tool_name`, `tool_input.command` and an undocumented `tool_call_id` (unused here) — no `transcript_path`, no `permission_mode`, no `tool_response`. Verified live against kimi-code v0.27.0 for `PreToolUse`/`Bash`. Instructions file is `AGENTS.md`, not `CLAUDE.md`. | + +## `CONFIRM ` — recourse for a degraded `ask` + +Both harnesses above downgrade `ask` to a hard `deny`: Kimi's binary +short-circuits on `hookSpecificOutput?.permissionDecision !== "deny"`, and +Codex fails a hook open on the unsupported `ask` shape. Claude Code is +unaffected — its native `ask` still shows an interactive confirmation, and +this mechanism never touches it (`src/runtime/confirm/confirm-gate.ts`'s +`DEGRADES_ASK_TO_DENY` set is `{"codex", "kimi"}` only). + +When an `ask` about to be degraded carries a command, the deny message gets a +short 4-hex-char code appended. Retyping `CONFIRM ` in the next prompt +authorizes that **exact** action once — parsed in `src/runtime/confirm/confirm-submit.ts` +(`handleConfirmSubmit`, wired from `UserPromptSubmit` in `handle.ts`) via a +`CONFIRM_RE` tolerant of `confirm4f2a`/`Confirm-4f2a`/`confirm_4f2a`. + +Guardrails (`src/runtime/confirm/confirm-state.ts`, `confirm-subagent.ts`, `confirm-irreversible.ts`): + +- **G0** — no token can be placed while a sub-agent is active for the session: + a monotone `Math.max(prevSeenAt, now)` timestamp bumped by both + SubagentStart and SubagentStop (never decremented, so a duplicated event + from the multi-plugin fan-out can't desync it open), window tunable via + `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC` (default 300s). G0's primary protection + is structural, not the window: a sub-agent never receives its own + `UserPromptSubmit`, so it cannot place a token regardless of the window's + value — the timestamp is a second belt for harnesses where that structural + property isn't proven yet (Kimi). +- **G1** — a token is consumed on first successful use. +- **G2** — a token expires 5 minutes after it was placed. +- **G3** — the token is scoped to the action's full SHA-256 hash + (`hashForAction`), never the 4-char display code (`displayCodeForAction`), + which collides by design at 16 bits — it exists only so a human has + something short to retype, never as the actual authorization boundary. +- **G4** — irreversible commands are never confirmable, regardless of a valid + token: destructive git (`push --force`, `reset --hard`, `branch -D`, + `clean -fd`, …, reusing `GIT_BLOCKED`) and any `rm -rf`/`-fr` variant. +- **G5** — an explicit refusal (`non`/`no`/`stop`/`cancel`/`annule`/…) in the + next prompt drops any pending token. + +**Scope, stated plainly**: this closes a recourse gap for an honest mistake or +a change of mind under a harness that can't show an interactive prompt — it is +**not** a security control against an adversarial agent. Any agent with +arbitrary shell access can write its own confirm token directly into the +session-state file it would need to pass and self-approve; that is true of +every stateful gate this harness keeps outside a sandbox, not specific to this +mechanism. ## Claude Code — `@fusengine/harness/adapters/claude` diff --git a/docs/config.md b/docs/config.md index 8f65eb3..64dbc26 100644 --- a/docs/config.md +++ b/docs/config.md @@ -29,6 +29,7 @@ float / `<= 0` all fall back to the default. | `FUSE_DESIGN_GEMINI` | _(off)_ | opt-in — a **different** gate from the one above: enables the design-pipeline's own Gemini gates (`policy/design/gates.ts`), inert unless a design agent is active — see [design.md](./design.md) | | `FUSE_MCP_TTL_SEC` | `172800` (48h) | Context7/Exa cache freshness (`runtime/mcp-key.ts`) | | `FUSE_WEBFETCH_TTL_SEC` | `86400` (24h) | WebFetch cache freshness — pages stale faster than docs | +| `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC` | `300` | G0 cool-down (seconds) for the `CONFIRM ` mechanism (`runtime/confirm/confirm-subagent.ts`) — no confirm token can be placed within this window of the last SubagentStart/SubagentStop seen for the session. Reuses `parseEnvInt` directly (not `resolveTtlSec`, whose default is hardcoded to 120 regardless of key) | | `RALPH_MODE` | _(off)_ | opt-in autonomous mode — exempts safe git commands (`add`/`commit`/`checkout -b`/`status`/`diff`/`log`) from the confirmation ask and auto-approves project installs; destructive git and system installs still gate (`policy/patterns.ts`) | | `FUSE_HARNESS_SOUND` | _(on)_ | set to `0` to disable every lifecycle notification sound (`runtime/notifications.ts`) | | `FUSE_HARNESS_SOUND_STOP` | _(bundled `assets/song/finish.mp3`)_ | override path for the Codex-`Stop` sound | diff --git a/docs/guards.md b/docs/guards.md index 3fc19b2..6da68a6 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -27,7 +27,7 @@ wraps `evaluate`/`evaluateApex` the same way, so a bug can never disable enforce | `securityGuard` | `rm -rf /\|/etc\|/usr…`, fork bomb, `curl \| sh`, `mkfs`/`shred`/`fdisk`/`diskutil erase`, `> /dev/{sda,hda,nvme}` | block | | | `sudo`/`su`/`doas`/`passwd`, `chmod 777`, recursive `chown`, `eval`, `rm`/`unlink`, write to `/etc` | ask | | `protectedPathGuard` | Write/Edit under `.claude/plugins\|logs\|cache`, `.git/` | block | -| `bashWriteGuard` | `python3 -c`, `sed -i`, heredoc/redirect to a code file | block | +| `bashWriteGuard` | `sed -i`, heredoc/redirect to a code file, `python3 -c` whose inline script mutates files/spawns a process (content-gated — same treatment as `node -e`, a read-only one-liner passes) | block | | | redirect to a non-code file, `tee`, `dd of=`, `node -e` writes | ask | | `interfaceSeparationGuard` | top-level `interface`/`type`/`protocol`/`record` in a TS/JS/Vue/Svelte, Python, **Go**, **Java/Kotlin**, PHP, or Swift component/view/controller/handler | block | | `installGuard` | `npm/yarn/pnpm/bun/pip/cargo/go/gem/composer` + `brew/apt/dnf/pacman` installs | ask | diff --git a/docs/runtime.md b/docs/runtime.md index d4d1810..fa58be9 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -22,8 +22,15 @@ const { stdout, exit } = await handleHook(id, payload, { fast path → APEX gates (from the track) → native response via `respond(id, prompt)`. - **POST event** → `activityFor(event)` → `recordActivity` (fills the track; agent `quality` is derived from the response length), and `mcpPostStore` caches responses. -- **UserPromptSubmit** (payload carries `prompt`) → `detectCreationIntent` → - `recordBrainstormRequired`, so `brainstormGate` can fire on the next edit. +- **UserPromptSubmit** (payload carries `prompt`) → `promptText(payload.prompt)` + normalizes the field first (`./prompt-text.ts`) — a plain string on Claude + Code/Codex passes through unchanged, but Kimi 0.31.1 sends an array of + content blocks (`[{type,text}]`), which is flattened by joining each + block's `.text` with `"\n"`; anything else yields `""`, never a throw — then + `detectCreationIntent` → `recordBrainstormRequired` (`brainstormGate` fires + on the next edit) and `handleConfirmSubmit` (`./confirm/confirm-submit.ts`) + parses the same text for a `CONFIRM ` reply or an explicit refusal — + see [adapters.md](./adapters.md#confirm-code--recourse-for-a-degraded-ask). `normalizeEvent(id, payload)` unifies the payload shapes (Claude/Codex/Gemini/ Cursor `tool_name`+`tool_input`; Cline nested `preToolUse`). diff --git a/package.json b/package.json index ee3d453..9fa4ba9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.87", + "version": "0.1.88", "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.", "type": "module", "module": "src/index.ts", diff --git a/src/adapters/codex/index.ts b/src/adapters/codex/index.ts index cdef262..fc41a5d 100644 --- a/src/adapters/codex/index.ts +++ b/src/adapters/codex/index.ts @@ -19,6 +19,7 @@ import { countLines } from "../../policy/file-size"; import { formatPrompt, type Prompt } from "../../prompt/types"; import { parseApplyPatch } from "./apply-patch"; import { commandToString } from "../../runtime/command-string"; +import { canonicalizeCodexShellTool } from "../../runtime/codex-shell-tool"; import { contextResponse, denyResponse, informResponse, type ClaudeHookInput } from "../claude"; import { isBypassPermissions } from "./permission-mode"; @@ -56,7 +57,10 @@ function applyPatchPrompt(command: string): Prompt | null { function resolvePrompt(input: ClaudeHookInput): Prompt | null { const i = input.tool_input; const r = evaluate({ - tool: input.tool_name ?? "Write", + // Code Mode-wrapped exec_command surfaces its raw function-tool name here + // instead of "Bash" (see codex-shell-tool.ts) — canonicalize so the SOLID/ + // protected-path/bash-write guards recognize it like a native Bash call. + tool: canonicalizeCodexShellTool("codex", input.tool_name ?? "Write"), filePath: i?.file_path, content: i?.content ?? i?.new_string, command: commandToString(i?.command), diff --git a/src/policy/design/gates-pipeline.ts b/src/policy/design/gates-pipeline.ts index 5bc43c5..c499fc0 100644 --- a/src/policy/design/gates-pipeline.ts +++ b/src/policy/design/gates-pipeline.ts @@ -32,6 +32,30 @@ export function designSystemWriteGate(filePath: string, state: DesignState, corp return null; } +/** + * Gate writing .html/.css: PIPELINE gate (never `uiDesignSkillGate`/`UI_FILE_RE` + * — owner-scoped out of the skill gate's remit). Requires phase >= 3 AND + * designSystemValid (same two-condition defense as {@link geminiCreateGate}) + * AND `designSystemFileExists` (caller-computed via `findDesignSystem` — a + * state can say phase 3 after the file was later deleted, or the file can + * exist while a fresh agent's state is still phase 0). + */ +export function htmlCssPipelineGate(filePath: string, state: DesignState, designSystemFileExists: boolean): Prompt | null { + if (!/\.(html|css)$/.test(filePath)) return null; + if (state.currentPhase >= 3 && state.designSystemValid && designSystemFileExists) return null; + const gaps: string[] = []; + if (state.currentPhase < 3) gaps.push(`phase ${state.currentPhase}/3`); + if (!state.designSystemValid) gaps.push("design-system.md not validated"); + if (!designSystemFileExists) gaps.push("design-system.md not found on disk"); + return deny( + `BLOCKED: cannot write '${filePath}' before the design-system pipeline is complete (${gaps.join(", ")}). ` + + "RECOVERY: 1) Read identity templates from skills/design-system/ 2) Read design-inspiration.md " + + "3) Read the refs-design corpus with the Read tool 4) Screenshot real sector sites with " + + "mcp__fuse-browser__browser_screenshot on a LIVE session 5) Write a valid design-system.md " + + "6) Then write .html/.css", + ); +} + /** Gate Gemini create_frontend: requires phase >= 3 and a validated design system. */ export function geminiCreateGate(state: DesignState): Prompt | null { if (state.currentPhase < 3) { diff --git a/src/policy/guards/bash-write-patterns.ts b/src/policy/guards/bash-write-patterns.ts index cecde78..9cab1b7 100644 --- a/src/policy/guards/bash-write-patterns.ts +++ b/src/policy/guards/bash-write-patterns.ts @@ -17,7 +17,6 @@ export const CODE_REDIRECT: RegExp = new RegExp(`(?:>>?)\\s*[^\\s|;&]*\\.(?:${CO */ export const CODE_MUTATORS: readonly { re: RegExp; desc: string }[] = [ { re: new RegExp(`${CMD}python3?\\s+-\\s*<<`), desc: "Python heredoc input" }, - { re: new RegExp(`${CMD}python3?\\s+-c\\b`), desc: "Python inline script" }, { re: new RegExp(`${CMD}sed\\b[^|]*\\s-i`), desc: "sed in-place edit" }, { re: new RegExp(`${CMD}perl\\b[^|]*\\s-[pi]i?\\b`), desc: "perl in-place edit" }, { re: new RegExp(`${CMD}awk\\b[^|]*-i\\s*inplace`), desc: "awk in-place edit" }, @@ -48,6 +47,69 @@ export const NODE_WRITES: RegExp = export const RUBY_WRITES: RegExp = /File\.(?:write|open|delete|rename)|IO\.write|FileUtils|\bsystem\b|\bexec\b|`[^`]/; +/** + * Command-position anchor for `python3 -c` (same anchored-token shape as the + * removed unconditional CODE_MUTATORS entry) — used ONLY to gate {@link PYTHON_WRITES} + * below, never as a standalone block. `python3 - <`/`N>` and `>&N` fd * redirects via the `(? m.re.test(cmd)); if (mutator) return blockCodeWrite(`${mutator.desc} — Use Edit/Write tools instead`); if (CODE_COMMAND_WRITE.test(cmd)) return blockCodeWrite("tee/dd into a code file — Use Edit/Write tools instead"); + if (PYTHON_C_ANCHOR.test(cmd) && PYTHON_WRITES.test(cmd)) { + return blockCodeWrite("Python inline script mutates files/spawns a process — Use Edit/Write tools instead"); + } if (SAFE_PREFIXES.some((p) => stripped.startsWith(p)) && !FILE_REDIRECT.test(stripped)) { return null; diff --git a/src/runtime/codex-shell-tool.ts b/src/runtime/codex-shell-tool.ts new file mode 100644 index 0000000..809b6d2 --- /dev/null +++ b/src/runtime/codex-shell-tool.ts @@ -0,0 +1,37 @@ +/** + * @module codex-shell-tool + * Canonicalizes a Codex-only shell `tool_name` alias to `"Bash"`, so every + * existing `tool === "Bash"` consumer (activity credit, explore-tools + * classification, the protected-path guard, the design corpus read gate, …) + * treats a Code Mode exec dispatch exactly like a native Bash call — no + * per-consumer change needed. + * + * A Code Mode-wrapped call (`functions.exec` -> `tools.exec_command(...)`) + * surfaces via the generic `CoreToolRuntime` hook default (openai/codex#23757, + * commit 5c20513), which keeps the raw function-tool name `"exec_command"` + * verbatim — unlike a DIRECT `exec_command` dispatch, which + * `ExecCommandHandler`'s own `pre_tool_use_payload` override re-labels + * `"Bash"` (`tool_name: HookToolName::bash()`). This is the exact call chain + * a real Codex CLI 0.146.0 session was observed emitting for a `sed -n` skill + * read. Only ONE alias is confirmed here — `ToolName::plain("exec_command")` + * in `core/src/tools/handlers/unified_exec/exec_command.rs`; `shell`, + * `unified_exec`, `local_shell`, and `shell_command` were audited but not + * evidenced as a distinct hook `tool_name` value, so they are deliberately + * left out rather than guessed. + * @packageDocumentation + */ + +/** Codex `tool_name` values that are a shell execution but not literally `"Bash"`. */ +const CODEX_SHELL_TOOL_ALIASES = new Set(["exec_command"]); + +/** + * Canonicalize a Codex-only shell tool alias to `"Bash"`. Scoped to + * `id === "codex"` — every other harness id, and every non-aliased tool + * name, passes through unchanged (see `test/codex-shell-read-credit.test.ts` + * for the non-regression proof). + * @param id - Harness adapter id (e.g. "codex", "claude-code", "kimi"). + * @param tool - Raw `tool_name` from the hook payload. + */ +export function canonicalizeCodexShellTool(id: string, tool: string): string { + return id === "codex" && CODEX_SHELL_TOOL_ALIASES.has(tool) ? "Bash" : tool; +} diff --git a/src/runtime/confirm/confirm-code.ts b/src/runtime/confirm/confirm-code.ts new file mode 100644 index 0000000..d9af00a --- /dev/null +++ b/src/runtime/confirm/confirm-code.ts @@ -0,0 +1,33 @@ +import { createHash } from "node:crypto"; + +/** + * Full SHA-256 hex digest (64 chars) of an action string (the exact command + * a deny prompt showed). This is the ONLY value ever compared to authorize a + * confirmation (G3, confirm-state.ts) — collision-resistant, unlike the + * 4-char {@link displayCodeForAction} below. + * @param action - The command/content string a deny prompt showed. + */ +export function hashForAction(action: string): string { + return createHash("sha256").update(action).digest("hex"); +} + +/** + * 4-hex-char PREFIX of {@link hashForAction}, for DISPLAY ONLY — never a + * security boundary. It exists purely so a human has something short to + * retype ("CONFIRM 4f2a"); at 16 bits it collides constantly across + * unrelated actions (birthday bound ~a few hundred actions/session), and the + * agent ALWAYS sees it too — `systemMessage` never reaches the user or model + * under Codex (measured 2026-08-03: 0 occurrence in the rollout, nothing on + * screen), so `permissionDecisionReason` is the only channel, and the model + * reads its own code back in the very tool result that got blocked. + * + * What actually scopes a confirmation to ONE specific action is the full + * hash recorded at deny time and re-checked at consume time (see + * confirm-pending.ts / confirm-state.ts). What prevents an agent from typing + * its own code back to self-approve is G0 (fail-closed while a sub-agent is + * active) — never the code's secrecy, because it has none. + * @param action - The command/content string to derive the display code from. + */ +export function displayCodeForAction(action: string): string { + return hashForAction(action).slice(0, 4); +} diff --git a/src/runtime/confirm/confirm-gate.ts b/src/runtime/confirm/confirm-gate.ts new file mode 100644 index 0000000..85b0803 --- /dev/null +++ b/src/runtime/confirm/confirm-gate.ts @@ -0,0 +1,47 @@ +import type { Prompt } from "../../prompt/types"; +import { displayCodeForAction, hashForAction } from "./confirm-code"; +import { isIrreversible } from "./confirm-irreversible"; +import { recordPendingDeny } from "./confirm-pending"; +import { consumeConfirmToken } from "./confirm-state"; + +/** + * Harnesses where `respond.ts` silently downgrades `kind: "ask"` to a hard + * deny (Codex: `case "codex"`; Kimi: `toKimiResponse` maps `ask` to the same + * `permissionDecision:"deny"` envelope as `block`). Claude Code keeps native + * interactive `ask` — this mechanism NEVER applies there. + */ +const DEGRADES_ASK_TO_DENY: ReadonlySet = new Set(["codex", "kimi"]); + +export type ConfirmVerdict = { allow: true } | { allow: false; prompt: Prompt }; + +/** + * Whether/how a CONFIRM token changes an `ask` prompt about to be downgraded + * to a deny. Returns `null` when this mechanism doesn't apply AT ALL — any + * harness other than codex/kimi, any prompt kind other than `ask`, no + * command to key a hash off, or an irreversible command (G4) — in which case + * the caller's ORIGINAL prompt/response path runs completely unchanged. That + * `null` fast-path, hit on every claude-code call and every non-`ask` prompt, + * IS the non-regression property. + * @param id - Harness target id. + * @param prompt - The prompt `gate()` returned. + * @param command - `event.command` for the tool-use under judgment. + * @param sessionId - `event.sessionId`. + * @param now - Epoch ms. + * @param home - Test-only OS home override. + */ +export function confirmGate(id: string, prompt: Prompt, command: string | undefined, sessionId: string, now: number, home?: string): ConfirmVerdict | null { + if (prompt.kind !== "ask" || !DEGRADES_ASK_TO_DENY.has(id) || !command || isIrreversible(command)) return null; + try { + const hash = hashForAction(command); + if (consumeConfirmToken(sessionId, hash, now, home)) return { allow: true }; + const code = displayCodeForAction(command); + recordPendingDeny(sessionId, hash, code, now, home); + return { allow: false, prompt: { ...prompt, reason: `${prompt.reason}\nPour autoriser, réponds : CONFIRM ${code}` } }; + } catch { + // A state-io failure (full disk, unwritable home) must fall back to the + // plain deny, never crash the hook — same invariant as confirm-submit.ts. + // The caller's `confirm ? confirm.prompt : prompt` treats `null` exactly + // like "mechanism doesn't apply", i.e. the pre-CONFIRM deny unchanged. + return null; + } +} diff --git a/src/runtime/confirm/confirm-irreversible.ts b/src/runtime/confirm/confirm-irreversible.ts new file mode 100644 index 0000000..5b5bb78 --- /dev/null +++ b/src/runtime/confirm/confirm-irreversible.ts @@ -0,0 +1,32 @@ +import { GIT_BLOCKED, matchPatterns } from "../../policy/patterns"; + +/** + * A single flag token carrying BOTH `r` and `f` in either order (`-rf`, + * `-fr`, `-rfv`, …) — same lookahead technique as patterns.ts's + * `CLEAN_FD_FLAG`, reused here rather than re-derived (DRY). + */ +const RM_COMBINED_FLAG = /\s-(?=[a-zA-Z]*r)(?=[a-zA-Z]*f)[a-zA-Z]+(?:\s|=|$)/; +/** Split flags `-r ... -f` (either order) within the SAME command segment (no `;&|` between). */ +const RM_SPLIT_FLAG = /\brm\b[^;&|\n]*\s-r\b[^;&|\n]*\s-f\b|\brm\b[^;&|\n]*\s-f\b[^;&|\n]*\s-r\b/; + +/** + * Generic `rm -rf`/`-fr` (ANY target, not just `/`/`~`) — deliberately + * broader than `src/codex-rules/rules/rm-variants.ts`'s root/system-path-only + * forbidden list: G4 must treat every recursive force-delete as + * never-confirmable, not only the ones aimed at a system path. + */ +function isRmRf(cmd: string): boolean { + return /\brm\b/.test(cmd) && (RM_COMBINED_FLAG.test(cmd) || RM_SPLIT_FLAG.test(cmd)); +} + +/** + * G4: commands NEVER unlockable by a CONFIRM token, regardless of a valid, + * fresh, correctly-scoped one. Reuses {@link GIT_BLOCKED} (push --force, + * reset --hard, clean -fd, branch -D, rebase --force — DRY, same list the + * rest of the policy already enforces) plus a generic `rm -rf` check. + * @param cmd - The command a prompt is about (empty/undefined = not irreversible). + */ +export function isIrreversible(cmd: string | undefined): boolean { + if (!cmd) return false; + return matchPatterns(cmd, GIT_BLOCKED) || isRmRf(cmd); +} diff --git a/src/runtime/confirm/confirm-pending.ts b/src/runtime/confirm/confirm-pending.ts new file mode 100644 index 0000000..b90d57f --- /dev/null +++ b/src/runtime/confirm/confirm-pending.ts @@ -0,0 +1,34 @@ +import { homedir } from "node:os"; +import { loadSessionState, saveSessionState, sanitizeSessionId } from "../home-state"; + +/** The last `ask`-turned-deny for a session: its full hash plus the short code shown to the human. */ +export interface PendingDeny { + hash: string; + code: string; + ts: number; +} + +/** + * Record the action a deny prompt just showed (overwrites any previous + * pending deny for this session — only the LAST blocked action can be + * confirmed). Pure bookkeeping, no gate: every ask-turned-deny records one, + * whether or not the user ever confirms it. + * @param sessionIdRaw - Raw session id from the payload. + * @param hash - Full {@link import("./confirm-code").hashForAction} of the command. + * @param code - The short display code shown alongside it. + * @param now - Epoch ms. + * @param home - Test-only OS home override. + */ +export function recordPendingDeny(sessionIdRaw: unknown, hash: string, code: string, now: number, home: string = homedir()): void { + const sid = sanitizeSessionId(sessionIdRaw); + if (!sid) return; + const state = loadSessionState(sid, home); + saveSessionState(sid, { ...state, pendingDeny: { hash, code, ts: now } satisfies PendingDeny }, home); +} + +/** Read back the pending deny for a session (`undefined` when none/invalid session id). */ +export function getPendingDeny(sessionIdRaw: unknown, home: string = homedir()): PendingDeny | undefined { + const sid = sanitizeSessionId(sessionIdRaw); + if (!sid) return undefined; + return loadSessionState(sid, home).pendingDeny as PendingDeny | undefined; +} diff --git a/src/runtime/confirm/confirm-state.ts b/src/runtime/confirm/confirm-state.ts new file mode 100644 index 0000000..ee182d5 --- /dev/null +++ b/src/runtime/confirm/confirm-state.ts @@ -0,0 +1,56 @@ +import { homedir } from "node:os"; +import { loadSessionState, saveSessionState, sanitizeSessionId } from "../home-state"; +import { isSubagentActive } from "./confirm-subagent"; + +/** A posed confirmation token: the FULL action hash (G3) plus its mint timestamp (G2). */ +interface ConfirmToken { + hash: string; + ts: number; +} + +/** Confirmation freshness window (G2) — 5 minutes, matches the validated prototype. */ +const TTL_MS = 5 * 60 * 1000; + +/** + * Pose a confirmation token for this session's pending action. No-op while + * {@link isSubagentActive} (G0, confirm-subagent.ts) — the sole gate that + * stops an agent, which always sees the display code in its own blocked tool + * result, from typing it back to self-approve. + */ +export function placeConfirmToken(sessionIdRaw: unknown, hash: string, now: number, home: string = homedir(), env: Record = process.env): void { + const sid = sanitizeSessionId(sessionIdRaw); + if (!sid || isSubagentActive(sid, now, home, env)) return; + const state = loadSessionState(sid, home); + saveSessionState(sid, { ...state, confirmToken: { hash, ts: now } satisfies ConfirmToken }, home); +} + +/** Invalidate any pending token for this session (G5: an explicit refusal). */ +export function dropConfirmToken(sessionIdRaw: unknown, home: string = homedir()): void { + const sid = sanitizeSessionId(sessionIdRaw); + if (!sid) return; + const state = loadSessionState(sid, home); + if (state.confirmToken === undefined) return; + const { confirmToken: _drop, ...rest } = state; + saveSessionState(sid, rest, home); +} + +/** + * Consume a token that matches `hash` exactly (G3), whether or not it's + * fresh — a mismatched hash leaves the token untouched (it may still be + * valid for the action it actually confirms). G1 (one-shot) + G2 (5-min TTL) + * both apply only once the hash matches. + * @returns true = allow (token consumed); false = deny (nothing changed, or + * the matching token had expired and was dropped). + */ +export function consumeConfirmToken(sessionIdRaw: unknown, hash: string, now: number, home: string = homedir()): boolean { + const sid = sanitizeSessionId(sessionIdRaw); + if (!sid) return false; + const tok = loadSessionState(sid, home).confirmToken as ConfirmToken | undefined; + if (!tok || tok.hash !== hash) return false; + if (now - tok.ts > TTL_MS) { + dropConfirmToken(sid, home); + return false; + } + dropConfirmToken(sid, home); + return true; +} diff --git a/src/runtime/confirm/confirm-subagent.ts b/src/runtime/confirm/confirm-subagent.ts new file mode 100644 index 0000000..f476aca --- /dev/null +++ b/src/runtime/confirm/confirm-subagent.ts @@ -0,0 +1,112 @@ +import { homedir } from "node:os"; +import { parseEnvInt } from "../../config/env"; +import { loadSessionState, saveSessionState, sanitizeSessionId } from "../home-state"; + +/** Env var overriding the G0 cool-down window below (seconds). Unset = the 300s/5min default. */ +const CONFIRM_WINDOW_ENV_KEY = "FUSE_CONFIRM_SUBAGENT_WINDOW_SEC"; +/** Default G0 cool-down (seconds) when {@link CONFIRM_WINDOW_ENV_KEY} is unset/invalid — 300s (5min). */ +const DEFAULT_CONFIRM_WINDOW_SEC = 300; + +/** + * G0 cool-down after the last SubagentStart/SubagentStop seen for this + * session — {@link DEFAULT_CONFIRM_WINDOW_SEC} by default, overridable via + * `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC` (seconds) without a rebuild. Read this + * doc before ever touching this value: it is NOT G0's primary protection, + * and it is not sized to "safely cover a sub-agent's whole runtime". + * + * G0's PRIMARY protection is P0, measured live under Codex in a prior + * session: a sub-agent never receives its own `UserPromptSubmit` — only a + * human typing into the top-level session does — so a sub-agent is + * STRUCTURALLY unable to place a CONFIRM token at all, regardless of this + * window's value. This timestamp is a SECOND belt, covering only the + * residual case where that structural property does not hold on some future + * harness (Kimi: not yet measured either way). + * + * The default is a deliberate, owner-decided trade-off between over-refusal + * and usability, NOT a safety-maximizing constant: sub-agents in this + * ecosystem share the lead session's `session_id`, and the owner runs + * sub-agents continuously — an earlier 30-minute window froze CONFIRM for + * the entire session on every single sub-agent call, which neutralizes the + * feature it protects (a guard that disables what it guards is not a good + * guard). 5 minutes comfortably covers the case that actually matters — a + * sub-agent genuinely in flight at the exact moment a human types a CONFIRM + * code — without pinning a busy session shut. Do not read this value as "the + * time G0 needs to be safe" — that safety comes from P0. + * + * This deliberately reuses {@link parseEnvInt} (`src/config/env.ts`) rather + * than `resolveTtlSec`/`FUSE_ENFORCE_TTL_SEC` (`src/config/ttl.ts`): that TTL + * governs APEX evidence freshness (120s default) — an unrelated concern + * accidentally coupling the two would make lengthening the research-evidence + * window also lengthen the confirmation cool-down. `resolveTtlSec` also + * hardcodes its own fallback (`DEFAULT_TTL_SEC` = 120) regardless of which + * env key is passed, so it cannot express a 300s default either — this key + * gets its OWN env var and its OWN default via the same underlying + * `parseEnvInt` primitive, with no new parsing logic. + * + * Mechanically this is still a single monotone "last seen" timestamp, NOT a + * start/stop pair — a symmetric increment/decrement depth counter was tried + * and rejected: the harness dispatches ONE real SubagentStart/SubagentStop + * through MULTIPLE concurrent sibling plugin processes (the repo's own + * "~11-process multi-plugin fan-out"), all doing UNLOCKED read-modify-write + * on the same session-state file — a duplicated decrement can walk the + * counter to 0 while a sub-agent is still running, and G0 would then wrongly + * OPEN. Two ever-growing `starts`/`stops` counters were also rejected: if the + * fan-out dispatches SubagentStart via N processes and SubagentStop via + * M ≠ N, `starts > stops` stays true FOREVER and the session is frozen for + * good — worse than any window. + * + * `Math.max(prevSeenAt, now)` avoids both failure modes: an LWW + * (last-writer-wins) register merged with `max`, which is idempotent, + * commutative, and associative — under ANY interleaving of concurrent + * writers (lost updates included), the stored value converges to the + * largest `now` any writer supplied, and every writer's `now` is a real + * wall-clock read taken within milliseconds of the true event, so it can + * never regress below a timestamp it already held. BOTH SubagentStart and + * SubagentStop bump the SAME field this way — there is no decrement + * anywhere in this file, so there is nothing for a duplicated event to + * desynchronize; the flag falls only once this window elapses with no + * further sighting. + * @param env - Env map to resolve the override from (defaults to `process.env`; tests inject a plain object). + */ +function subagentWindowMs(env: Record = process.env): number { + return parseEnvInt(env[CONFIRM_WINDOW_ENV_KEY], DEFAULT_CONFIRM_WINDOW_SEC) * 1000; +} + +/** + * G0: true within {@link subagentWindowMs} of the last SubagentStart/ + * SubagentStop seen for this session. An invalid/missing session id is + * treated as active too — fail-closed, never pose a token when in doubt. + */ +export function isSubagentActive(sessionIdRaw: unknown, now: number, home: string = homedir(), env: Record = process.env): boolean { + const sid = sanitizeSessionId(sessionIdRaw); + if (!sid) return true; + const seenAt = loadSessionState(sid, home).subagentSeenAt; + return typeof seenAt === "number" && now - seenAt < subagentWindowMs(env); +} + +/** + * Record a SubagentStart OR SubagentStop sighting for this session as a + * monotone max-write (see {@link subagentWindowMs}'s doc for why this can + * never desync unsafely under concurrent racy writers). Both events call + * this the SAME way — there is no separate "clear" path. + * + * Called unconditionally, BEFORE scope branching, on every SubagentStart/Stop + * across every plugin (dispatch.ts) — never allowed to throw: a disk fault + * here (full disk, unwritable home) must degrade to "sighting not recorded", + * never crash sub-agent lifecycle dispatch for scopes that have nothing to do + * with CONFIRM. Safe to swallow: G0's PRIMARY protection is P0 (a sub-agent + * structurally never receives its own UserPromptSubmit, confirm-gate.ts's + * doc) — this window is only a second belt, so losing one sighting degrades + * that belt, it does not open the gate. + */ +export function markSubagentSeen(sessionIdRaw: unknown, now: number, home: string = homedir()): void { + try { + const sid = sanitizeSessionId(sessionIdRaw); + if (!sid) return; + const state = loadSessionState(sid, home); + const prev = typeof state.subagentSeenAt === "number" ? state.subagentSeenAt : 0; + saveSessionState(sid, { ...state, subagentSeenAt: Math.max(prev, now) }, home); + } catch { + // A state-io failure must never break the hook (same invariant as confirm-submit.ts). + } +} diff --git a/src/runtime/confirm/confirm-submit.ts b/src/runtime/confirm/confirm-submit.ts new file mode 100644 index 0000000..741771e --- /dev/null +++ b/src/runtime/confirm/confirm-submit.ts @@ -0,0 +1,38 @@ +import { getPendingDeny } from "./confirm-pending"; +import { dropConfirmToken, placeConfirmToken } from "./confirm-state"; + +/** Common explicit-refusal words (fr/en), any of which invalidates a pending token (G5). */ +const REFUSAL_RE = /\b(non|no|stop|annule|cancel|abort|nope|laisse tomber|pas maintenant)\b/i; +/** `CONFIRM <4-hex-chars>`, case-insensitive, tolerant of `confirm4f2a` / `Confirm-4f2a` / `confirm_4f2a`. */ +const CONFIRM_RE = /confirm[\s_-]*([0-9a-f]{4})\b/i; + +/** + * Parse a submitted user prompt for `CONFIRM ` or an explicit refusal. + * A refusal always wins (checked first) and drops any pending token (G5), + * even if the same text also happens to contain a code. A confirm only + * places a token when its 4-char code matches this session's LAST pending + * deny (see confirm-pending.ts) — the code is looked up back to the full + * hash that deny recorded, never compared as a code-to-code match (that + * would reopen the collision {@link import("./confirm-code").displayCodeForAction} + * warns about). Never throws — this runs inside a hook. + * @param sessionId - The normalized event's session id. + * @param text - The prompt text ({@link import("../prompt-text").promptText} output). + * @param now - Epoch ms. + * @param home - Test-only OS home override. + */ +export function handleConfirmSubmit(sessionId: string, text: string, now: number, home?: string): void { + try { + if (REFUSAL_RE.test(text)) { + dropConfirmToken(sessionId, home); + return; + } + const m = text.match(CONFIRM_RE); + const typedCode = m?.[1]; + if (!typedCode) return; + const pending = getPendingDeny(sessionId, home); + if (!pending || pending.code.toLowerCase() !== typedCode.toLowerCase()) return; + placeConfirmToken(sessionId, pending.hash, now, home); // G0 enforced inside + } catch { + // A state-io failure must never break the hook. + } +} diff --git a/src/runtime/design-cache-resolve.ts b/src/runtime/design-cache-resolve.ts new file mode 100644 index 0000000..f02637f --- /dev/null +++ b/src/runtime/design-cache-resolve.ts @@ -0,0 +1,51 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fuseHarnessHome, sanitizeSessionId } from "./home-state"; + +/** `~/.fuse-harness/design-sessions/` — session-anchored design-state dir. */ +function sessionCacheDir(sid: string, home?: string): string { + return join(fuseHarnessHome(home), "design-sessions", sid); +} + +/** True when `dir` already holds the design-agent flag or a `.design-state-*.json` snapshot. */ +function hasDesignState(dir: string): boolean { + if (existsSync(join(dir, "design-agent-active"))) return true; + try { + return readdirSync(dir).some((name) => name.startsWith(".design-state-")); + } catch { + return false; + } +} + +/** + * Resolve the cache dir the design pipeline (active-agent flag + + * `.design-state-.json`) should read AND write for this hook call. + * + * Anchored on the session id, not the invoking process's `cwd`: a design + * agent's `SubagentStart` and its later tool writes can run with different + * `process.cwd()` values, and a cwd-keyed cache dir then silently misses the + * flag/state — every design gate fails open (see MEMORY/LESSON.md). + * + * Contract (non-regression): `resolved = sessionDirIfPopulated ?? + * cwdDirIfPopulated ?? sessionDirDefault`. Before this fix the session-keyed + * dir never existed, so for every session whose state already lives under + * `cwdCacheDir` the FIRST branch is always empty and resolution falls through + * to `cwdCacheDir` — byte-identical to the pre-fix value. Only a session with + * NO state anywhere (today's silent-fail-open case, e.g. cwd drifted between + * `SubagentStart` and the write) diverges, anchoring on the session dir + * instead of silently missing the flag. Read and write always resolve through + * this same function against the same two inputs, so they can never target + * different locations mid-session. + * @param sessionId - The hook event's session id (`NormalizedEvent.sessionId`). + * @param cwdCacheDir - The legacy, cwd-derived cache dir (`projectLayout(cwd).cacheDir`). + * @param home - OS home override (tests only). + * @returns The cache dir to pass to the design flag/state/gate functions. + */ +export function resolveDesignCacheDir(sessionId: string, cwdCacheDir: string, home?: string): string { + const sid = sanitizeSessionId(sessionId); + if (!sid) return cwdCacheDir; // no stable session id -> unchanged legacy behavior + const sessDir = sessionCacheDir(sid, home); + if (hasDesignState(sessDir)) return sessDir; + if (hasDesignState(cwdCacheDir)) return cwdCacheDir; + return sessDir; +} diff --git a/src/runtime/design-files-gate.ts b/src/runtime/design-files-gate.ts index 5a77af7..b467c77 100644 --- a/src/runtime/design-files-gate.ts +++ b/src/runtime/design-files-gate.ts @@ -14,19 +14,49 @@ * FUSE_DESIGN_GEMINI=1 (off by default — an informational flag), * and designSystemExists is never read after init; * - delete: skipped (parity with the sibling applyPatchGate). - * One violating file blocks the whole envelope. KNOWN GAPS (documented, out - * of scope): apply_patch also bypasses uiDesignSkillGate and the Gemini - * create_frontend precondition; htmlCssOnlyGate stays excluded (owner D2); - * and `Design-System.md` (case) escapes every endsWith check, here and on - * Write — a real bypass on case-insensitive macOS, not widened now. + * One violating file blocks the whole envelope. `htmlCssOnlyGate` NOW applies + * here too (owner D2 revised): the two write paths must not diverge on what + * the design agent may write — an `apply_patch` `.tsx`/`.astro`/etc. add or + * update is just as much a framework-file write as the same content via + * `Write`/`Edit`, and letting it through only on this path was an unintended + * apply_patch-shaped bypass, not a deliberate relaxation. `uiDesignSkillGate` + * and the Gemini create_frontend precondition NO LONGER bypass apply_patch + * (fixed): the skill gate is wired in `design.ts` on `event.files` (same + * any-agent scope as its Write/Edit call site, before the agentId + * early-return); the Gemini precondition is ported into this function, + * right below `htmlCssOnlyGate`, gated the same way (opt-in, + * FUSE_DESIGN_GEMINI, state.geminiCalls === 0). REMAINING KNOWN GAPS + * (documented, still out of scope): + * `Design-System.md` (case) escapes every endsWith check, here and on + * Write — a real bypass on case-insensitive macOS, not widened now; and + * `*** Move to:` (apply-patch.ts:76-79) unconditionally OVERWRITES `cur.path` + * on ANY preceding op (`Add File`/`Update File`), so a `*** Update File: a.css` + * + `Move to: a.tsx` sequence surfaces here as `filePath` ending in `.tsx` + * (correctly blocked — NOT a bypass, verified against the parser). The + * un-verified direction is the reverse: `Add File: a.tsx` + `Move to: a.css` + * would surface only the PERMITTED destination extension while `f.content` + * is real framework code — the source path is never preserved anywhere, and + * this gate (like Write's htmlCssOnlyGate) checks only the extension, never + * the content's actual syntax. Whether Codex's real grammar allows Move-to + * after an Add (vs. Update-only) is unconfirmed; flagged, not fixed here. + * Also: the POST side still does NOT run `runDesignChecks` (tsx/jsx/css + * warnings) on an `apply_patch` file — `handle-post.ts` keeps `designGate` on + * the RAW, un-fanned envelope there (recordPost's apply_patch branch is + * promote-only and resolves `design-system.md` via `join(cwd, …)`; routing a + * fanned Write/Edit-shaped event through it instead would read `filePath` + * UNRESOLVED and risk degrading the state — a real regression, not a + * hypothetical one). Restoring the tsx/jsx/css warning would require + * resolving relative apply_patch paths AND preserving the promote-only + * doctrine inside `recordPost` at the same time — out of scope here, owner + * decision. * @packageDocumentation */ import type { Prompt } from "../prompt/types"; import type { NormalizedFile } from "./normalize"; import type { DesignState } from "../policy/design/state"; import { pluginsWriteGuard } from "../policy/design/corpus"; -import { stateFileGate } from "../policy/design/gates"; -import { designSystemWriteGate } from "../policy/design/gates-pipeline"; +import { htmlCssOnlyGate, stateFileGate, geminiEnabled } from "../policy/design/gates"; +import { designSystemWriteGate, htmlCssPipelineGate } from "../policy/design/gates-pipeline"; import { designSystemContentGate } from "./design-content-gate"; /** @@ -41,7 +71,13 @@ export function substituteLiteral(s: string, from: string, to: string, all: bool return all ? s.split(from).join(to) : s.replace(from, () => to); } -/** Gate every file of a multi-file write primitive; the first violation blocks the envelope. */ +/** + * Gate every file of a multi-file write primitive; the first violation blocks + * the envelope. `designSystemFileExists` is caller-computed (`findDesignSystem` + * lives in `design-helpers.ts`, which itself imports `substituteLiteral` from + * THIS module — importing it back here would cycle) so both write paths + * (Write/Edit in `design.ts`, apply_patch here) consume the SAME disk read. + */ export function designFilesGate( files: readonly NormalizedFile[], state: DesignState, @@ -49,11 +85,24 @@ export function designFilesGate( corpusRoot: string, corpusRequired: boolean, cwd: string, + designSystemFileExists: boolean, ): Prompt | null { for (const f of files) { const hit = pluginsWriteGuard(f.filePath, pluginsRoot, cwd) ?? stateFileGate(f.filePath); if (hit) return hit; if (f.op === "delete") continue; + const htmlCssHit = htmlCssOnlyGate(f.filePath); + if (htmlCssHit) return htmlCssHit; + // Parity with design.ts's htmlCssPipelineGate call: SAME verdict for the + // SAME file+state on both write paths (Write/Edit vs apply_patch). + const pipelineHit = htmlCssPipelineGate(f.filePath, state, designSystemFileExists); + if (pipelineHit) return pipelineHit; + // apply_patch parity for the Gemini precondition (design.ts:72-74, D2 gap): + // same condition, same Prompt shape as the Write/Edit branch — opt-in via + // FUSE_DESIGN_GEMINI (OFF by default), so a no-op when Gemini is disabled. + if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(f.filePath)) { + return { kind: "block", title: "Design pipeline", reason: "BLOCKED: generate the frontend via create_frontend before hand-writing HTML/CSS.", actions: ["Call mcp__gemini-design__create_frontend first"] }; + } if (!f.filePath.endsWith("design-system.md")) continue; const gate = designSystemWriteGate(f.filePath, state, corpusRequired) ?? (f.op === "add" diff --git a/src/runtime/design-helpers.ts b/src/runtime/design-helpers.ts index 28048e0..9b0dd91 100644 --- a/src/runtime/design-helpers.ts +++ b/src/runtime/design-helpers.ts @@ -9,11 +9,11 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, isAbsolute, join } from "node:path"; import type { NormalizedEvent } from "./normalize"; import { type DesignState, saveDesignState } from "../policy/design/state"; -import { recordScreenshot, recordCorpusRead, recordNavigate, recordScroll, recordValidDesignSystem, recordRead } from "../policy/design/transitions"; -import { classifyCorpusRead } from "../policy/design/corpus"; +import { recordNavigate, recordScroll, recordScreenshot, recordValidDesignSystem } from "../policy/design/transitions"; import { SHOT_TOOLS } from "../policy/design/screenshot-tools"; import { designSystemProblems } from "./design-content-gate"; import { substituteLiteral } from "./design-files-gate"; +import { creditRead, creditShellReads } from "./design-read-credit"; export { designSystemContentGate } from "./design-content-gate"; @@ -46,14 +46,10 @@ export function recordPost(event: NormalizedEvent, cacheDir: string, state: Desi else if (event.tool === NAV) saveDesignState(cacheDir, recordNavigate(state)); else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state)); else if (event.tool === GEMINI) saveDesignState(cacheDir, { ...state, geminiCalls: state.geminiCalls + 1 }); - else if (event.tool === "Read") { - const fp = event.filePath ?? ""; - // A corpus read counts only when anchored under the delivered root AND the - // file exists (no tool_response reaches this hook — existsSync compensates). - if (classifyCorpusRead(fp, corpusRoot) && existsSync(fp)) { - saveDesignState(cacheDir, recordCorpusRead(state, fp.slice(corpusRoot.length + 1), corpusRequired)); - } else saveDesignState(cacheDir, recordRead(state, fp, corpusRequired)); - } else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) { + else if (event.tool === "Read") creditRead(cacheDir, state, corpusRoot, corpusRequired, event.filePath ?? ""); + // Codex Code Mode exec (`exec_command`, canonicalized to "Bash" by codex-shell-tool.ts). + else if (event.tool === "Bash" && event.command) creditShellReads(cacheDir, state, corpusRoot, corpusRequired, event.command); + else if ((event.tool === "Write" || event.tool === "Edit") && (event.filePath ?? "").endsWith("design-system.md")) { // Write access ≠ validity, same rule both sides: POST validates only a // zero-problem content and DEGRADES only what PRE would have blocked. const fp = event.filePath ?? ""; diff --git a/src/runtime/design-lifecycle.ts b/src/runtime/design-lifecycle.ts index 464a950..e61dbef 100644 --- a/src/runtime/design-lifecycle.ts +++ b/src/runtime/design-lifecycle.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { detectMode } from "../policy/design/transitions"; import { initDesignState, saveDesignState, cleanupDesignStates } from "../policy/design/state"; import { setActiveDesignAgent, clearActiveDesignAgent, activeDesignAgent } from "../policy/design/flag"; +import { promptText } from "./prompt-text"; /** * Handle the design-agent SubagentStart/Stop lifecycle: init the pipeline state + @@ -29,7 +30,7 @@ export function designLifecycle(payload: Record, cacheDir: stri if (event === "SubagentStart") { if (!agentId) return false; const dsExists = existsSync(join(cwd, "design-system.md")); - const prompt = typeof payload.prompt === "string" ? payload.prompt : ""; + const prompt = promptText(payload.prompt); saveDesignState(cacheDir, initDesignState(agentId, detectMode(prompt, dsExists), dsExists)); setActiveDesignAgent(cacheDir, agentId); return true; diff --git a/src/runtime/design-read-credit.ts b/src/runtime/design-read-credit.ts new file mode 100644 index 0000000..64a6b15 --- /dev/null +++ b/src/runtime/design-read-credit.ts @@ -0,0 +1,28 @@ +/** + * @module design-read-credit + * Shared credit path for a design-corpus/ref read, reused by both the native + * `Read` branch and the shell-read branch (Codex Code Mode `exec_command`, + * canonicalized to `"Bash"` by `codex-shell-tool.ts`) in + * `design-helpers.ts::recordPost`. Split out to keep that file within the + * SOLID size budget — zero new parser, zero new store: both callers hit the + * SAME `recordCorpusRead`/`recordRead` transitions. + * @packageDocumentation + */ +import { existsSync } from "node:fs"; +import type { DesignState } from "../policy/design/state"; +import { saveDesignState } from "../policy/design/state"; +import { recordCorpusRead, recordRead } from "../policy/design/transitions"; +import { classifyCorpusRead } from "../policy/design/corpus"; +import { shellReadRefPaths } from "../policy/shell-read-refs"; + +/** Credit one read path as a corpus read (anchored + on-disk) or a plain ref read. */ +export function creditRead(cacheDir: string, state: DesignState, corpusRoot: string, corpusRequired: boolean, fp: string): void { + if (classifyCorpusRead(fp, corpusRoot) && existsSync(fp)) { + saveDesignState(cacheDir, recordCorpusRead(state, fp.slice(corpusRoot.length + 1), corpusRequired)); + } else saveDesignState(cacheDir, recordRead(state, fp, corpusRequired)); +} + +/** Credit every `.md` path a read-only shell `command` targets, same path as {@link creditRead}. */ +export function creditShellReads(cacheDir: string, state: DesignState, corpusRoot: string, corpusRequired: boolean, command: string): void { + for (const fp of shellReadRefPaths(command)) creditRead(cacheDir, state, corpusRoot, corpusRequired, fp); +} diff --git a/src/runtime/design.ts b/src/runtime/design.ts index aac638b..8e27da1 100644 --- a/src/runtime/design.ts +++ b/src/runtime/design.ts @@ -14,7 +14,7 @@ import { htmlCssOnlyGate, stateFileGate, screenshotScrollGate, geminiEnabled, } from "../policy/design/gates"; -import { designSystemWriteGate, geminiCreateGate, browserNavigateGate } from "../policy/design/gates-pipeline"; +import { designSystemWriteGate, geminiCreateGate, browserNavigateGate, htmlCssPipelineGate } from "../policy/design/gates-pipeline"; const NAV = "mcp__fuse-browser__browser_navigate"; const SHOT = "mcp__fuse-browser__browser_screenshot"; @@ -32,6 +32,21 @@ export function designGate(payload: Record, event: NormalizedEv const skillBlock = uiDesignSkillGate(event.tool, event.filePath ?? "", event.content ?? "", collectDesignEvidence(event.sessionId, cwd)); if (skillBlock) return skillBlock; } + // Codex apply_patch parity (D2 gap, docstring design-files-gate.ts:17-24): the + // same UI write can arrive fanned into event.files instead of a single + // Write/Edit. Map each non-delete file to its Write/Edit-equivalent tool + // ("add" -> Write, "update" -> Edit) and run the SAME gate, so the skill + // requirement cannot be bypassed just by routing the write through apply_patch. + // Scope matches the Write/Edit block above: ANY agent, before the agentId + // early-return — never narrowed to design-agent-only. + if (event.phase !== "post" && event.files && event.files.length > 0) { + const evidence = collectDesignEvidence(event.sessionId, cwd); + for (const f of event.files) { + if (f.op === "delete") continue; + const skillBlock = uiDesignSkillGate(f.op === "add" ? "Write" : "Edit", f.filePath, f.content, evidence); + if (skillBlock) return skillBlock; + } + } const agentId = typeof payload.agent_id === "string" ? payload.agent_id : ""; if (!agentId) return null; // top-level (lead) calls are never design-agent-scoped @@ -66,7 +81,8 @@ export function designGate(payload: Record, event: NormalizedEv if (event.tool === "Write" || event.tool === "Edit") { const fp = event.filePath ?? ""; // Parity: only design-system.md is screenshot-quota-gated (designSystemWriteGate). - const base = pluginsWriteGuard(fp, pluginsRoot) ?? stateFileGate(fp) ?? htmlCssOnlyGate(fp) ?? designSystemWriteGate(fp, state, corpusRequired) + const base = pluginsWriteGuard(fp, pluginsRoot) ?? stateFileGate(fp) ?? htmlCssOnlyGate(fp) + ?? htmlCssPipelineGate(fp, state, findDesignSystem(cwd) !== "") ?? designSystemWriteGate(fp, state, corpusRequired) ?? designSystemContentGate({ filePath: fp, tool: event.tool, content: event.content ?? "", oldString: event.oldString, replaceAll: event.input.replace_all === true, state, corpusRoot, corpusRequired }); if (base) return base; if (geminiEnabled() && state.geminiCalls === 0 && /\.(html|css)$/.test(fp)) { @@ -75,7 +91,7 @@ export function designGate(payload: Record, event: NormalizedEv return null; } // Codex apply_patch (D2): gate each fanned-out file like a Write. - if (event.files && event.files.length > 0) return designFilesGate(event.files, state, pluginsRoot, corpusRoot, corpusRequired, cwd); + if (event.files && event.files.length > 0) return designFilesGate(event.files, state, pluginsRoot, corpusRoot, corpusRequired, cwd, findDesignSystem(cwd) !== ""); if (event.tool === NAV) { return browserNavigateGate(state, typeof event.input.url === "string" ? event.input.url : ""); } diff --git a/src/runtime/handle-post.ts b/src/runtime/handle-post.ts index 30c31e2..751957d 100644 --- a/src/runtime/handle-post.ts +++ b/src/runtime/handle-post.ts @@ -24,17 +24,29 @@ import type { Prompt } from "../prompt/types"; /** * Run the PostToolUse pipeline: store the MCP response, emit a design warning, * record the activity into the session track, apply per-scope side-effects (SEO - * deny, aipilot task cache), then inject the post-edit context. Codex - * `apply_patch` is fanned into per-file events ({@link fanOutFiles}) before the - * per-file gates (tracking, SOLID size, Tailwind, post-edit context) run. + * deny, aipilot task cache), then inject the post-edit context. + * + * POST is advisory-only for the design pipeline: it can never undo a tool + * that already ran (the hard block lives in the PreToolUse `designFilesGate`). + * `designGate` therefore stays on the RAW, un-fanned event — its + * `recordPost` apply_patch branch is promote-only and resolves relative + * `design-system.md` paths via `join(cwd, …)` (design-helpers.ts); fanning + * that call would instead route the file through the Write/Edit branch, + * which reads `event.filePath` UNRESOLVED (breaking cwd-relative promotion) + * and can DEGRADE the state — both forbidden by the apply_patch promote-only + * doctrine (see `design-files-gate.ts` module doc). Only `designPassNotice` + * (pure formatting, no disk access, no state write) is fanned via + * {@link fanOutFiles}, so `apply_patch` gets one notice line per real file + * instead of none. * @param ctx - The resolved context (same shape as the pre pipeline). * @returns The native hook outcome. */ export async function handlePost(ctx: PreContext): Promise { const { id, payload, event, framework, mcpDir, file, opts } = ctx; + const designCacheDir = ctx.designCacheDir ?? mcpDir; const response = payload.tool_response ?? payload.tool_output; mcpPostStore(event.tool, event.input, response, mcpDir); - const designWarn = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot); + const designWarn = designGate(payload, event, designCacheDir, opts.cwd, opts.corpusRoot); const activities = activityFor({ tool: event.tool, input: event.input, sessionId: event.sessionId, framework, now: opts.now, responseLength: extractText(response).length }); for (const activity of activities) await recordActivity(file, activity); // Session-scoped evidence (parity track-subagent-research.py): sub-agent hooks @@ -48,7 +60,8 @@ export async function handlePost(ctx: PreContext): Promise { // (Kimi's string `tool_output` would forge a success receipt; see module). await captureBashReceipt(file, event.tool, event.command, payload.tool_result, response, opts.now); if (id === "codex") recordCodexPostFailure(event.tool, payload.tool_result ?? response, { now: opts.now, dir: defaultStateDir(opts.cwd), sessionId: event.sessionId }); - // Codex `apply_patch` fans into per-file events here; every other tool is a + // Codex `apply_patch` fans into per-file events here (tracking, SOLID size, + // Tailwind, post-edit context, and the notice below); every other tool is a // single-element identity array, so behavior below is unchanged for them. const files = fanOutFiles(event); for (const f of files) postTrackingSideEffects(opts.scope ?? "core", f, f.input, opts.now, payload, opts.cwd); @@ -72,11 +85,18 @@ export async function handlePost(ctx: PreContext): Promise { if (extra) break; } // Python-parity `post_pass`: user-visible pass notice, merged into whatever else fires - // (deny paths above returned already — a deny stays byte-identical). - const notice = designPassNotice({ - agentId: typeof payload.agent_id === "string" ? payload.agent_id : "", - tool: event.tool, filePath: event.filePath ?? "", content: event.content ?? "", url: "", phase: "post", - }, mcpDir); + // (deny paths above returned already — a deny stays byte-identical). Run + // per fanned file (event.tool/filePath/content are always undefined on the + // raw apply_patch envelope) and CONCATENATE every non-empty line — unlike + // designWarn, a notice is advisory-only, so every file's line is kept, not + // just the first. + const agentId = typeof payload.agent_id === "string" ? payload.agent_id : ""; + const noticeLines: string[] = []; + for (const f of files) { + const n = designPassNotice({ agentId, tool: f.tool, filePath: f.filePath ?? "", content: f.content ?? "", url: "", phase: "post" }, designCacheDir); + if (n?.userMessage) noticeLines.push(n.userMessage); + } + const notice: Prompt | null = noticeLines.length ? { kind: "inform", title: "Design pipeline", reason: "", userMessage: noticeLines.join("\n") } : null; // Compact compliance notice: a skill/SOLID `.md` reference credited by THIS // PostToolUse call (dedup'd against the ×11 hook fan-out inside refCreditNoticeFor). const refNotice = refCreditNoticeFor(activities, event.sessionId, opts.now, defaultStateDir(opts.cwd)); diff --git a/src/runtime/handle-pre.ts b/src/runtime/handle-pre.ts index f387d97..cdeaafd 100644 --- a/src/runtime/handle-pre.ts +++ b/src/runtime/handle-pre.ts @@ -14,6 +14,7 @@ import { isAgentTool } from "./is-agent-tool"; import { allowOutcome } from "./pre-allow"; import { applyPatchGate } from "./apply-patch-gate"; import { isBypassPermissions } from "../adapters/codex/permission-mode"; +import { confirmGate } from "./confirm/confirm-gate"; import type { HandleOptions, HandleOutcome } from "./handle"; /** Context the PreToolUse pipeline needs (resolved once by {@link handleHook}). */ @@ -23,6 +24,13 @@ export interface PreContext { event: NormalizedEvent; framework: string; mcpDir: string; + /** + * Session-anchored design-pipeline cache dir (see design-cache-resolve.ts) — + * distinct from `mcpDir`. Optional: pre-existing context literals (tests + * built before this field existed) omit it and fall back to `mcpDir` + * unchanged, so they keep their old, already-passing behavior verbatim. + */ + designCacheDir?: string; file: string; opts: HandleOptions; } @@ -34,13 +42,14 @@ export interface PreContext { */ export async function handlePre(ctx: PreContext): Promise { const { id, payload, event, framework, mcpDir, file, opts } = ctx; + const designCacheDir = ctx.designCacheDir ?? mcpDir; const intercept = mcpPreIntercept(id, event.tool, event.input, mcpDir, MCP_TTL_MS, opts.now); if (intercept !== null) { if (intercept.docSource) await recordActivity(file, { kind: "doc", framework, sessionId: event.sessionId, source: intercept.docSource }); return { stdout: intercept.stdout, exit: 0 }; } - const designBlock = designGate(payload, event, mcpDir, opts.cwd, opts.corpusRoot); + const designBlock = designGate(payload, event, designCacheDir, opts.cwd, opts.corpusRoot); if (designBlock) return { stdout: withDenyNotice(id, respond(id, designBlock), designBlock, event.sessionId, dirname(file), opts.now), exit: 0 }; // Security scope is advisory-only (ports check-security-skill.py): emit the @@ -92,9 +101,21 @@ export async function handlePre(ctx: PreContext): Promise { transcriptPath: typeof payload.transcript_path === "string" ? payload.transcript_path : undefined, neverApproval: id === "codex" && isBypassPermissions(event.permissionMode), }); - if (prompt) return { stdout: withDenyNotice(id, respond(id, prompt), prompt, event.sessionId, dirname(file), opts.now), exit: 0 }; + if (prompt) { + // CONFIRM flow: ONLY changes anything when Codex/Kimi are about to + // downgrade THIS `ask` to a hard deny (confirmGate returns null in every + // other case — including every claude-code call, unconditionally, and + // every non-`ask` prompt kind — so the line below is byte-identical to + // the pre-CONFIRM behavior whenever it applies). + const confirm = confirmGate(id, prompt, event.command, event.sessionId, opts.now, opts.home); + if (confirm?.allow) { + return allowOutcome(id, event, payload, designCacheDir, opts.cwd, { trackFile: file, windowMs: opts.windowMs, now: opts.now }, opts.corpusRoot); + } + const finalPrompt = confirm ? confirm.prompt : prompt; + return { stdout: withDenyNotice(id, respond(id, finalPrompt), finalPrompt, event.sessionId, dirname(file), opts.now), exit: 0 }; + } // Every gate allowed: hand off to the ALLOW-path assembly (pass notice + // decision-time lesson + evidence-fresh notice). A deny/ask already returned // above, so nothing it emits can block nor override a decision. - return allowOutcome(id, event, payload, mcpDir, opts.cwd, { trackFile: file, windowMs: opts.windowMs, now: opts.now }, opts.corpusRoot); + return allowOutcome(id, event, payload, designCacheDir, opts.cwd, { trackFile: file, windowMs: opts.windowMs, now: opts.now }, opts.corpusRoot); } diff --git a/src/runtime/handle-types.ts b/src/runtime/handle-types.ts index 5fdaac9..321f747 100644 --- a/src/runtime/handle-types.ts +++ b/src/runtime/handle-types.ts @@ -16,6 +16,8 @@ export interface HandleOptions { * as-is, so tests can drive the corpus-present branch deterministically. */ corpusRoot?: string; + /** Test-only OS home override for the session-anchored design cache dir (see design-cache-resolve.ts). Undefined = real `os.homedir()`. */ + home?: string; } /** What the hook bin should print + exit with. */ diff --git a/src/runtime/handle.ts b/src/runtime/handle.ts index 2b48091..77d8824 100644 --- a/src/runtime/handle.ts +++ b/src/runtime/handle.ts @@ -11,9 +11,12 @@ import { lifecycleStdout } from "./lifecycle-bridge"; import { handlePre } from "./handle-pre"; import { handlePost } from "./handle-post"; import { asyncScopeStdout } from "./handle-scope-async"; +import { resolveDesignCacheDir } from "./design-cache-resolve"; import { resyncCodexAgents } from "./lifecycle/codex-resync/resync"; import { resetFragmentRegistry } from "./fragment-registry"; import { attachBudgetRecap } from "./inject-budget-recap"; +import { promptText } from "./prompt-text"; +import { handleConfirmSubmit } from "./confirm/confirm-submit"; import type { HandleOptions, HandleOutcome } from "./handle-types"; export type { HandleOptions, HandleOutcome } from "./handle-types"; @@ -37,6 +40,10 @@ export async function handleHook(id: string, payload: Record, o const layout = projectLayout(opts.cwd); const file = trackFile(event.sessionId, defaultStateDir(opts.cwd)); const mcpDir = layout.cacheDir; + // Design pipeline (flag + `.design-state-*.json`) is anchored on the session + // id, not `mcpDir`/cwd — see design-cache-resolve.ts. `mcpDir` itself stays + // cwd-derived for the unrelated MCP/WebFetch cache. + const designCacheDir = resolveDesignCacheDir(event.sessionId, mcpDir, opts.home); const framework = detectFramework(event.filePath ?? "", event.content ?? "", opts.cwd); // Design-agent lifecycle (SubagentStart/Stop): init/cleanup the pipeline state machine. @@ -47,7 +54,7 @@ export async function handleHook(id: string, payload: Record, o // spawn_agent tool RESULT, not the hook payload). `payload.prompt` (design-mode "component" // detection below) is absent from Codex's schema — degrades to detectMode's default, not a // break. Cursor/Gemini/Cline/Hermes remain unverified — NOT added without the same proof. - if ((id === "claude-code" || id === "codex") && designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) { + if ((id === "claude-code" || id === "codex") && designLifecycle(payload, designCacheDir, opts.cwd, String(opts.now), opts.now)) { return { stdout: "", exit: 0 }; } @@ -68,15 +75,21 @@ export async function handleHook(id: string, payload: Record, o } // UserPromptSubmit (core scope): brainstorm flag + CLAUDE.md injection. - const userPrompt = typeof payload.prompt === "string" ? payload.prompt : undefined; + // `payload.prompt` is a string on Claude Code/Codex, an array of content + // blocks on Kimi (see promptText) — either shape is normalized to text; + // anything else (field absent, or an unrecognized type) stays `undefined` + // so the block below is skipped exactly as before promptText existed. + const rawPrompt = payload.prompt; + const userPrompt = typeof rawPrompt === "string" || Array.isArray(rawPrompt) ? promptText(rawPrompt) : undefined; if (userPrompt !== undefined) { + handleConfirmSubmit(event.sessionId, userPrompt, opts.now, opts.home); await withTrack(file, (track) => recordBrainstormRequired(track, detectCreationIntent(userPrompt))); return { stdout: promptSubmitContext(userPrompt, opts.cwd, id), exit: 0 }; } if (event.phase === "post") { - return handlePost({ id, payload, event, framework, mcpDir, file, opts }); + return handlePost({ id, payload, event, framework, mcpDir, designCacheDir, file, opts }); } - return handlePre({ id, payload, event, framework, mcpDir, file, opts }); + return handlePre({ id, payload, event, framework, mcpDir, designCacheDir, file, opts }); } diff --git a/src/runtime/lifecycle/dispatch.ts b/src/runtime/lifecycle/dispatch.ts index 4017288..aae2872 100644 --- a/src/runtime/lifecycle/dispatch.ts +++ b/src/runtime/lifecycle/dispatch.ts @@ -5,6 +5,7 @@ import { solidDetectStart } from "./solid-detect"; import { subagentCacheContext } from "./subagent-cache"; import { trackAgentMemory } from "./agent-memory"; import { harvestSubagentTrack } from "../../freshness/evidence-harvest-io"; +import { markSubagentSeen } from "../confirm/confirm-subagent"; import { teammateIdleContext } from "./teammate-idle-check"; import { failureLessonContext } from "./failure-lesson"; import { postCompactContext } from "./post-compact"; @@ -59,6 +60,13 @@ export function dispatchLifecycle(input: LifecycleInput): string | null { if (input.scope === "lessons") return dispatchLessons("UserPromptSubmit", input.payload, input.cwd, input.now, input.id ?? "claude-code"); return null; case "SubagentStart": + // G0 (CONFIRM-token mechanism, confirm-subagent.ts): mark this session + // as having recent sub-agent activity BEFORE the scope branching below, + // so it fires regardless of which plugin scope dispatched it. A + // monotone max-write timestamp, NOT a counter — see confirm-subagent.ts's + // subagentWindowMs doc for why a start/stop counter desyncs unsafely + // under this same multi-plugin fan-out. + markSubagentSeen(input.payload.session_id, input.now); if (input.scope === "rules") return injectRules(resolveRulesRoot(input.id ?? "claude-code", input.cwd), input.event, input.id ?? "claude-code"); if (input.scope === "aipilot") return ""; if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now, input.id ?? "claude-code"); @@ -67,6 +75,9 @@ export function dispatchLifecycle(input: LifecycleInput): string | null { if (input.scope === "lessons") return dispatchLessons("Stop", input.payload, input.cwd, input.now, input.id ?? "claude-code"); return input.scope === "core" ? stopCore(input.payload, input.cwd, input.now) : null; case "SubagentStop": + // G0 counterpart of the SubagentStart branch above — the SAME + // monotone max-write, never a decrement (see confirm-subagent.ts). + markSubagentSeen(input.payload.session_id, input.now); if (input.scope === "aipilot") return ""; // Retroactively harvest the finishing sub-agent's transcript into the session // track BEFORE the reminder — so next turn's freshness gate sees research/ diff --git a/src/runtime/normalize.ts b/src/runtime/normalize.ts index d241ba0..42aeef7 100644 --- a/src/runtime/normalize.ts +++ b/src/runtime/normalize.ts @@ -1,5 +1,6 @@ import { parseApplyPatch } from "../adapters/codex/apply-patch"; import { commandToString } from "./command-string"; +import { canonicalizeCodexShellTool } from "./codex-shell-tool"; /** One file fanned out of a multi-file edit primitive (Codex `apply_patch`). */ export interface NormalizedFile { @@ -58,7 +59,7 @@ export function normalizeEvent(id: string, payload: Record): No } const event = str(payload.hook_event_name) ?? ""; const input = (payload.tool_input as Record | undefined) ?? payload; - const tool = str(payload.tool_name) ?? ""; + const tool = canonicalizeCodexShellTool(id, str(payload.tool_name) ?? ""); const base = { phase: (/post|after/i.test(event) ? "post" : "pre") as "pre" | "post", tool, diff --git a/src/runtime/prompt-text.ts b/src/runtime/prompt-text.ts new file mode 100644 index 0000000..986a672 --- /dev/null +++ b/src/runtime/prompt-text.ts @@ -0,0 +1,38 @@ +/** + * Normalizes a hook payload's `prompt` field across harness shapes: a plain + * string (Claude Code, Codex) or an array of content blocks (Kimi 0.31.1 — + * captured live: `[{"type":"text","text":"..."}]` on `UserPromptSubmit`). + * Never throws: this runs inside a hook, where an exception breaks the harness. + */ + +/** A Kimi-shaped prompt content block: any object carrying a `text` string. */ +interface PromptTextBlock { + text: string; +} + +/** True for an object whose `text` property is a string (the only field this reads). */ +function hasTextField(value: unknown): value is PromptTextBlock { + return typeof value === "object" && value !== null && typeof (value as { text?: unknown }).text === "string"; +} + +/** + * Coerce a hook payload's `prompt` field to plain text. + * + * - A string is returned AS-IS (same reference, no trim/normalize) — this is + * the identity branch that guarantees zero regression on Claude Code/Codex, + * whose `prompt` is already a string. + * - An array (Kimi's content-block shape) is flattened: every element with a + * string `.text` is kept, joined with `"\n"` — one block per line, since + * downstream consumers ({@link detectCreationIntent}, {@link detectMode}) + * only run `\b`-anchored regexes with no line-start/end assumption, so a + * newline join is safe and reads like the multi-turn/multi-block source. + * - Anything else (`undefined`, `null`, a number, a plain object) yields `""`. + * + * @param value - The raw `prompt` field from a hook payload (unknown shape). + * @returns The prompt text, or `""` when it cannot be recovered. + */ +export function promptText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.filter(hasTextField).map((b) => b.text).join("\n"); + return ""; +} diff --git a/test/bash-write-python-content-gate.test.ts b/test/bash-write-python-content-gate.test.ts new file mode 100644 index 0000000..264ee72 --- /dev/null +++ b/test/bash-write-python-content-gate.test.ts @@ -0,0 +1,110 @@ +import { test, expect } from "bun:test"; +import { bashWriteGuard } from "../src/policy/guards/bash-write"; +import type { GuardContext } from "../src/policy/guards/context"; + +/** + * `python3 -c` content-gated write detection (bash-write-patterns.ts + * PYTHON_C_ANCHOR + PYTHON_WRITES), aligned with the existing NODE_WRITES / + * RUBY_WRITES pattern in bash-write.ts. See bash-write.ts:37-42 for the block + * site and bash-write-patterns.ts for the pattern definitions. + */ + +const cmd = (command: string): GuardContext => ({ tool: "Bash", command }); + +// Must stay BLOCKED — real mutation / process spawn behind `python3 -c`. + +test("blocks python3 -c open(...,'w').write(...)", () => { + expect(bashWriteGuard(cmd("python3 -c \"open('x.ts','w').write('bad')\""))?.kind).toBe("block"); +}); + +test("blocks python3 -c os.remove", () => { + expect(bashWriteGuard(cmd("python3 -c \"import os; os.remove('x.ts')\""))?.kind).toBe("block"); +}); + +test("blocks python3 -c subprocess.run", () => { + expect(bashWriteGuard(cmd("python3 -c \"import subprocess; subprocess.run(['rm','-rf','/'])\""))?.kind).toBe("block"); +}); + +test("blocks python3 -c shutil.rmtree", () => { + expect(bashWriteGuard(cmd("python3 -c \"import shutil; shutil.rmtree('src')\""))?.kind).toBe("block"); +}); + +test("blocks python3 -c Path(...).write_text(...)", () => { + expect(bashWriteGuard(cmd("python3 -c \"from pathlib import Path; Path('x.ts').write_text('bad')\""))?.kind).toBe("block"); +}); + +test("blocks python3 heredoc unconditionally (content not inspected)", () => { + expect(bashWriteGuard(cmd("python3 - < { + expect(bashWriteGuard(cmd("python3 -c \"import json; json.dump(d, open('f','w'))\""))?.kind).toBe("block"); +}); + +// Must PASS — read-only inline scripts. + +test("passes python3 -c print", () => { + expect(bashWriteGuard(cmd("python3 -c \"print(1)\""))).toBeNull(); +}); + +test("passes python3 -c json.load", () => { + expect(bashWriteGuard(cmd("python3 -c \"import json; print(json.load(open('f'))['x'])\""))).toBeNull(); +}); + +test("passes python3 -c json.dumps (serialize only, no I/O)", () => { + expect(bashWriteGuard(cmd("python3 -c \"import json; print(json.dumps(d))\""))).toBeNull(); +}); + +// Non-regression — node/ruby -e verdicts, and the other write-detection paths, +// must be strictly unchanged by the python content-gate addition. + +test("non-regression: node -e read-only vs write", () => { + expect(bashWriteGuard(cmd("node -e \"console.log(1)\""))).toBeNull(); + expect(bashWriteGuard(cmd("node -e \"fs.writeFileSync('x.ts','bad')\""))?.kind).toBe("ask"); +}); + +test("non-regression: ruby -e read-only vs write", () => { + expect(bashWriteGuard(cmd("ruby -e \"puts 1\""))).toBeNull(); + expect(bashWriteGuard(cmd("ruby -e \"File.write('x.ts','bad')\""))?.kind).toBe("ask"); +}); + +test("non-regression: sed/perl/awk/patch/tee-to-code stay blocked", () => { + expect(bashWriteGuard(cmd("sed -i 's/a/b/' src/x.ts"))?.kind).toBe("block"); + expect(bashWriteGuard(cmd("perl -pi -e 's/a/b/' src/x.ts"))?.kind).toBe("block"); + expect(bashWriteGuard(cmd("awk -i inplace '{print}' src/x.ts"))?.kind).toBe("block"); + expect(bashWriteGuard(cmd("patch -p1 < changes.diff"))?.kind).toBe("block"); + expect(bashWriteGuard(cmd("echo x > src/y.ts"))?.kind).toBe("block"); + expect(bashWriteGuard(cmd("echo x | tee src/z.ts"))?.kind).toBe("block"); +}); + +// Falsifiability witness: PYTHON_WRITES is what blocks the write cases, not +// PYTHON_C_ANCHOR alone or some other guard branch. Mutating PYTHON_WRITES to +// never-match (via a fresh, deliberately-unmatchable regex import path is not +// exercised here — see report §6 for the manual toggle evidence) would flip +// these to null; this test instead pins the anchor-only case to prove the +// anchor by itself does not block a read-only script. +test("anchor alone (no write content) does not block — proves the AND-gate, not just the anchor", () => { + expect(bashWriteGuard(cmd("python3 -c \"x = 1 + 1\""))).toBeNull(); +}); + +// The reported grep false positive (mandate case 3) IS fixed, but only as a +// side effect of content-gating, not because the anchor's quoting blindness +// was corrected. PYTHON_C_ANCHOR still mis-reads the `|` inside the quoted +// grep pattern as a command separator (verified true below by importing the +// anchor directly) — it is PYTHON_WRITES failing to match this specific +// string's content (no write-indicating token) that keeps the verdict at +// null. A grep pattern string that happens to ALSO contain write-indicating +// text after the `|` would still false-block; that residual gap is real and +// is NOT fixable without quote-aware tokenization (see bash-command-anchor.ts +// residual-gaps doc comment) — documented here, not silently assumed fixed. +test("grep for the literal pattern string now passes (content-gate side effect, anchor bug remains)", () => { + expect(bashWriteGuard(cmd(String.raw`grep -rn "inline script\|python3 -c\|python.*-c" src/policy`))).toBeNull(); +}); + +test("KNOWN RESIDUAL LIMITATION: a quoted grep pattern that ALSO contains write-indicating text still false-blocks", () => { + // Same quoting bug as above, but this time the anchor's false match is + // followed by content that legitimately matches PYTHON_WRITES (os.remove), + // even though it's still just a string literal inside `grep`'s pattern arg. + const result = bashWriteGuard(cmd(String.raw`grep -rn "a|python3 -c os.remove(x)" src/policy`)); + expect(result?.kind).toBe("block"); +}); diff --git a/test/bash-write.test.ts b/test/bash-write.test.ts index 24bbfae..724a4e1 100644 --- a/test/bash-write.test.ts +++ b/test/bash-write.test.ts @@ -7,9 +7,13 @@ test("blocks sed -i on a code file", () => { expect(bashWriteGuard(ctx)?.kind).toBe("block"); }); -test("blocks redirect to a code-file extension + python3 -c", () => { +test("blocks redirect to a code-file extension", () => { expect(bashWriteGuard({ tool: "Bash", command: "echo x > app.tsx" })?.kind).toBe("block"); - expect(bashWriteGuard({ tool: "Bash", command: "python3 -c 'print(1)'" })?.kind).toBe("block"); +}); + +test("python3 -c: read-only script passes, a mutating one blocks", () => { + expect(bashWriteGuard({ tool: "Bash", command: "python3 -c 'print(1)'" })).toBeNull(); + expect(bashWriteGuard({ tool: "Bash", command: "python3 -c \"import os; os.remove('a.ts')\"" })?.kind).toBe("block"); }); test("asks before redirect to a non-code file", () => { diff --git a/test/codex-shell-read-credit.test.ts b/test/codex-shell-read-credit.test.ts new file mode 100644 index 0000000..6791132 --- /dev/null +++ b/test/codex-shell-read-credit.test.ts @@ -0,0 +1,95 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { activityFor } from "../src/runtime/activity"; +import { designGate } from "../src/runtime/design"; +import { handleHook } from "../src/runtime/handle"; +import { projectLayout } from "../src/config/layout"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import { loadDesignState, saveDesignState, initDesignState } from "../src/policy/design/state"; + +/** + * A Codex Code Mode exec dispatch (`functions.exec` -> `tools.exec_command(...)`) + * surfaces `tool_name: "exec_command"` instead of `"Bash"` — the harness's ref/ + * design credit paths only ever recognized `"Bash"`, so a skill read through + * this path was invisible (0% credit, matching a real measured session). + * `codex-shell-tool.ts` canonicalizes it; `design-read-credit.ts` wires the + * SAME `shellReadRefPaths` whitelist into `recordPost`'s new shell branch. + */ +const execEv = (id: string, command: string, sessionId = "s") => + normalizeEvent(id, { hook_event_name: "PostToolUse", tool_name: "exec_command", tool_input: { command }, session_id: sessionId }); + +test("1) exec_command + sed -n skill read -> credited in refsRead (activityFor)", () => { + const path = "/proj/skills/design-web/SKILL.md"; + const event = execEv("codex", `sed -n '1,40p' ${path}`); + const activities = activityFor({ tool: event.tool, input: event.input, sessionId: "s", framework: "generic", now: 1000 }); + expect(activities).toContainEqual({ kind: "ref", path, ts: 1000 }); +}); + +test("2) same event -> design state progresses phase 0 -> 1 (production entry point: handleHook)", async () => { + const cwd = mkdtempSync(join(tmpdir(), "fh-cxr-proj-")); + const cache = projectLayout(cwd).cacheDir; + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, initDesignState("ag", "full", false)); + const path = join(cwd, "skills", "design-system", "SKILL.md"); + const payload = { hook_event_name: "PostToolUse", tool_name: "exec_command", tool_input: { command: `sed -n '1,40p' ${path}` }, session_id: "s", agent_id: "ag" }; + await handleHook("codex", payload, { now: 1000, cwd }); + expect(loadDesignState(cache, "ag")!.currentPhase).toBe(1); +}); + +test("3) corpus read via cat -> recordCorpusRead, not a plain ref read", () => { + const cache = mkdtempSync(join(tmpdir(), "fh-cxr-c-")); + const root = join(mkdtempSync(join(tmpdir(), "fh-cxr-root-")), "refs-design"); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "README.md"), "# index"); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, initDesignState("ag", "component", false)); + const event = execEv("codex", `cat ${join(root, "README.md")}`); + expect(designGate({ agent_id: "ag" }, event, cache, "/proj", root)).toBeNull(); + const state = loadDesignState(cache, "ag")!; + expect(state.corpusReads).toEqual(["README.md"]); + // component mode needs >=1 corpus read and >=1 screenshot for phase 2 — a + // read alone proves recordCorpusRead ran without asserting the full quota. +}); + +test("4) a WRITE command (sed -i / tee / >) never credits a read", () => { + const cache = mkdtempSync(join(tmpdir(), "fh-cxr-w-")); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, initDesignState("ag", "full", false)); + const path = join(cache, "skills", "design-system", "SKILL.md"); + mkdirSync(join(cache, "skills", "design-system"), { recursive: true }); + writeFileSync(path, "# skill"); + for (const cmd of [`sed -i 's/a/b/' ${path}`, `echo hi | tee ${path}`, `echo hi > ${path}`]) { + const event = execEv("codex", cmd); + expect(activityFor({ tool: event.tool, input: event.input, sessionId: "s", framework: "generic", now: 1000 }).some((a) => a.kind === "ref")).toBe(false); + } + expect(loadDesignState(cache, "ag")!.currentPhase).toBe(0); +}); + +test("5) non-regression: Claude Code Read/Bash are byte-identical, and exec_command is NOT canonicalized off Codex", () => { + // tool_name: "Read" — unaffected by the new branch (parity with activity-shell-read.test.ts). + const readEvent = normalizeEvent("claude-code", { hook_event_name: "PostToolUse", tool_name: "Read", tool_input: { file_path: "skills/solid/references/srp.md" }, session_id: "s" }); + expect(activityFor({ tool: readEvent.tool, input: readEvent.input, sessionId: "s", framework: "generic", now: 4000 })).toEqual([{ kind: "ref", path: "skills/solid/references/srp.md", ts: 4000 }]); + // tool_name: "Bash" + cat — unaffected (existing behavior, untouched branch). + const bashEvent = normalizeEvent("claude-code", { hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "cat skills/react/references/hooks.md" }, session_id: "s" }); + expect(activityFor({ tool: bashEvent.tool, input: bashEvent.input, sessionId: "s", framework: "generic", now: 2000 })).toContainEqual({ kind: "ref", path: "skills/react/references/hooks.md", ts: 2000 }); + // Scope proof: "exec_command" is CODEX-ONLY — every other harness id passes it through raw. + expect(normalizeEvent("claude-code", { hook_event_name: "PostToolUse", tool_name: "exec_command", tool_input: { command: "x" }, session_id: "s" }).tool).toBe("exec_command"); + expect(normalizeEvent("kimi", { hook_event_name: "PostToolUse", tool_name: "exec_command", tool_input: { command: "x" }, session_id: "s" }).tool).toBe("exec_command"); + expect(normalizeEvent("codex", { hook_event_name: "PostToolUse", tool_name: "exec_command", tool_input: { command: "x" }, session_id: "s" }).tool).toBe("Bash"); +}); + +test("6) exec_command running a non-read command (npm test) credits nothing", () => { + const event = execEv("codex", "npm test"); + expect(activityFor({ tool: event.tool, input: event.input, sessionId: "s", framework: "generic", now: 1000 }).some((a) => a.kind === "ref")).toBe(false); +}); + +test("7) owner's exact terrain case, absolute path, verbatim command", () => { + const path = "/Users/brunoazoulay/.codex/plugins/cache/fusengine-codex/design-expert/2.1.40/skills/design-web/SKILL.md"; + const event = execEv("codex", `sed -n '1,40p' ${path}`); + expect(event.tool).toBe("Bash"); + const activities = activityFor({ tool: event.tool, input: event.input, sessionId: "s", framework: "generic", now: 1000 }); + expect(activities).toContainEqual({ kind: "ref", path, ts: 1000 }); +}); diff --git a/test/confirm-g0.test.ts b/test/confirm-g0.test.ts new file mode 100644 index 0000000..335f879 --- /dev/null +++ b/test/confirm-g0.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from "bun:test"; +import { tmpdir } from "node:os"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { handleHook, type HandleOptions } from "../src/runtime/handle"; +import { hashForAction } from "../src/runtime/confirm/confirm-code"; +import { placeConfirmToken, consumeConfirmToken } from "../src/runtime/confirm/confirm-state"; +import { isSubagentActive, markSubagentSeen } from "../src/runtime/confirm/confirm-subagent"; +import { sessionStatePath } from "../src/runtime/home-state"; + +// Default G0 cool-down (FUSE_CONFIRM_SUBAGENT_WINDOW_SEC unset) — confirm-subagent.ts's DEFAULT_CONFIRM_WINDOW_SEC, in ms. +const SUBAGENT_WINDOW_MS = 5 * 60 * 1000; + +const cwd = (): string => mkdtempSync(join(tmpdir(), "fh-confirm-g0-cwd-")); +const home = (): string => mkdtempSync(join(tmpdir(), "fh-confirm-g0-home-")); +const sid = (label: string): string => `${label}-${randomUUID()}`; + +const pre = (id: string, s: string, command: string) => ({ hook_event_name: "PreToolUse", session_id: s, tool_name: "Bash", tool_input: { command } }); +const submit = (s: string, prompt: string) => ({ hook_event_name: "UserPromptSubmit", session_id: s, prompt }); + +/** Extract the 4-hex-char code from a "Pour autoriser, réponds : CONFIRM xxxx" deny message. */ +function codeFromDeny(stdout: string): string { + const m = stdout.match(/CONFIRM ([0-9a-f]{4})/i); + const code = m?.[1]; + if (!code) throw new Error(`no CONFIRM code in: ${stdout}`); + return code; +} + +test("G0 unit: the cool-down is a monotone window, not a start/stop toggle — it only falls once SUBAGENT_WINDOW_MS has fully elapsed since the LAST sighting", () => { + const h = home(); + const s = sid("g0-unit"); + expect(isSubagentActive(s, 1000, h)).toBe(false); + markSubagentSeen(s, 1000, h); // SubagentStart + expect(isSubagentActive(s, 1001, h)).toBe(true); + placeConfirmToken(s, hashForAction("anything"), 1001, h); // must no-op (G0) + expect(consumeConfirmToken(s, hashForAction("anything"), 1002, h)).toBe(false); + markSubagentSeen(s, 1050, h); // SubagentStop — SAME max-write, no decrement, still active + expect(isSubagentActive(s, 1051, h)).toBe(true); + placeConfirmToken(s, hashForAction("anything"), 1051, h); + expect(consumeConfirmToken(s, hashForAction("anything"), 1052, h)).toBe(false); + // Only once the window has elapsed since the LAST sighting (1050) does the flag fall. + const past = 1050 + SUBAGENT_WINDOW_MS + 1; + expect(isSubagentActive(s, past, h)).toBe(false); + placeConfirmToken(s, hashForAction("anything"), past, h); + expect(consumeConfirmToken(s, hashForAction("anything"), past + 1, h)).toBe(true); +}); + +test("G0 wiring: a real SubagentStart/Stop keeps CONFIRM blocked for the WHOLE cool-down window, not just until Stop", async () => { + // Uses the REAL default OS home (no `home` override) because dispatch.ts's + // SubagentStart/SubagentStop path does not thread HandleOptions.home — a + // unique session id keeps this isolated from any other run/session. The + // residual state file this writes to the real home is cleaned up in + // `finally` (never leave orphaned files on the owner's machine). + const opts: HandleOptions = { now: 1000, cwd: cwd() }; + const s = sid("g0-wiring"); + const cmd = "git commit -m confirm-g0-wiring"; + try { + const denied = await handleHook("codex", pre("codex", s, cmd), opts); + const code = codeFromDeny(denied.stdout); + await handleHook("codex", { hook_event_name: "SubagentStart", session_id: s, agent_id: "a1", agent_type: "x" }, { ...opts, now: 1050 }); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + const stillDenied = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: 1200 }); + expect(JSON.parse(stillDenied.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); + // SubagentStop fires — but there is no decrement/clear anywhere in this + // design, so a confirmation attempt shortly after Stop must STILL be blocked. + await handleHook("codex", { hook_event_name: "SubagentStop", session_id: s, agent_id: "a1", agent_type: "x" }, { ...opts, now: 1250 }); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1300 }); + const stillDeniedAfterStop = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: 1400 }); + expect(JSON.parse(stillDeniedAfterStop.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); + // Only once the whole window has elapsed since the LAST sighting (the + // Stop at 1250) does a fresh confirmation succeed. + const past = 1250 + SUBAGENT_WINDOW_MS + 1000; + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: past }); + const allowedNow = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: past + 100 }); + expect(allowedNow.stdout.includes('"permissionDecision":"deny"')).toBe(false); + } finally { + rmSync(sessionStatePath(s), { force: true }); + } +}); + +test("G0 env override: FUSE_CONFIRM_SUBAGENT_WINDOW_SEC changes the cool-down window", () => { + const h = home(); + const s = sid("g0-env-override"); + const oneSecWindow = { FUSE_CONFIRM_SUBAGENT_WINDOW_SEC: "1" }; + markSubagentSeen(s, 1000, h); + // Under the 1s override, 1500ms later is still inside the window... + expect(isSubagentActive(s, 1500, h, oneSecWindow)).toBe(true); + // ...but 2001ms later is past it. + expect(isSubagentActive(s, 2001, h, oneSecWindow)).toBe(false); + // The SAME timestamps stay well inside the 300s DEFAULT when no override is passed — + // proves the env key actually drives the value, not just a coincidental match. + expect(isSubagentActive(s, 2001, h)).toBe(true); + // The lever reaches placeConfirmToken too: under the override the window has + // elapsed, so G0 no longer blocks and the token gets placed. + placeConfirmToken(s, hashForAction("anything"), 2001, h, oneSecWindow); + expect(consumeConfirmToken(s, hashForAction("anything"), 2002, h)).toBe(true); +}); diff --git a/test/confirm.test.ts b/test/confirm.test.ts new file mode 100644 index 0000000..57960ba --- /dev/null +++ b/test/confirm.test.ts @@ -0,0 +1,160 @@ +import { test, expect } from "bun:test"; +import { tmpdir } from "node:os"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { handleHook, type HandleOptions } from "../src/runtime/handle"; +import { hashForAction, displayCodeForAction } from "../src/runtime/confirm/confirm-code"; +import { isIrreversible } from "../src/runtime/confirm/confirm-irreversible"; +import { confirmGate } from "../src/runtime/confirm/confirm-gate"; + +const cwd = (): string => mkdtempSync(join(tmpdir(), "fh-confirm-cwd-")); +const home = (): string => mkdtempSync(join(tmpdir(), "fh-confirm-home-")); +const sid = (label: string): string => `${label}-${randomUUID()}`; + +const pre = (id: string, s: string, command: string) => ({ hook_event_name: "PreToolUse", session_id: s, tool_name: "Bash", tool_input: { command } }); +const submit = (s: string, prompt: string) => ({ hook_event_name: "UserPromptSubmit", session_id: s, prompt }); + +/** Extract the 4-hex-char code from a "Pour autoriser, réponds : CONFIRM xxxx" deny message. */ +function codeFromDeny(stdout: string): string { + const m = stdout.match(/CONFIRM ([0-9a-f]{4})/i); + const code = m?.[1]; + if (!code) throw new Error(`no CONFIRM code in: ${stdout}`); + return code; +} + +test("baseline unchanged: codex ask with no token -> deny carrying a CONFIRM code", async () => { + const opts: HandleOptions = { now: 1000, cwd: cwd(), home: home() }; + const s = sid("baseline"); + const out = await handleHook("codex", pre("codex", s, "git commit -m confirm-baseline"), opts); + const j = JSON.parse(out.stdout); + expect(j.hookSpecificOutput.permissionDecision).toBe("deny"); + expect(out.stdout).toContain("CONFIRM "); +}); + +test("claude-code untouched: native ask stays ask, no CONFIRM text ever added", async () => { + const opts: HandleOptions = { now: 1000, cwd: cwd(), home: home() }; + const s = sid("claude-native"); + const out = await handleHook("claude-code", pre("claude-code", s, "git commit -m confirm-claude"), opts); + const j = JSON.parse(out.stdout); + expect(j.hookSpecificOutput.permissionDecision).toBe("ask"); + expect(out.stdout).not.toContain("Pour autoriser"); +}); + +test("confirm the exact action -> next identical Bash call is allowed", async () => { + const h = home(); + const opts: HandleOptions = { now: 1000, cwd: cwd(), home: h }; + const s = sid("confirm-ok"); + const cmd = "git commit -m confirm-ok-action"; + const denied = await handleHook("codex", pre("codex", s, cmd), opts); + const code = codeFromDeny(denied.stdout); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + const allowed = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: 1200 }); + expect(allowed.stdout.includes('"permissionDecision":"deny"')).toBe(false); +}); + +test("G1: a consumed token cannot be replayed for the same action", async () => { + const h = home(); + const opts: HandleOptions = { now: 1000, cwd: cwd(), home: h }; + const s = sid("g1-replay"); + const cmd = "git commit -m confirm-g1-replay"; + const denied = await handleHook("codex", pre("codex", s, cmd), opts); + const code = codeFromDeny(denied.stdout); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + await handleHook("codex", pre("codex", s, cmd), { ...opts, now: 1200 }); // consumes the token + const replay = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: 1300 }); + expect(JSON.parse(replay.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); +}); + +test("G2: a token older than the 5-minute TTL is rejected", async () => { + const h = home(); + const opts: HandleOptions = { now: 1000, cwd: cwd(), home: h }; + const s = sid("g2-stale"); + const cmd = "git commit -m confirm-g2-stale"; + const denied = await handleHook("codex", pre("codex", s, cmd), opts); + const code = codeFromDeny(denied.stdout); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + const tenMinLater = 1100 + 10 * 60 * 1000; + const stale = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: tenMinLater }); + expect(JSON.parse(stale.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); +}); + +test("G3: a token bound to one action's FULL hash never unlocks another action, even when their 4-char display codes collide", async () => { + // Verified collision (node:crypto sha256, computed offline): both share the + // display code "94e3" but their full 64-char hashes differ. + const cmdA = "git commit -m confirm-collision-127"; + const cmdB = "git commit -m confirm-collision-239"; + expect(displayCodeForAction(cmdA)).toBe("94e3"); + expect(displayCodeForAction(cmdB)).toBe("94e3"); + expect(hashForAction(cmdA)).not.toBe(hashForAction(cmdB)); + + const h = home(); + const opts: HandleOptions = { now: 1000, cwd: cwd(), home: h }; + const s = sid("g3-collision"); + const deniedA = await handleHook("codex", pre("codex", s, cmdA), opts); + const code = codeFromDeny(deniedA.stdout); + expect(code.toLowerCase()).toBe("94e3"); + // Confirm A's code — this must bind the token to A's FULL hash, not "94e3". + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + // B was never the pending action when the code was typed, and B's hash + // differs from A's — B must stay denied despite the code matching. + const deniedB = await handleHook("codex", pre("codex", s, cmdB), { ...opts, now: 1200 }); + expect(JSON.parse(deniedB.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); + // A itself must still be confirmable (token untouched by B's mismatch, per confirm-state.ts's "no-op on hash mismatch"). + const allowedA = await handleHook("codex", pre("codex", s, cmdA), { ...opts, now: 1300 }); + expect(allowedA.stdout.includes('"permissionDecision":"deny"')).toBe(false); +}); + +test("G4: git push --force is never confirmable (hard block, no CONFIRM code offered at all)", async () => { + const opts: HandleOptions = { now: 1000, cwd: cwd(), home: home() }; + const s = sid("g4-force-push"); + const out = await handleHook("codex", pre("codex", s, "git push --force"), opts); + expect(JSON.parse(out.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); + expect(out.stdout).not.toContain("CONFIRM"); +}); + +test("G4 unit: confirmGate never fires for an irreversible command even packaged as an ask prompt", () => { + const askPrompt = { kind: "ask", title: "t", reason: "r" } as const; + const verdict = confirmGate("codex", askPrompt, "git stash; rm -rf /tmp/whatever", "s-g4-unit", 1000); + expect(verdict).toBeNull(); + expect(isIrreversible("git stash; rm -rf /tmp/whatever")).toBe(true); +}); + +test("G5: an explicit refusal invalidates a pending token before it can be used", async () => { + const h = home(); + const opts: HandleOptions = { now: 1000, cwd: cwd(), home: h }; + const s = sid("g5-refusal"); + const cmd = "git commit -m confirm-g5-refusal"; + const denied = await handleHook("codex", pre("codex", s, cmd), opts); + const code = codeFromDeny(denied.stdout); + await handleHook("codex", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + await handleHook("codex", submit(s, "non merci, annule"), { ...opts, now: 1150 }); + const stillDenied = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: 1200 }); + expect(JSON.parse(stillDenied.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); +}); + +test("case/typo tolerance: confirm4f2a / Confirm-4f2a / CONFIRM_xxxx all parse", async () => { + const h = home(); + const opts: HandleOptions = { now: 1000, cwd: cwd(), home: h }; + const s = sid("case-tolerant"); + const cmd = "git commit -m confirm-case-tolerant"; + const denied = await handleHook("codex", pre("codex", s, cmd), opts); + const code = codeFromDeny(denied.stdout); + await handleHook("codex", submit(s, `confirm${code}`), { ...opts, now: 1100 }); + const allowed = await handleHook("codex", pre("codex", s, cmd), { ...opts, now: 1200 }); + expect(allowed.stdout.includes('"permissionDecision":"deny"')).toBe(false); +}); + +test("kimi degrades ask to deny too, and honors the same CONFIRM flow", async () => { + const h = home(); + const opts: HandleOptions = { now: 1000, cwd: cwd(), home: h }; + const s = sid("kimi-flow"); + const cmd = "git commit -m confirm-kimi"; + const denied = await handleHook("kimi", pre("kimi", s, cmd), opts); + const j = JSON.parse(denied.stdout); + expect(j.hookSpecificOutput.permissionDecision).toBe("deny"); + const code = codeFromDeny(denied.stdout); + await handleHook("kimi", submit(s, `CONFIRM ${code}`), { ...opts, now: 1100 }); + const allowed = await handleHook("kimi", pre("kimi", s, cmd), { ...opts, now: 1200 }); + expect(allowed.stdout.includes('"permissionDecision":"deny"')).toBe(false); +}); diff --git a/test/design-apply-patch-guards.test.ts b/test/design-apply-patch-guards.test.ts new file mode 100644 index 0000000..0e894ec --- /dev/null +++ b/test/design-apply-patch-guards.test.ts @@ -0,0 +1,190 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { designGate } from "../src/runtime/design"; +import { handlePost } from "../src/runtime/handle-post"; +import { defaultStateDir, trackFile } from "../src/runtime/paths"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import { loadDesignState, saveDesignState, initDesignState } from "../src/policy/design/state"; +import type { DesignState } from "../src/policy/design/state"; +import type { NormalizedEvent } from "../src/runtime/normalize"; +import type { PreContext } from "../src/runtime/handle-pre"; + +/** + * Non-regression matrix for the D2 htmlCssOnlyGate parity fix in the PRE path + * (Write/Edit vs apply_patch, `designFilesGate`), plus two POST regression + * witnesses proving the RAW-envelope wiring through the REAL `handlePost()` + * call site — never a hand-rolled `designGate` call, which proves nothing + * about how `handle-post.ts` itself wires the event (see its module doc and + * MEMORY/LESSON.md's fan-out regression entry). Mirrors the real chain used + * by `test/design-apply-patch.test.ts` — no mocking. + */ +const URL_DS = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; +const tmp = (): string => mkdtempSync(join(tmpdir(), "fh-apg-")); +const TSX = "export default function Foo(){ return null; }"; +const activate = (cache: string): void => { + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, initDesignState("ag", "full", false)); +}; +const patchAdd = (path: string, body: string): string => `*** Begin Patch\n*** Add File: ${path}\n+${body}\n*** End Patch`; +const apEv = (phase: string, patch: string, agentId?: string) => + normalizeEvent("codex", { hook_event_name: phase === "pre" ? "PreToolUse" : "PostToolUse", tool_name: "apply_patch", tool_input: { command: patch }, session_id: "s", ...(agentId ? { agent_id: agentId } : {}) }); + +/** + * D-html-css-pipeline-gate: a design-system.md validated in BOTH state + * (phase 3 + designSystemValid) AND on disk — the ONLY combination + * `htmlCssPipelineGate` (gates-pipeline.ts) allows a .html/.css write through. + * `activateReady` writes the real file (findDesignSystem reads disk, never state). + */ +const activateReady = (cache: string, proj: string, agentId = "ag"): void => { + setActiveDesignAgent(cache, agentId); + const state: DesignState = { ...initDesignState(agentId, "page", true), currentPhase: 3, designSystemValid: true, designSystemExists: true }; + saveDesignState(cache, state); + writeFileSync(join(proj, "design-system.md"), URL_DS); +}; + +test("Write .tsx -> DENY", () => { + const cache = tmp(), proj = tmp(); + activate(cache); + const ev = normalizeEvent("claude-code", { hook_event_name: "PreToolUse", tool_name: "Write", tool_input: { file_path: join(proj, "Foo.tsx"), content: TSX }, session_id: "s", agent_id: "ag" }); + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "")?.kind).toBe("block"); +}); +test("Edit .tsx -> DENY", () => { + const cache = tmp(), proj = tmp(); + activate(cache); + const ev = normalizeEvent("claude-code", { hook_event_name: "PreToolUse", tool_name: "Edit", tool_input: { file_path: join(proj, "Foo.tsx"), old_string: "null", new_string: "undefined" }, session_id: "s", agent_id: "ag" }); + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "")?.kind).toBe("block"); +}); +// D-html-css-pipeline-gate (the fix, test/design-html-css-pipeline-gate.test.ts +// owns the full DENY/ALLOW/parity matrix): was "Write .html -> ALLOW" unconditionally +// at any phase — the exact gap the owner reported. Setup moved to a validated +// design-system (phase 3 + on disk) so this assertion stays true post-fix. +test("Write .html, design-system validated (phase 3 + on disk) -> ALLOW", () => { + const cache = tmp(), proj = tmp(); + activateReady(cache, proj); + const ev = normalizeEvent("claude-code", { hook_event_name: "PreToolUse", tool_name: "Write", tool_input: { file_path: join(proj, "index.html"), content: "" }, session_id: "s", agent_id: "ag" }); + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "")).toBeNull(); +}); +test("apply_patch add .tsx -> DENY (the fix: htmlCssOnlyGate now runs in designFilesGate)", () => { + const cache = tmp(), proj = tmp(); + activate(cache); + const ev = apEv("pre", patchAdd(join(proj, "Foo.tsx"), TSX), "ag"); + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "")?.kind).toBe("block"); +}); +test("apply_patch add .html in phase 0, Gemini precondition ON, no create_frontend call yet -> DENY (the fix: Gemini precondition now runs in designFilesGate; this case was ALLOW before)", () => { + const cache = tmp(), proj = tmp(); + activate(cache); + const prev = process.env.FUSE_DESIGN_GEMINI; + process.env.FUSE_DESIGN_GEMINI = "1"; + try { + const ev = apEv("pre", patchAdd(join(proj, "index.html"), ""), "ag"); + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "")?.kind).toBe("block"); + } finally { + if (prev === undefined) delete process.env.FUSE_DESIGN_GEMINI; + else process.env.FUSE_DESIGN_GEMINI = prev; + } +}); +// D-html-css-pipeline-gate: both tests below require the design-system to also +// be validated now (previously ALLOW at any phase — the gap the fix closes). +test("apply_patch add .html, Gemini precondition ON but create_frontend already called (geminiCalls > 0), design-system validated -> ALLOW (not over-blocking)", () => { + const cache = tmp(), proj = tmp(); + activateReady(cache, proj); + saveDesignState(cache, { ...loadDesignState(cache, "ag")!, geminiCalls: 1 }); + const prev = process.env.FUSE_DESIGN_GEMINI; + process.env.FUSE_DESIGN_GEMINI = "1"; + try { + const ev = apEv("pre", patchAdd(join(proj, "index.html"), ""), "ag"); + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "")).toBeNull(); + } finally { + if (prev === undefined) delete process.env.FUSE_DESIGN_GEMINI; + else process.env.FUSE_DESIGN_GEMINI = prev; + } +}); +test("apply_patch add .html, Gemini precondition OFF (default), design-system validated -> ALLOW (unchanged default behavior)", () => { + const cache = tmp(), proj = tmp(); + activateReady(cache, proj); + const ev = apEv("pre", patchAdd(join(proj, "index.html"), ""), "ag"); + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "")).toBeNull(); +}); +test("apply_patch by a NON-design agent (active design agent is someone else) -> ALLOW", () => { + const cache = tmp(), proj = tmp(); + setActiveDesignAgent(cache, "someone-else"); + const ev = apEv("pre", patchAdd(join(proj, "Foo.tsx"), TSX), "ag"); + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "")).toBeNull(); +}); +test("apply_patch with no agent_id at all (top-level/lead call) -> ALLOW", () => { + const cache = tmp(), proj = tmp(); + const ev = apEv("pre", patchAdd(join(proj, "Foo.tsx"), TSX)); + expect(designGate({}, ev, cache, proj, "")).toBeNull(); +}); +test("apply_patch by a NON-design agent on a UI-path file -> same verdict as the equivalent Write (parity proof that uiDesignSkillGate's placement in design.ts covers apply_patch for ANY agent, not just design agents)", () => { + const cache = tmp(), proj = tmp(); + setActiveDesignAgent(cache, "someone-else"); // "ag" is NOT the active design agent + const uiPath = join(proj, "components", "Foo.tsx"); + const evWrite = normalizeEvent("claude-code", { hook_event_name: "PreToolUse", tool_name: "Write", tool_input: { file_path: uiPath, content: TSX }, session_id: "s", agent_id: "ag" }); + const evPatch = apEv("pre", patchAdd(uiPath, TSX), "ag"); + const writeVerdict = designGate({ agent_id: "ag" }, evWrite, cache, proj, ""); + const patchVerdict = designGate({ agent_id: "ag" }, evPatch, cache, proj, ""); + expect(writeVerdict?.kind).toBe("block"); + expect(patchVerdict?.kind).toBe("block"); + expect(patchVerdict?.reason).toBe(writeVerdict?.reason); +}); +test("apply_patch multi-file: only the 2nd file violates uiDesignSkillGate (UI-path .tsx, no skill evidence) -> the WHOLE envelope is blocked", () => { + const cache = tmp(), proj = tmp(); + activate(cache); + const patch = `*** Begin Patch\n*** Add File: ${join(proj, "notes.md")}\n+hello\n*** Add File: ${join(proj, "components", "Bad.tsx")}\n+${TSX}\n*** End Patch`; + const ev = apEv("pre", patch, "ag"); + const v = designGate({ agent_id: "ag" }, ev, cache, proj, ""); + expect(v?.kind).toBe("block"); + expect(v?.title).toBe("Design skill"); +}); +test("apply_patch multi-file: only the 2nd file violates -> the WHOLE envelope is blocked", () => { + const cache = tmp(), proj = tmp(); + activate(cache); + const patch = `*** Begin Patch\n*** Add File: ${join(proj, "ok.css")}\n+div { color: red; }\n*** Add File: ${join(proj, "Bad.tsx")}\n+${TSX}\n*** End Patch`; + const ev = apEv("pre", patch, "ag"); + expect(designGate({ agent_id: "ag" }, ev, cache, proj, "")?.kind).toBe("block"); +}); + +// --- Regression witnesses: POST apply_patch, RELATIVE design-system.md path, +// must promote via promote-only + join(cwd, …) (design-helpers.ts:79-95) — +// through the REAL `handlePost()` call site (`src/runtime/handle-post.ts:48`), +// never a hand-rolled `designGate` call: a direct call proves nothing about +// how `handle-post.ts` itself wires the raw event vs. a fanned one, which is +// exactly the reverted regression (MEMORY/LESSON.md, 2026-08-02 22:20 entry). +// Falsifiability was verified manually (not re-encoded here): temporarily +// mutating handle-post.ts:48 to `designGate(payload, files[0] ?? event, ...)` +// made the first assertion below fail (currentPhase stayed 2, not 3); the +// mutation was reverted (git diff src/ clean) before landing this file. +const dsSetup = (): { cache: string; proj: string; patch: string } => { + const cache = tmp(), proj = tmp(); + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, { ...initDesignState("ag", "full", false), currentPhase: 2, inspirationRead: true, screenshotsCount: 4 }); + writeFileSync(join(proj, "design-system.md"), URL_DS); // tool "already ran": real file on disk. + const dsLines = URL_DS.split("\n").map((l) => `+${l}`).join("\n"); + return { cache, proj, patch: `*** Begin Patch\n*** Add File: design-system.md\n${dsLines}\n*** End Patch` }; +}; +/** Same PreContext shape handlePost receives in production (mirrors test/apply-patch-post.test.ts's ctxFor), pinned to the "ag" design agent, corpus waived. */ +const postCtx = (cache: string, proj: string, event: NormalizedEvent): PreContext => ({ + id: "codex", payload: { agent_id: "ag" }, event, framework: "generic", mcpDir: cache, + file: trackFile(event.sessionId, defaultStateDir(proj)), opts: { now: 1000, cwd: proj, scope: "rules", corpusRoot: "" }, +}); +test("POST apply_patch through handlePost(): RELATIVE design-system.md path promotes (real production call site, raw envelope)", async () => { + const { cache, proj, patch } = dsSetup(); + const out = await handlePost(postCtx(cache, proj, apEv("post", patch, "ag"))); + expect(out.exit).toBe(0); + const s = loadDesignState(cache, "ag")!; + expect(s.currentPhase).toBe(3); + expect(s.designSystemValid).toBe(true); +}); +test("regression witness: handlePost() also surfaces the per-file pass notice for the promoted design-system.md", async () => { + const { cache, proj, patch } = dsSetup(); + const out = await handlePost(postCtx(cache, proj, apEv("post", patch, "ag"))); + expect(out.exit).toBe(0); + expect(out.stdout).toContain("design-system.md"); + const s = loadDesignState(cache, "ag")!; + expect(s.currentPhase).toBe(3); + expect(s.designSystemValid).toBe(true); +}); diff --git a/test/design-html-css-pipeline-gate.test.ts b/test/design-html-css-pipeline-gate.test.ts new file mode 100644 index 0000000..c8445c9 --- /dev/null +++ b/test/design-html-css-pipeline-gate.test.ts @@ -0,0 +1,105 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { designGate } from "../src/runtime/design"; +import { setActiveDesignAgent } from "../src/policy/design/flag"; +import { saveDesignState, initDesignState } from "../src/policy/design/state"; +import type { DesignState } from "../src/policy/design/state"; +import { htmlCssPipelineGate } from "../src/policy/design/gates-pipeline"; + +/** + * Matrix for `htmlCssPipelineGate` (gates-pipeline.ts): writing a .html/.css + * file in the design-agent context requires NO skill read (owner-scoped out + * of `uiDesignSkillGate`/`UI_FILE_RE` — never touched here) but DOES require + * the design-system pipeline complete: phase >= 3, `designSystemValid`, AND + * the file present on disk. Covers PRE Write/Edit (`design.ts`) and PRE + * apply_patch (`design-files-gate.ts`) with an explicit parity assertion — + * the two write paths must render the SAME verdict for the SAME file+state. + */ +const URL_DS = "## Design Reference\nInspiration: https://boulangerie-dupont.fr\n--a: oklch(0.62 0.19 250);\n--font: \"Fraunces\";"; +const tmp = (): string => mkdtempSync(join(tmpdir(), "fh-hcpg-")); +const TSX = "export default function Foo(){ return null; }"; +const patchAdd = (path: string, body: string): string => `*** Begin Patch\n*** Add File: ${path}\n+${body}\n*** End Patch`; +const apEv = (patch: string, agentId?: string) => + normalizeEvent("codex", { hook_event_name: "PreToolUse", tool_name: "apply_patch", tool_input: { command: patch }, session_id: "s", ...(agentId ? { agent_id: agentId } : {}) }); +const writeEv = (proj: string, filename: string, content: string) => + normalizeEvent("claude-code", { hook_event_name: "PreToolUse", tool_name: "Write", tool_input: { file_path: join(proj, filename), content }, session_id: "s", agent_id: "ag" }); + +/** Design agent at phase 0 — no design-system read, none on disk. Not yet ready. */ +const activatePhase0 = (cache: string): void => { + setActiveDesignAgent(cache, "ag"); + saveDesignState(cache, initDesignState("ag", "full", false)); +}; +/** Design agent with the design-system validated BOTH in state AND on disk — the only ALLOW combination. */ +const activateReady = (cache: string, proj: string): void => { + setActiveDesignAgent(cache, "ag"); + const state: DesignState = { ...initDesignState("ag", "page", true), currentPhase: 3, designSystemValid: true, designSystemExists: true }; + saveDesignState(cache, state); + writeFileSync(join(proj, "design-system.md"), URL_DS); +}; + +test("1) design-agent, .html, phase 0 -> DENY (the fix)", () => { + const cache = tmp(), proj = tmp(); + activatePhase0(cache); + expect(designGate({ agent_id: "ag" }, writeEv(proj, "index.html", ""), cache, proj, "")?.kind).toBe("block"); +}); +test("2) design-agent, .html, phase 3 + design-system validated -> ALLOW", () => { + const cache = tmp(), proj = tmp(); + activateReady(cache, proj); + expect(designGate({ agent_id: "ag" }, writeEv(proj, "index.html", ""), cache, proj, "")).toBeNull(); +}); +test("3) design-agent, .css, phase 0 -> DENY; validated -> ALLOW", () => { + const c1 = tmp(), p1 = tmp(); + activatePhase0(c1); + expect(designGate({ agent_id: "ag" }, writeEv(p1, "style.css", "body{}"), c1, p1, "")?.kind).toBe("block"); + + const c2 = tmp(), p2 = tmp(); + activateReady(c2, p2); + expect(designGate({ agent_id: "ag" }, writeEv(p2, "style.css", "body{}"), c2, p2, "")).toBeNull(); +}); +test("4) PARITY: same .html via Write vs apply_patch -> identical verdict, at phase 0 and once validated", () => { + const cD = tmp(), pD = tmp(); + activatePhase0(cD); + const wD = designGate({ agent_id: "ag" }, writeEv(pD, "index.html", ""), cD, pD, ""); + const aD = designGate({ agent_id: "ag" }, apEv(patchAdd(join(pD, "index.html"), ""), "ag"), cD, pD, ""); + expect(wD?.kind).toBe("block"); + expect(aD?.kind).toBe("block"); + + const cA = tmp(), pA = tmp(); + activateReady(cA, pA); + const wA = designGate({ agent_id: "ag" }, writeEv(pA, "index.html", ""), cA, pA, ""); + const aA = designGate({ agent_id: "ag" }, apEv(patchAdd(join(pA, "index.html"), ""), "ag"), cA, pA, ""); + expect(wA).toBeNull(); + expect(aA).toBeNull(); +}); +test("5) NON-design agent, .html, phase 0 -> ALLOW (proof of no over-blocking)", () => { + const cache = tmp(), proj = tmp(); + setActiveDesignAgent(cache, "someone-else"); // "ag" is NOT the active design agent + expect(designGate({ agent_id: "ag" }, writeEv(proj, "index.html", ""), cache, proj, "")).toBeNull(); +}); +test("6) design-agent, .md and .json, phase 0 -> unchanged (gate only matches .html/.css)", () => { + const cache = tmp(), proj = tmp(); + activatePhase0(cache); + expect(designGate({ agent_id: "ag" }, writeEv(proj, "notes.md", "hello"), cache, proj, "")).toBeNull(); + expect(designGate({ agent_id: "ag" }, writeEv(proj, "tokens.json", "{}"), cache, proj, "")).toBeNull(); +}); +test("7a) non-regression: .tsx still DENY (htmlCssOnlyGate, unrelated to this gate)", () => { + const cache = tmp(), proj = tmp(); + activatePhase0(cache); + expect(designGate({ agent_id: "ag" }, writeEv(proj, "Foo.tsx", TSX), cache, proj, "")?.kind).toBe("block"); +}); +test("7b) non-regression: design-system.md write still gated by designSystemWriteGate (phase >= 2), unaffected by the new gate", () => { + const cache = tmp(), proj = tmp(); + activatePhase0(cache); + const v = designGate({ agent_id: "ag" }, writeEv(proj, "design-system.md", URL_DS), cache, proj, ""); + expect(v?.kind).toBe("block"); + expect(v?.reason).toContain("Cannot write design-system.md"); +}); +test("scope proof: htmlCssPipelineGate is a pure predicate over (filePath, state, fileExists) — non-.html/.css always null regardless of phase", () => { + const phase0: DesignState = initDesignState("ag", "full", false); + expect(htmlCssPipelineGate("notes.md", phase0, false)).toBeNull(); + expect(htmlCssPipelineGate("Foo.tsx", phase0, false)).toBeNull(); + expect(htmlCssPipelineGate("index.html", phase0, false)?.kind).toBe("block"); +}); diff --git a/test/design-session-cache-resolve.test.ts b/test/design-session-cache-resolve.test.ts new file mode 100644 index 0000000..c96a5cd --- /dev/null +++ b/test/design-session-cache-resolve.test.ts @@ -0,0 +1,102 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveDesignCacheDir } from "../src/runtime/design-cache-resolve"; +import { activeDesignAgent, setActiveDesignAgent } from "../src/policy/design/flag"; +import { handleHook, type HandleOptions } from "../src/runtime/handle"; + +const dir = (): string => mkdtempSync(join(tmpdir(), "fh-dcr-")); + +test("resolveDesignCacheDir: no/invalid session id -> unchanged cwd dir (legacy fallback)", () => { + const cwdCache = dir(); + expect(resolveDesignCacheDir("", cwdCache, dir())).toBe(cwdCache); +}); + +test("resolveDesignCacheDir: state already exists at cwd, nothing at session -> resolves to cwd (non-regression)", () => { + const cwdCache = dir(); + setActiveDesignAgent(cwdCache, "ag-legacy"); + const home = dir(); + expect(resolveDesignCacheDir("sess-1", cwdCache, home)).toBe(cwdCache); +}); + +test("resolveDesignCacheDir: state exists at session dir -> resolves to session dir even if cwd is populated too", () => { + const cwdCache = dir(); + setActiveDesignAgent(cwdCache, "ag-legacy"); + const home = dir(); + const sessionDir = join(home, ".fuse-harness", "design-sessions", "sess-2"); + setActiveDesignAgent(sessionDir, "ag-new"); + expect(resolveDesignCacheDir("sess-2", cwdCache, home)).toBe(sessionDir); +}); + +test("resolveDesignCacheDir: nothing anywhere -> defaults to the session dir (closes the silent fail-open)", () => { + const cwdCache = dir(); + const home = dir(); + const resolved = resolveDesignCacheDir("sess-3", cwdCache, home); + expect(resolved).toBe(join(home, ".fuse-harness", "design-sessions", "sess-3")); + expect(resolved).not.toBe(cwdCache); +}); + +/** + * The bug case, reproduced through the REAL `handleHook()` entry point (not a + * hand-rolled `designGate` call): SubagentStart resolves under cwd A, the + * agent's write lands under cwd B, same session id throughout. Pre-fix, the + * cwd-keyed `mcpDir` diverges between the two calls, `designLifecycle` writes + * the flag+state under A, and the write's `designGate(..., mcpDir(B), ...)` + * finds nothing there — `activeDesignAgent` is empty, `designGate` returns + * null (runtime/design.ts:52) and the `.tsx` write is silently ALLOWED. + * Post-fix, both calls resolve the SAME session-anchored dir regardless of + * cwd, so the gate sees the active agent and BLOCKS with the real + * `htmlCssOnlyGate` design-pipeline message. + * + * `scope: "solid"` isolates the signal: it skips the unrelated generic APEX + * freshness gate (`gate()` in handle-pre.ts, reached only when `designGate` + * itself returns null) that would otherwise ALSO deny the write — for a + * completely different reason — and mask whether the design gate fired. + */ +test("SubagentStart in cwd A, write in cwd B, same session -> BLOCKED by the design pipeline (the reported bug)", async () => { + const home = dir(); + const cwdA = dir(); + const cwdB = dir(); + const sessionId = "bug-repro-session"; + const agentId = "design-a1"; + + const startPayload = { hook_event_name: "SubagentStart", agent_id: agentId, agent_type: "fuse-design:design-expert", session_id: sessionId }; + const startOpts: HandleOptions = { now: 1000, cwd: cwdA, home }; + await handleHook("claude-code", startPayload, startOpts); + + const writePayload = { + hook_event_name: "PreToolUse", + session_id: sessionId, + agent_id: agentId, + tool_name: "Write", + tool_input: { file_path: join(cwdB, "Foo.tsx"), content: "export default function Foo(){ return null; }" }, + }; + const writeOpts: HandleOptions = { now: 2000, cwd: cwdB, home, scope: "solid" }; + const out = await handleHook("claude-code", writePayload, writeOpts); + + expect(out.exit).toBe(0); + expect(out.stdout).toContain("Design pipeline"); + expect(out.stdout).toContain("can only write .html, .css, .json, .md"); +}); + +test("FALSIFIABILITY: without the session anchor (cwd-only resolution), the same scenario is silently ALLOWED", () => { + // Simulates the pre-fix resolution: mcpDir computed strictly from each + // call's own cwd (projectLayout(cwd).cacheDir), never anchored on session. + const cwdA = dir(); + const cwdB = dir(); + setActiveDesignAgent(cwdA, "design-a1"); // designLifecycle wrote the flag under A's cacheDir + expect(activeDesignAgent(cwdB)).toBe(""); // B's cacheDir never saw it -> designGate fails open +}); + +test("resolveDesignCacheDir: read and write target the same location within one session (no divergence)", () => { + const cwdCache = dir(); + const home = dir(); + const first = resolveDesignCacheDir("sess-4", cwdCache, home); + // First call decided "session dir" (nothing existed anywhere); a real write + // would land there. Simulate it, then confirm the SECOND call in the same + // session resolves to the SAME dir, not back to cwdCache. + setActiveDesignAgent(first, "ag"); + const second = resolveDesignCacheDir("sess-4", cwdCache, home); + expect(second).toBe(first); +}); diff --git a/test/parity/fixtures.ts b/test/parity/fixtures.ts index 665b3a3..d84d67a 100644 --- a/test/parity/fixtures.ts +++ b/test/parity/fixtures.ts @@ -35,7 +35,7 @@ export const CASES: readonly Case[] = [ c("block-git-clean-fd", "git clean -fd", "block"), c("block-git-branch-D", "git branch -D feature", "block"), c("block-git-rebase-force", "git rebase --force", "block"), - c("block-bw-python-c", "python3 -c 'print(1)'", "block"), + c("block-bw-python-write", "python3 -c \"import os; os.remove('a.ts')\"", "block"), c("block-bw-sed-i", "sed -i s/a/b/ src/app.ts", "block"), c("block-bw-redirect-code", "echo x > src/app.ts", "block"), c("ask-sec-rm-file", "rm notes.txt", "ask"), @@ -58,6 +58,7 @@ export const CASES: readonly Case[] = [ c("allow-git-diff", "git diff HEAD", "allow"), c("allow-echo", "echo hello world", "allow"), c("allow-cat", "cat README.md", "allow"), + c("allow-bw-python-c", "python3 -c 'print(1)'", "allow"), ]; /** Build the Claude PreToolUse stdin payload for a Bash command. */ diff --git a/test/parity/golden.snapshot.json b/test/parity/golden.snapshot.json index f1ab4c4..e5e4dbe 100644 --- a/test/parity/golden.snapshot.json +++ b/test/parity/golden.snapshot.json @@ -14,7 +14,7 @@ "block-git-clean-fd": "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"[BLOCKED] Destructive git command\\nDestructive git command: git clean -fd\\nNext:\\n 1. Use a non-destructive alternative (e.g. --force-with-lease; avoid --hard / -D)\"}}", "block-git-branch-D": "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"[BLOCKED] Destructive git command\\nDestructive git command: git branch -D feature\\nNext:\\n 1. Use a non-destructive alternative (e.g. --force-with-lease; avoid --hard / -D)\"}}", "block-git-rebase-force": "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"[BLOCKED] Destructive git command\\nDestructive git command: git rebase --force\\nNext:\\n 1. Use a non-destructive alternative (e.g. --force-with-lease; avoid --hard / -D)\"}}", - "block-bw-python-c": "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"[BLOCKED] Bash write to code file\\nPython inline script — Use Edit/Write tools instead\\nNext:\\n 1. Use the Write/Edit tool instead\"}}", + "block-bw-python-write": "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"[BLOCKED] Bash write to code file\\nPython inline script mutates files/spawns a process — Use Edit/Write tools instead\\nNext:\\n 1. Use the Write/Edit tool instead\"}}", "block-bw-sed-i": "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"[BLOCKED] Bash write to code file\\nsed in-place edit — Use Edit/Write tools instead\\nNext:\\n 1. Use the Write/Edit tool instead\"}}", "block-bw-redirect-code": "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"[BLOCKED] Bash write to code file\\nBash redirect to code file — Use Write/Edit tools (enforces APEX + SOLID specs)\\nNext:\\n 1. Use the Write/Edit tool instead\"}}", "ask-sec-rm-file": "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"[CONFIRM] Dangerous command\\nDELETE: 'rm' permanently deletes - confirmation required.\\nNext:\\n 1. Confirm this command is intended\\n 2. Run with least privilege\"}}", @@ -36,5 +36,6 @@ "allow-git-status": null, "allow-git-diff": null, "allow-echo": null, - "allow-cat": null + "allow-cat": null, + "allow-bw-python-c": null } diff --git a/test/prompt-text.test.ts b/test/prompt-text.test.ts new file mode 100644 index 0000000..9cef677 --- /dev/null +++ b/test/prompt-text.test.ts @@ -0,0 +1,56 @@ +import { test, expect } from "bun:test"; +import { promptText } from "../src/runtime/prompt-text"; +import { handleHook } from "../src/runtime/handle"; +import { loadTrack } from "../src/tracking/store"; +import { trackFile, defaultStateDir } from "../src/runtime/paths"; +import { tmpdir } from "node:os"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; + +const root = (): string => mkdtempSync(join(tmpdir(), "fh-pt-")); + +test("promptText: string identity — same reference, no trim/normalize", () => { + const s = " Foo Bar "; + expect(promptText(s)).toBe(s); +}); + +test("promptText: Kimi content-block array joins .text with \\n", () => { + const kimi = [{ type: "text", text: "Lance la commande shell: ls -la . Puis reponds OK." }]; + expect(promptText(kimi)).toBe("Lance la commande shell: ls -la . Puis reponds OK."); +}); + +test("promptText: multi-block array joins with \\n, one block per line", () => { + expect(promptText([{ type: "text", text: "a" }, { type: "text", text: "b" }])).toBe("a\nb"); +}); + +test("promptText: degraded inputs never throw, all yield \"\"", () => { + expect(promptText(undefined)).toBe(""); + expect(promptText(null)).toBe(""); + expect(promptText([])).toBe(""); + expect(promptText([{ type: "image" }])).toBe(""); + expect(promptText([{ text: 123 }])).toBe(""); + expect(promptText(42)).toBe(""); + expect(promptText({})).toBe(""); +}); + +test("handleHook: real Kimi UserPromptSubmit payload (captured 0.31.1 shape) reaches the brainstorm-intent branch, same as an equivalent Claude Code string prompt", async () => { + const cwd = root(); + const sid = "session_e8c00a10-8587-4c47-a7f1-cc20a50caeda"; + const kimiPayload = { + hook_event_name: "UserPromptSubmit", + session_id: sid, + cwd, + // Real capture, verbatim (see mission fixture) — creation-intent wording + // ("build") so recordBrainstormRequired flips a bit we can assert on. + prompt: [{ type: "text", text: "build a new component, then reply OK." }], + is_steer: false, + }; + const out = await handleHook("kimi", kimiPayload, { now: 1000, cwd }); + expect(out.exit).toBe(0); + + // Before the fix, `typeof payload.prompt === "string"` was false for this + // array shape -> userPrompt stayed undefined -> the whole branch (including + // recordBrainstormRequired) was skipped -> brainstormRequired never set. + const track = await loadTrack(trackFile(sid, defaultStateDir(cwd))); + expect(track.brainstormRequired).toBe(true); +});