@fusengine/harness 0.1.87 → 0.1.89
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 +34 -3
- package/dist/adapters/claude/index.mjs +1 -1
- package/dist/adapters/cline/index.mjs +1 -1
- package/dist/adapters/codex/index.mjs +4 -4
- package/dist/adapters/cursor/index.mjs +1 -1
- package/dist/adapters/gemini/index.mjs +1 -1
- package/dist/adapters/hermes/index.mjs +1 -1
- package/dist/adapters/kimi/index.mjs +1 -1
- package/dist/{claude-CEWX-8m9.mjs → claude-5bC8TDAz.mjs} +1 -1
- package/dist/cli/bin.mjs +2 -2
- package/dist/cli/index.d.mts +6 -1
- package/dist/cli/index.mjs +1 -1
- package/dist/{evaluate-9bUzRquQ.mjs → evaluate-C1VSI5pb.mjs} +48 -4
- package/dist/{handle-Boj3vnxD.mjs → handle-DHhukRdh.mjs} +635 -135
- package/dist/{hermes-H6qePmBC.mjs → hermes-kP6NUQlG.mjs} +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{kimi-CqLkpg1A.mjs → kimi-D6X7b8wF.mjs} +2 -2
- package/dist/{permission-mode-BN3MNgbm.mjs → permission-mode-B0MUl7FT.mjs} +38 -1
- package/dist/policy/index.mjs +2 -2
- package/dist/{run-B4kqWMA3.mjs → run-CdIlUtjv.mjs} +15 -5
- package/dist/runtime/index.d.mts +9 -0
- package/dist/runtime/index.mjs +1 -1
- package/dist/{validate-DYPT8NY-.mjs → validate-DZFtAbLP.mjs} +1 -1
- package/package.json +1 -1
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]
|
|
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 | `
|
|
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-
|
|
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,8 +1,8 @@
|
|
|
1
|
-
import { t as evaluate } from "../../evaluate-
|
|
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-
|
|
5
|
-
import { _ as commandToString, c as readClaudeInput, i as denyResponse, r as contextResponse, s as informResponse } from "../../claude-
|
|
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,2 +1,2 @@
|
|
|
1
|
-
import { n as readHermesInput, r as toHermesResponse, t as guard } from "../../hermes-
|
|
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-
|
|
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-
|
|
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
|
@@ -3,9 +3,9 @@ import { o as resolveStdinMaxBytes } from "../limits-DG5pl-5s.mjs";
|
|
|
3
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-
|
|
6
|
+
import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-CdIlUtjv.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-
|
|
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";
|
package/dist/cli/index.d.mts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
//#region src/cli/run.d.ts
|
|
2
2
|
/** Staged files (Added/Copied/Modified/Renamed). Uses `node:child_process` (Bun shell can hang on `git show`). */
|
|
3
3
|
declare function stagedFiles(): string[];
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Read a file's staged (index) content — not the working-tree version.
|
|
6
|
+
* Uses `execFileSync` with an argv array (never a shell), so a staged
|
|
7
|
+
* filename containing shell metacharacters (backticks, `$(...)`, quotes)
|
|
8
|
+
* reaches `git show` as a single literal argument — see issue #87.
|
|
9
|
+
*/
|
|
5
10
|
declare function stagedContent(path: string): string;
|
|
6
11
|
/**
|
|
7
12
|
* Evaluate staged code files against the policy core; return violation blocks.
|
package/dist/cli/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-
|
|
1
|
+
import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-CdIlUtjv.mjs";
|
|
2
2
|
export { checkStaged, stagedContent, stagedFiles };
|
|
@@ -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",
|