@fusengine/harness 0.1.86 → 0.1.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,12 +125,12 @@ also works elsewhere.
125
125
  | Harness | PreToolUse coverage | Lifecycle (Session/Subagent/Stop/Compact/…) | Known limit |
126
126
  |---|---|---|---|
127
127
  | **claude-code** | Full: `evaluate` + APEX gates via `handleHook` (`src/adapters/claude/index.ts`) | 14 event types implemented (`dispatch.ts`) — fires once wired into `.claude/settings.json` beyond the `init` default | None found; richer lifecycle needs manual/marketplace wiring (see Quickstart) |
128
- | **codex** | Bash gated reliably; **`apply_patch` edits are now gated too** — the patch text is parsed per file (`adapters/codex/apply-patch.ts`), each hunk runs the file gates and ONE violating hunk denies the whole patch (`runtime/apply-patch-gate.ts`, sim scenario 22 incl. the multi-file smuggling case). `ask` prompts are **downgraded to explicit deny** (`respond.ts`, sim scenario 23) because Codex fails open on unsupported shapes. 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`). |
128
+ | **codex** | Bash gated reliably; **`apply_patch` edits are now gated too** — the patch text is parsed per file (`adapters/codex/apply-patch.ts`), each hunk runs the file gates and ONE violating hunk denies the whole patch (`runtime/apply-patch-gate.ts`, sim scenario 22 incl. the multi-file smuggling case). `ask` prompts are **downgraded to explicit deny** (`respond.ts`, sim scenario 23) because Codex fails open on unsupported shapes — the deny now carries a `CONFIRM <code>` 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`). |
129
129
  | **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`. |
130
130
  | **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. |
131
131
  | **cline** | `PreToolUse` only; block → `{cancel:true}`, non-block → `contextModification` (`cline/index.ts:24-36`) | none | Same as gemini-cli: stateless guard only. |
132
132
  | **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`). |
133
- | **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`. |
133
+ | **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 <code>` 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`. |
134
134
 
135
135
  ## What it enforces
136
136
 
@@ -141,7 +141,7 @@ Guard/gate chain evaluated before a tool runs (`src/policy/guards/index.ts`,
141
141
  |---|---|
142
142
  | security | `rm -rf /`, fork bombs, `curl \| sh`; `sudo` (asks) |
143
143
  | protected-path | edits to `.claude/plugins\|logs\|cache`, `.git/`, the harness's own state dirs |
144
- | bash-write | `python3 -c` / `sed -i` / redirects to code files |
144
+ | bash-write | `sed -i` / redirects to code files; `python3 -c` judged on content (mutating script → block, read-only → pass, same as `node -e`) |
145
145
  | interface-separation | top-level interface/type/protocol in a component/controller |
146
146
  | install | `npm/pip/brew/...` installs (asks) |
147
147
  | 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).
161
161
 
162
162
  Features shipped since 0.1.44, each with its own test:
163
163
 
164
+ - **`CONFIRM <code>` — recourse for a degraded `ask`** — Codex and Kimi both
165
+ ignore `permissionDecision: "ask"` (Kimi's own binary shortcuts on
166
+ `hookSpecificOutput?.permissionDecision !== "deny"`; Codex fails a hook open
167
+ on the unsupported shape), so the harness downgrades every `ask` there to a
168
+ hard `deny`. That deny now appends a short 4-hex-char code; retyping
169
+ `CONFIRM <code>` in the very next prompt authorizes that **exact** action
170
+ once (Claude Code is untouched — its native `ask` still shows an interactive
171
+ confirmation, no code ever appears there,
172
+ `src/runtime/confirm/confirm-gate.ts`). Guardrails, each independently
173
+ testable (`test/confirm.test.ts`, `test/confirm-g0.test.ts`): **G0** no token
174
+ can be placed while a sub-agent is active for this session (a monotone
175
+ max-write timestamp, window tunable via `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC`,
176
+ default 300s); **G1** a token is consumed on first use; **G2** a token
177
+ expires after 5 minutes; **G3** the token is scoped to the action's full
178
+ SHA-256 hash, never the 4-char display code (which collides by design — it
179
+ exists only for a human to retype); **G4** irreversible commands
180
+ (`push --force`, `reset --hard`, `rm -rf`, `git clean -fd`, `branch -D`, …)
181
+ are never confirmable, regardless of a valid token; **G5** an explicit
182
+ refusal in the next prompt drops any pending token. **This is a guard
183
+ against accidental/hasty denial with no recourse, not a security control
184
+ against an adversarial agent** — an agent with arbitrary shell access can
185
+ write the token straight into the session-state file it authorizes from and
186
+ self-approve, same as it could bypass any other stateful gate this harness
187
+ keeps outside a sandbox.
164
188
  - **Deny-loop breaker** — an identical retried call that was already denied gets
165
189
  a rewritten `[REPEAT] … STOP` message forcing a different approach, instead of
166
190
  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
291
315
  | `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)). |
292
316
  | `FUSE_MCP_TTL_SEC` | MCP (Context7/Exa) cache freshness, seconds (default 48h, `src/runtime/mcp-key.ts`). |
293
317
  | `FUSE_WEBFETCH_TTL_SEC` | WebFetch cache freshness, seconds (default 24h — pages stale faster than docs). |
318
+ | `FUSE_CONFIRM_SUBAGENT_WINDOW_SEC` | G0 cool-down (seconds, default `300`) for the `CONFIRM <code>` mechanism above — no token can be placed within this window of the last SubagentStart/Stop seen for the session. |
294
319
  | `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. |
295
320
  | `CLAUDE_PROJECT_DIR` | Overrides the project root used to hash the out-of-tree state dir (`src/runtime/paths.ts:20`). |
296
321
  | `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.
384
409
  checkpoint, not continuously.
385
410
  - **Hermes coverage beyond `pre_tool_call` is unverified** — no lifecycle events
386
411
  have been proven against a live Hermes install in this repo.
412
+ - **`CONFIRM <code>` is not a security boundary.** It guards against an `ask`
413
+ being silently dropped by Codex/Kimi's degrade-to-deny, i.e. against
414
+ precipitation and lost recourse — not against an adversarial agent. Any
415
+ agent with shell access can write its own confirm token into session state
416
+ and self-approve; this is true of every stateful gate this harness keeps
417
+ outside a sandbox, not specific to this mechanism.
387
418
 
388
419
  ## Develop
389
420
 
@@ -1,2 +1,2 @@
1
- import { a as fileSizeGuard, c as readClaudeInput, i as denyResponse, l as systemMessage, n as blockResponse, o as guard, r as contextResponse, s as informResponse, t as attachSystemMessage, u as toClaudeResponse } from "../../claude-CEWX-8m9.mjs";
1
+ import { a as fileSizeGuard, c as readClaudeInput, i as denyResponse, l as systemMessage, n as blockResponse, o as guard, r as contextResponse, s as informResponse, t as attachSystemMessage, u as toClaudeResponse } from "../../claude-5bC8TDAz.mjs";
2
2
  export { attachSystemMessage, blockResponse, contextResponse, denyResponse, fileSizeGuard, guard, informResponse, readClaudeInput, systemMessage, toClaudeResponse };
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-9bUzRquQ.mjs";
1
+ import { t as evaluate } from "../../evaluate-C1VSI5pb.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/cline/index.ts
4
4
  /**
@@ -1,8 +1,8 @@
1
- import { t as evaluate } from "../../evaluate-9bUzRquQ.mjs";
1
+ import { t as evaluate } from "../../evaluate-C1VSI5pb.mjs";
2
2
  import { f as countLines } from "../../home-state-oUGFB4ds.mjs";
3
3
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
4
- import { n as parseApplyPatch, t as isBypassPermissions } from "../../permission-mode-BN3MNgbm.mjs";
5
- import { _ as commandToString, c as readClaudeInput, i as denyResponse, r as contextResponse, s as informResponse } from "../../claude-CEWX-8m9.mjs";
4
+ import { n as canonicalizeCodexShellTool, r as parseApplyPatch, t as isBypassPermissions } from "../../permission-mode-B0MUl7FT.mjs";
5
+ import { _ as commandToString, c as readClaudeInput, i as denyResponse, r as contextResponse, s as informResponse } from "../../claude-5bC8TDAz.mjs";
6
6
  //#region src/adapters/codex/index.ts
7
7
  /**
8
8
  * OpenAI Codex CLI adapter (hook-mode). Codex's `PreToolUse` hook (since 2026)
@@ -51,7 +51,7 @@ function applyPatchPrompt(command) {
51
51
  function resolvePrompt(input) {
52
52
  const i = input.tool_input;
53
53
  const r = evaluate({
54
- tool: input.tool_name ?? "Write",
54
+ tool: canonicalizeCodexShellTool("codex", input.tool_name ?? "Write"),
55
55
  filePath: i?.file_path,
56
56
  content: i?.content ?? i?.new_string,
57
57
  command: commandToString(i?.command),
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-9bUzRquQ.mjs";
1
+ import { t as evaluate } from "../../evaluate-C1VSI5pb.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/cursor/index.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-9bUzRquQ.mjs";
1
+ import { t as evaluate } from "../../evaluate-C1VSI5pb.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/gemini/index.ts
4
4
  /**
@@ -1,2 +1,2 @@
1
- import { n as readHermesInput, r as toHermesResponse, t as guard } from "../../hermes-H6qePmBC.mjs";
1
+ import { n as readHermesInput, r as toHermesResponse, t as guard } from "../../hermes-kP6NUQlG.mjs";
2
2
  export { guard, readHermesInput, toHermesResponse };
@@ -1,2 +1,2 @@
1
- import { i as toKimiResponse, n as kimiDenyResponse, r as readKimiInput, t as guard } from "../../kimi-CqLkpg1A.mjs";
1
+ import { i as toKimiResponse, n as kimiDenyResponse, r as readKimiInput, t as guard } from "../../kimi-D6X7b8wF.mjs";
2
2
  export { guard, kimiDenyResponse, readKimiInput, toKimiResponse };
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "./evaluate-9bUzRquQ.mjs";
1
+ import { t as evaluate } from "./evaluate-C1VSI5pb.mjs";
2
2
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
package/dist/cli/bin.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { o as resolveStdinMaxBytes } from "../limits-DG5pl-5s.mjs";
3
- import { r as loadDotenv, s as resolveTtlSec } from "../dotenv-DoqNlALG.mjs";
3
+ import { c as resolveTtlSec, r as loadDotenv } from "../dotenv-BLBkBTww.mjs";
4
4
  import { t as detectHarness } from "../harness-BMuLJ9lm.mjs";
5
5
  import { t as claudeHome } from "../home-state-oUGFB4ds.mjs";
6
- import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-B4kqWMA3.mjs";
6
+ import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-DdWG0Meg.mjs";
7
7
  import { n as writeInitFile, t as initFor } from "../run-TAXNRSpD.mjs";
8
- import { $ as runningVersion, Q as runDoctor, Z as notify, et as versionBanner, i as respond, t as handleHook, zt as todayUtc } from "../handle-Dzf4JbrL.mjs";
8
+ import { $ as runningVersion, Q as runDoctor, Z as notify, et as versionBanner, i as respond, t as handleHook, zt as todayUtc } from "../handle-DHhukRdh.mjs";
9
9
  import { delimiter, join } from "node:path";
10
10
  import { existsSync, mkdirSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
@@ -1,2 +1,2 @@
1
- import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-B4kqWMA3.mjs";
1
+ import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-DdWG0Meg.mjs";
2
2
  export { checkStaged, stagedContent, stagedFiles };
@@ -1,5 +1,5 @@
1
1
  import { a as resolveMaxLines, c as storeBudget, i as hookBudget, l as parseEnvInt, n as DEFAULT_STDIN_MAX_BYTES, o as resolveStdinMaxBytes, r as MAX_LINES_ENV_KEY, s as splitTarget, t as DEFAULT_MAX_LINES } from "../limits-DG5pl-5s.mjs";
2
- import { a as DEFAULT_TTL_SEC, c as ttlLabel, i as parseEnvFile, n as envCandidates, o as TTL_ENV_KEY, r as loadDotenv, s as resolveTtlSec, t as HOME_DIR } from "../dotenv-DoqNlALG.mjs";
2
+ import { c as resolveTtlSec, i as parseEnvFile, l as ttlLabel, n as envCandidates, o as DEFAULT_TTL_SEC, r as loadDotenv, s as TTL_ENV_KEY, t as HOME_DIR } from "../dotenv-BLBkBTww.mjs";
3
3
  import { n as STATE_ROOT, r as projectLayout, t as STATE_GITIGNORE } from "../layout-KWoE_Mqn.mjs";
4
4
  import "../config-la_KkjCS.mjs";
5
5
  export { DEFAULT_MAX_LINES, DEFAULT_STDIN_MAX_BYTES, DEFAULT_TTL_SEC, HOME_DIR, MAX_LINES_ENV_KEY, STATE_GITIGNORE, STATE_ROOT, TTL_ENV_KEY, envCandidates, hookBudget, loadDotenv, parseEnvFile, parseEnvInt, projectLayout, resolveMaxLines, resolveStdinMaxBytes, resolveTtlSec, splitTarget, storeBudget, ttlLabel };
@@ -106,4 +106,4 @@ function loadDotenv(id, env = process.env, home = homedir(), cwd = process.cwd()
106
106
  for (const path of envCandidates(id, home, cwd)) for (const [k, v] of Object.entries(parseEnvFile(path))) if (env[k] === void 0) env[k] = v;
107
107
  }
108
108
  //#endregion
109
- export { DEFAULT_TTL_SEC as a, ttlLabel as c, parseEnvFile as i, envCandidates as n, TTL_ENV_KEY as o, loadDotenv as r, resolveTtlSec as s, HOME_DIR as t };
109
+ export { harnessHome as a, resolveTtlSec as c, parseEnvFile as i, ttlLabel as l, envCandidates as n, DEFAULT_TTL_SEC as o, loadDotenv as r, TTL_ENV_KEY as s, HOME_DIR as t };
@@ -826,10 +826,6 @@ const CODE_MUTATORS = [
826
826
  re: new RegExp(`${CMD}python3?\\s+-\\s*<<`),
827
827
  desc: "Python heredoc input"
828
828
  },
829
- {
830
- re: new RegExp(`${CMD}python3?\\s+-c\\b`),
831
- desc: "Python inline script"
832
- },
833
829
  {
834
830
  re: new RegExp(`${CMD}sed\\b[^|]*\\s-i`),
835
831
  desc: "sed in-place edit"
@@ -863,6 +859,53 @@ const CODE_COMMAND_WRITE = new RegExp(`${CMD}(?:tee\\s+[^;&|\\n]*?|dd\\b[^|]*\\b
863
859
  /** File-mutating one-liners via `node -e` / `ruby -e` (parity NODE_WRITES/RUBY_WRITES). */
864
860
  const NODE_WRITES = /writeFile|appendFile|createWriteStream|fs\.(?:write|rename|unlink|mkdir|rmdir|copyFile)|execSync|spawnSync|child_process/;
865
861
  const RUBY_WRITES = /File\.(?:write|open|delete|rename)|IO\.write|FileUtils|\bsystem\b|\bexec\b|`[^`]/;
862
+ /**
863
+ * Command-position anchor for `python3 -c` (same anchored-token shape as the
864
+ * removed unconditional CODE_MUTATORS entry) — used ONLY to gate {@link PYTHON_WRITES}
865
+ * below, never as a standalone block. `python3 - <<EOF` (heredoc) stays
866
+ * unconditionally blocked in CODE_MUTATORS — a heredoc body can't be inspected
867
+ * reliably with a single-line regex, so it is never widened.
868
+ */
869
+ const PYTHON_C_ANCHOR = new RegExp(`${CMD}python3?\\s+-c\\b`);
870
+ /**
871
+ * File/process-mutating constructs inside a `python3 -c` inline script (parity
872
+ * NODE_WRITES/RUBY_WRITES: content-gated, not name-gated). `python3 -c` is
873
+ * blocked ONLY when this matches — a read-only one-liner (`print(...)`,
874
+ * `json.load`/`json.dumps`) passes. Doc-verified against docs.python.org
875
+ * (functions/pathlib/shutil/subprocess/os/pickle) before writing this pattern.
876
+ *
877
+ * Families, each independently sufficient to mutate state or run arbitrary code:
878
+ * - `open(...)` / `Path(...).open(...)` in a write/append/exclusive/read-write
879
+ * mode — positional (`'w'`,`'x'`,`'a'`,`'r+'`,`'w+'`, …) or `mode=` kwarg, any
880
+ * `b`/`t` suffix. Plain `open(f)` / `open(f, 'r')` (read-only, the default)
881
+ * does NOT match.
882
+ * - `pathlib.Path` mutators: `write_text`, `write_bytes`, `unlink`, `rename`,
883
+ * `replace`, `mkdir`, `rmdir`, `touch`, `chmod`, `symlink_to`, `hardlink_to`,
884
+ * plus 3.14+ `move`, `move_into`, `copy`, `copy_into`.
885
+ * - `shutil.*` EXCLUDING the confirmed read-only members `which`,
886
+ * `disk_usage`, `get_terminal_size`, `get_archive_formats`,
887
+ * `get_unpack_formats` — every other shutil function copies/moves/deletes.
888
+ * - `os.*` mutators/exec: `remove`, `unlink`, `rename`, `replace`, `makedirs`,
889
+ * `mkdir`, `rmdir`, `removedirs`, `chmod`, `chown`, `symlink`, `link`,
890
+ * `truncate`, `chdir`, `fchdir`, `putenv`, `write`, `ftruncate`, `fchmod`,
891
+ * `fchown`, `fork`, `system`, `popen`, plus the `exec*`/`spawn*`/
892
+ * `posix_spawn` families.
893
+ * - `subprocess.run|call|check_call|check_output|Popen|getoutput|getstatusoutput`
894
+ * — the actual spawn surface. Non-spawning members (`PIPE`, `DEVNULL`,
895
+ * `STDOUT`, `CompletedProcess`, `CalledProcessError`, …) are deliberately
896
+ * excluded — a bare `subprocess\.\w+` would false-positive on those.
897
+ * - `pickle.dump` / `json.dump` (the file-writing form — `\b` after `dump`
898
+ * excludes `dumps`, which only serializes to a string/bytes in memory).
899
+ * - `csv.writer`, `tempfile.*` (temp-file/dir creation is still a write).
900
+ * - generic `.write(`/`.writelines(` — covers any file-like object, including
901
+ * one opened via a variable this static check can't trace back to `open()`.
902
+ * - `exec(`/`eval(` — arbitrary code execution, not merely file I/O.
903
+ *
904
+ * Deliberately NOT included: bare `import` statements, arithmetic/string
905
+ * formatting, `print`, `json.load`/`json.loads`, `sys.argv`/`os.environ` reads,
906
+ * `os.getcwd`/`os.listdir`/`os.path.*` — none of these mutate disk or spawn.
907
+ */
908
+ const PYTHON_WRITES = /* @__PURE__ */ new RegExp("\\bopen\\s*\\([^)]*(?:(['\"])(?:[wax]|r\\+|\\+r)[bt]?\\1|mode\\s*=\\s*(['\"])(?:[wax]|r\\+|\\+r)[bt]?\\2)|\\.(?:write_text|write_bytes|unlink|rename|replace|mkdir|rmdir|touch|chmod|symlink_to|hardlink_to|move_into|copy_into|move|copy|write|writelines)\\s*\\(|\\bshutil\\.(?!which\\b|disk_usage\\b|get_terminal_size\\b|get_archive_formats\\b|get_unpack_formats\\b)\\w+|\\bos\\.(?:remove|unlink|rename|replace|makedirs|mkdir|rmdir|removedirs|system|popen|chmod|chown|symlink|link|truncate|chdir|fchdir|putenv|write|ftruncate|fchmod|fchown|fork)\\b|\\bos\\.(?:exec|spawn|posix_spawn)\\w*\\b|\\bsubprocess\\.(?:run|call|check_call|check_output|Popen|getoutput|getstatusoutput)\\b|\\bpickle\\.dump\\b|\\bjson\\.dump\\b|\\bcsv\\.writer\\b|\\btempfile\\.\\w+|\\b(?:exec|eval)\\s*\\(");
866
909
  /** Redirect to a non-code file. Excludes `/dev/null`, `2>`/`N>` and `>&N` fd
867
910
  * redirects via the `(?<![0-9&])` lookbehind + `(?!…|&)` (parity has_file_redirect). */
868
911
  const FILE_REDIRECT = /(?<![0-9&])\s*>>?\s*(?!\/dev\/null|&)[a-zA-Z./~$]/;
@@ -962,6 +1005,7 @@ function bashWriteGuard(ctx) {
962
1005
  const mutator = CODE_MUTATORS.find((m) => m.re.test(cmd));
963
1006
  if (mutator) return blockCodeWrite(`${mutator.desc} — Use Edit/Write tools instead`);
964
1007
  if (CODE_COMMAND_WRITE.test(cmd)) return blockCodeWrite("tee/dd into a code file — Use Edit/Write tools instead");
1008
+ 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");
965
1009
  if (SAFE_PREFIXES.some((p) => stripped.startsWith(p)) && !FILE_REDIRECT.test(stripped)) return null;
966
1010
  if (cmd.includes(".fuse-harness/cache/sessions")) return {
967
1011
  kind: "block",