Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@
- 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).
- 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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
21 changes: 16 additions & 5 deletions src/policy/guards/bash-write-redirect-op.ts
Original file line number Diff line number Diff line change
@@ -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++) {
Expand All @@ -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+|-)$/;
Expand Down Expand Up @@ -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);
Expand Down
133 changes: 133 additions & 0 deletions test/bash-write-fd-dup-paren.test.ts
Original file line number Diff line number Diff line change
@@ -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) </dev/null", "ask"],
["diff <(ls) <(ls)", "allow"],
["cmd 2> >(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");
});
});
8 changes: 8 additions & 0 deletions test/helpers/redir-corpus.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down
Loading