From b36af37b796abd3cb8ccde0f744516e0c0ec2415 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 10 Nov 2026 23:11:35 +0100 Subject: [PATCH 1/3] fix(bash-write): end fd-dup and /dev/null redirect targets at a glued closing paren `(cmd 2>&1)` read the target as "1)" and raised a false Bash file-write confirm (regression from 0.1.115). The word now ends at `)` only when it is a pure fd dup/close or exactly /dev/null; every other target keeps its previous reading. Adds case-table test and shared guardVerdict helper; records the owner decision. --- docs/decisions.md | 1 + src/policy/guards/bash-write-redirect-op.ts | 21 +++- test/bash-write-fd-dup-paren.test.ts | 133 ++++++++++++++++++++ test/helpers/redir-corpus.ts | 8 ++ 4 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 test/bash-write-fd-dup-paren.test.ts diff --git a/docs/decisions.md b/docs/decisions.md index 9ef1196..cbc57ea 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -33,3 +33,4 @@ - 2026-11-10 13:05 — Owner (directly, in this session): « Oui, clos #121 » → comment and close Fusengine/harness#121 (fixed in 0.1.115, PR #126). - 2026-11-10 14:47 — Owner (directly, in this session): « Oui, nettoie » → move the batch probe/test leftovers (chg*/chal*/c2real, twin-*, motion-* older than 30 min) to ~/.Trash (never rm) and remove the 7 merged batch worktrees + their local branches (safe delete only; fix/receipt-dedupe untouched). - 2026-11-10 14:48 — Owner (directly): « on a tous finis il manque les commit cycle » → run the commit cycle on what remains uncommitted: this journal (docs-only) committed on a fresh docs branch from origin/main via fuse-ai-pilot:commit, pushed, PR, merged (merge, no squash); no version bump or tag for a docs-only change. MEMORY/LESSON.md, .kimi/, .kimi-code/ stay out of every commit. +- 2026-11-10 22:37 — Owner (relayed by the claude-plugins lead, typed there after seeing the prompt): « donc je doit accepté tous le temps » then « fais corrigé » → fix the false "[CONFIRM] Bash file write — Shell redirect to file detected" on a command whose only redirection is `2>&1` inside `( … 2>&1) || true` (harness 0.1.115). fd-duplication targets and /dev/null are not file writes → no prompt; a `>` inside quotes or a heredoc body is not a redirection; every real file write keeps today's verdict (code-file block stays a block, 0.1.115 scratchpad window unchanged), no relaxation elsewhere. Branch `fix/fd-dup-redirect-confirm` from main, tests first, sniper, challenger, WIP. Release 0.1.116 only on the owner's direct yes in this session (a relay is no release go). diff --git a/src/policy/guards/bash-write-redirect-op.ts b/src/policy/guards/bash-write-redirect-op.ts index 10b23fb..6266223 100644 --- a/src/policy/guards/bash-write-redirect-op.ts +++ b/src/policy/guards/bash-write-redirect-op.ts @@ -1,7 +1,8 @@ import type { ShellRedirect } from "./bash-write-redirects"; -/** Read one shell word starting at `start`: strips quotes/escapes, stops at whitespace or `; & | < >`. */ -function readTarget(input: string, start: number, end: number): string { +/** Read one shell word starting at `start`: strips quotes/escapes, stops at whitespace or `; & | < >` + * (and at an unquoted, unescaped `)` when `stopParen` is set). */ +function readTarget(input: string, start: number, end: number, stopParen = false): string { let out = ""; let quote: "'" | '"' | null = null; for (let i = start; i < end; i++) { @@ -16,12 +17,22 @@ function readTarget(input: string, start: number, end: number): string { continue; } if (ch === "'" || ch === '"') { quote = ch; continue; } - if (/\s|[;&|<>]/.test(ch)) break; + if (/\s|[;&|<>]/.test(ch) || (stopParen && ch === ")")) break; out += ch; } return out; } +/** + * The word ends at a glued `)` (`(cmd 2>&1)`, `(cmd >/dev/null)`) only for the two forms that write no + * real file: a descriptor dup/close (when `canDup`) or `/dev/null`. Any other target keeps its legacy + * `)`-glued reading, so safe-path / code-file / protected-path verdicts are exactly as before. + */ +function tightTarget(input: string, start: number, end: number, legacy: string, canDup: boolean): string { + const tight = readTarget(input, start, end, true); + return tight === "/dev/null" || (canDup && FD_DUP.test(tight)) ? tight : legacy; +} + /** `>&word` where word is plain digits or `-` duplicates/closes a descriptor (no file is written). * `2-` (bash fd-move) is deliberately NOT here: zsh treats `>&2-` as a file named `2-`. */ const FD_DUP = /^(?:\d+|-)$/; @@ -66,11 +77,11 @@ export function parseOutputRedirect(input: string, i: number, start: number, end let canDup = afterOp === i + 1; if (input[cursor] === "|" || input[cursor] === "!") { cursor++; canDup = false; } while (/\s/.test(input[cursor] ?? "")) cursor++; - const target = readTarget(input, cursor, end); + const target = tightTarget(input, cursor, end, readTarget(input, cursor, end), canDup); if (!target || (canDup && FD_DUP.test(target))) return none; return { redirect: { start: opStart, operator: input.slice(opStart, ampEnd + 1), target, extended: true }, opEnd: ampEnd }; } - const target = readTarget(input, cursor, end); + const target = tightTarget(input, cursor, end, readTarget(input, cursor, end), false); if (!target) return none; const extended = run > 0 || undefined; const operator = input.slice(opStart, afterOp); diff --git a/test/bash-write-fd-dup-paren.test.ts b/test/bash-write-fd-dup-paren.test.ts new file mode 100644 index 0000000..70e1f17 --- /dev/null +++ b/test/bash-write-fd-dup-paren.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { D, P, expand, guardVerdict as verdict } from "./helpers/redir-corpus"; + +/** Regression: a descriptor dup/close or `/dev/null` target glued to a closing paren (`(cmd 2>&1)`) was read as the file `1)`. */ + +/** Writes no file: must pass silently (verdict allow). */ +const SILENT: readonly string[] = [ + "cd /Users/x/guac; (ruby -ryaml -e 'd=YAML.safe_load(File.read(\"docker-compose.yml\")); p d[\"services\"].keys, d[\"services\"][\"guacamole\"][\"environment\"][\"REMOTE_IP_VALVE_ENABLED\"]' 2>&1) || true; which bun yq", + "(echo x 2>&1)", + "(echo x 2>&1) || true", + "{ echo x 2>&1; }", + "x=$(echo y 2>&1)", + "echo x 2>&1;", + "echo x 2>&1|cat", + "echo x 2>&1&&true", + "(echo x >&2)", + "(echo x 1>&2)", + "(cat <&0)", + "(echo x 2>&-)", + "echo x >/dev/null", + "echo x 2>/dev/null", + "echo x &>/dev/null", + "(echo x >/dev/null)", + "(echo x &>/dev/null)", + "echo 'a > b'", + "ruby -e 'p 1 > 0'", + "(echo x >&2) && (echo y 2>&1)", + "((echo x 2>&1))", + "(echo x >&-)", + "(echo x >&\"2\")", + "(echo x >>/dev/null)", + "(echo x 2>/dev/null)", + "f() (echo x >&2)", + "cat <(echo x 2>&1)", + "diff <(ls 2>&1) <(ls 2>/dev/null)", + "(echo x 2>&1) | cat", + "(echo x >&2)2>&1", + "echo x >&2)", + "(echo x > /dev/null)", + "(echo x >/dev/null 2>&1)", + "(echo x 2>&1 >/dev/null)", +]; + +/** Verdict frozen from main (0282275, 0.1.115): every row must keep it. `[command, verdict]`. */ +const FROZEN: readonly (readonly [string, "allow" | "ask" | "block"])[] = [ + ["cat <<'E'\na > b\nE", "block"], + ["echo x > f", "ask"], + ["echo x >> f", "ask"], + ["echo x 2> f", "ask"], + ["echo x &> f", "ask"], + ["echo x >| f", "ask"], + ["echo x | tee f", "ask"], + ["echo x > src/a.ts", "block"], + ["(echo x > f)", "ask"], + ["(echo x 2>&1 > f)", "ask"], + ["echo x 2>&1 > f", "ask"], + ["echo x >&2; echo x > f", "ask"], + ["echo x 2>& 1file", "ask"], + ["echo x >&f", "ask"], + ["(echo x >&f)", "ask"], + ["echo x > \"&1\"", "ask"], + ["echo x >&1f", "ask"], + ["(echo x >&f.ts)", "block"], + ["fd=f; echo x >&$fd", "ask"], + ["(echo x >&1f)", "ask"], + ["(echo x 2>&1f)", "ask"], + ["(echo x >&1.ts)", "block"], + ["(echo x >&\"1)\")", "ask"], + ["(echo x >&1a)", "ask"], + ["(echo x >&2-)", "ask"], + ["(echo x >>&2)", "ask"], + ["(echo x >&|2)", "ask"], + ["(echo x &>f)", "ask"], + ["(echo x &> f.ts)", "block"], + ["(echo x >&1 > f)", "ask"], + ["(echo x > f.ts)", "block"], + ["(echo x >&2) > f", "ask"], + ["(echo x >&2); echo y > f", "ask"], + ["(echo x 2>&1); echo y > src/a.ts", "block"], + ["(echo x >f)", "ask"], + ["(echo x >>f)", "ask"], + ["(echo x >|f)", "ask"], + ["(echo x >f.ts)", "block"], + ["(echo x 2>&1)>f", "ask"], + ["echo ')' > f", "ask"], + ["echo \")\" > f", "ask"], + ["echo x > f)", "ask"], + ["(echo x > a\\)b)", "ask"], + ["echo x > >(cat)", "allow"], + ["tee >(cat) >(cat)", "allow"], + ["case x in a) echo y > f;; esac", "ask"], + ["(echo x > ~/.claude/logs); echo y | tee .env", "ask"], + ["(echo x > ~/.claude/logs)", "ask"], + ["(echo x > ~/.fuse-harness/cache)", "ask"], + ["(echo x > $HOME/.claude/logs)", "ask"], + ["(echo x > ~/.claude/logs); dd if=/etc/hosts of=notes.txt", "ask"], + ["echo x >)", "ask"], + ["echo x > )", "ask"], + ["(echo x >)", "ask"], + ["(echo x >&)", "ask"], + ["(echo x >>f)", "ask"], + ["(echo x > ~/.claude/logs/a)", "allow"], +]; + +/** The only real write is to an allowed safe path (or none): the dup no longer adds a bogus file, so the verdict equals the paren-free twin. */ +const TWINS: readonly (readonly [string, string])[] = [ + ["(echo x > ~/.claude/logs/a 2>&1)", "echo x > ~/.claude/logs/a 2>&1"], + ["(echo x 2>&1) > ~/.claude/logs/a", "echo x 2>&1 > ~/.claude/logs/a"], + ["(echo x 2>&1) && touch notes.txt", "echo x 2>&1 && touch notes.txt"], +]; + +describe("fd dup/close and /dev/null glued to a closing paren write no file", () => { + test("silent rows", () => { + for (const cmd of SILENT) expect([cmd, verdict(cmd)]).toEqual([cmd, "allow"]); + }); + test("every real file write keeps main's verdict", () => { + for (const [cmd, v] of FROZEN) expect([cmd, verdict(cmd)]).toEqual([cmd, v]); + }); + test("a glued dup next to a real write gives the paren-free twin's verdict", () => { + for (const [paren, twin] of TWINS) expect([paren, verdict(paren)]).toEqual([paren, verdict(twin)]); + }); + test("scratchpad window unchanged: inside allow, outside and subshell forms ask", () => { + const rows: readonly (readonly [string, "allow" | "ask"])[] = [ + ["echo x > @D/a.txt", "allow"], ["echo x &> @D/a.txt", "allow"], ["echo x 2>&1 > @D/a.txt", "allow"], ["echo x > @D/a.txt 2>&1", "allow"], + ["echo x > @P/a.txt", "ask"], ["echo x &> @P/a.txt", "ask"], ["(echo x > @D/a.txt)", "ask"], ["(echo x > @D/a.txt 2>&1)", "ask"], + ["(echo x >&2) > @D/a.txt", "ask"], ["echo x > @D/../a.txt", "ask"], ["(echo x > @P/a.txt)", "ask"], + ]; + for (const [cmd, v] of rows) expect([cmd, verdict(expand(cmd), D)]).toEqual([cmd, v]); + expect(P).toBe("/Users/me/proj"); + }); +}); diff --git a/test/helpers/redir-corpus.ts b/test/helpers/redir-corpus.ts index b77ac18..de05829 100644 --- a/test/helpers/redir-corpus.ts +++ b/test/helpers/redir-corpus.ts @@ -1,3 +1,11 @@ +import { bashWriteGuard } from "../../src/policy/guards/bash-write"; + +/** Verdict of the Bash write guard for `command` (`allow` = no Prompt), optionally inside a scratchpad window. */ +export function guardVerdict(command: string, scratchpadDir?: string): "allow" | "ask" | "block" { + const r = bashWriteGuard({ tool: "Bash", command, scratchpadDir }); + return r === null ? "allow" : r.kind === "ask" ? "ask" : "block"; +} + /** Fake session scratchpad dir (literal, never touched on disk) used by the redirect-narrowing corpus. */ export const D = "/private/tmp/claude-501/-Users-me-proj/aaaaaaaa-1111-4222-8333-bbbbbbbbbbbb/scratchpad"; /** Fake project root used as the "must never be written silently" target. */ From 1a108301c74001d0bbbc8d296cae6774b70e200f Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 10 Nov 2026 23:18:57 +0100 Subject: [PATCH 2/3] docs(decisions): record the 0.1.116 release decision --- docs/decisions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/decisions.md b/docs/decisions.md index cbc57ea..852f12d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -34,3 +34,4 @@ - 2026-11-10 14:47 — Owner (directly, in this session): « Oui, nettoie » → move the batch probe/test leftovers (chg*/chal*/c2real, twin-*, motion-* older than 30 min) to ~/.Trash (never rm) and remove the 7 merged batch worktrees + their local branches (safe delete only; fix/receipt-dedupe untouched). - 2026-11-10 14:48 — Owner (directly): « on a tous finis il manque les commit cycle » → run the commit cycle on what remains uncommitted: this journal (docs-only) committed on a fresh docs branch from origin/main via fuse-ai-pilot:commit, pushed, PR, merged (merge, no squash); no version bump or tag for a docs-only change. MEMORY/LESSON.md, .kimi/, .kimi-code/ stay out of every commit. - 2026-11-10 22:37 — Owner (relayed by the claude-plugins lead, typed there after seeing the prompt): « donc je doit accepté tous le temps » then « fais corrigé » → fix the false "[CONFIRM] Bash file write — Shell redirect to file detected" on a command whose only redirection is `2>&1` inside `( … 2>&1) || true` (harness 0.1.115). fd-duplication targets and /dev/null are not file writes → no prompt; a `>` inside quotes or a heredoc body is not a redirection; every real file write keeps today's verdict (code-file block stays a block, 0.1.115 scratchpad window unchanged), no relaxation elsewhere. Branch `fix/fd-dup-redirect-confirm` from main, tests first, sniper, challenger, WIP. Release 0.1.116 only on the owner's direct yes in this session (a relay is no release go). +- 2026-11-10 23:12 — Owner (directly, in this session): « Oui, publie 0.1.116 » → release the fd-dup fix (WIP b36af37) as 0.1.116: push branch, PR, CI green, merge (no squash), PATCH bump + CHANGELOG, tag v0.1.116, npm Publish watched to green; then tell the claude-plugins lead the version. From 535a3fcaf1c740575ca440cd8603598bd5cd1393 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 10 Nov 2026 23:19:15 +0100 Subject: [PATCH 3/3] chore: bump version and CHANGELOG to 0.1.116 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 026c0db..d015f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.116] - 2026-11-10 + +### Fixed + +- **bash-write: fd-dup and /dev/null targets glued to a closing paren** (`src/policy/guards/bash-write-redirect-op.ts`) — Bash write gate no longer asks to confirm `(cmd 2>&1)`, `(cmd >&2)`, `(cmd 2>&-)` or `(cmd >/dev/null)` — an fd duplication/close or /dev/null target glued to a closing parenthesis was read as a file named `1)` (regression from 0.1.115). Every other redirect target keeps its previous verdict. Tests: `bash-write-fd-dup-paren`. + ## [0.1.115] - 2026-11-10 ### Added diff --git a/package.json b/package.json index 967da8d..eb0bb37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.115", + "version": "0.1.116", "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",