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.96] - 2026-09-07

### Added

- **Verification receipts for 20 tools across 7 ecosystems** (`src/tracking/receipt-runners.ts`, `receipt-runners-parse.ts`, `receipt-runners-parse-php.ts`, `receipt-runners-parse-static.ts`, `src/tracking/receipt-command.ts`) — the TaskCompleted receipt gate previously only recognised `tsc` and `bun test`/`vitest`/`jest`/`npm test`, matched anywhere in the raw command, so Python, Go, Rust, PHP, Swift and Dart projects could never satisfy it and a commit message merely mentioning `jest` forged a receipt. Now a runners table covers `bun test`, `vitest`, `jest`, `npm`/`pnpm`/`yarn`/`bun run test`, `pytest`, `go test`, `cargo test`, `phpunit`, `pest`, `php artisan test`, `swift test`, `dart`/`flutter test`, plus `tsc`, `mypy`, `pyright`, `phpstan`, `go vet`, `go build`, `cargo check`, `cargo clippy`, `swift build`, each with a per-tool summary parser calibrated on real output (cargo pads `Finished` to 12 columns, `pytest -q` prints an undecorated summary, PHPUnit's OK-but banners count skipped tests in `Tests`). Commands are matched on the quote/heredoc-stripped text, anchored at command position, with the runner required to be the last command of the list; redirected or piped-away output is not evidence, no-run and informational flags never count, and a receipt now needs positive evidence — tests executed above zero with zero failures, the tool's own success line, or documented silence with intact output. A missing exit code means no capture. This is a guard against forgetting to verify and against honest shortcuts, not a security control against deliberate output fabrication. `bash-command-anchor.ts` now exports its parts (`CMD` unchanged); gate messages were generalised for the new tool set. Tests: 1779 to 1837.

## [0.1.95] - 2026-09-06

### Fixed
Expand Down
28 changes: 14 additions & 14 deletions MEMORY/LESSON.md

Large diffs are not rendered by default.

42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,45 @@ Features shipped since 0.1.44, each with its own test:
- **One-shot gate metric** — every gate outcome (deny or its later fix) lands in
a 7-day sidecar keyed by a content-free op hash, so a deny→allow transition is
visible (`src/tracking/one-shot.ts`, `test/one-shot.test.ts`).
- **Verification receipts** — a `tsc`/test run is captured from PostToolUse Bash
output; `TaskCompleted` **refuses** a "done" over modified code files without a
fresh passing receipt (`src/tracking/receipts.ts`, `test/receipts.test.ts`).
- **Verification receipts** — a static-check or test run is captured from
PostToolUse Bash output, spanning TS/JS (`tsc`, `bun test`/`vitest`/`jest`/
`npm|pnpm|yarn test`), Python (`mypy`, `pyright`, `pytest`), Go (`go vet`/
`build`/`test`), Rust (`cargo check`/`clippy`/`test`), PHP (`phpstan`,
`phpunit`/`pest`/`php artisan test`), Swift (`swift build`/`test`), and Dart/
Flutter (`dart`/`flutter test`); `TaskCompleted` **refuses** a "done" over
modified code files without a fresh passing receipt. Commands are matched
after quote/heredoc stripping (a tool name mentioned in a commit message or
heredoc body is never a receipt) — and the recognised runner must be the
LAST command of the line: the unquoted text is split into shell list
segments on `;`/`&&`/`||`/a lone `&`/newline (never a bare pipe, which still
carries the runner's real output forward), and only the LAST segment is
searched, so `bun test && git commit -m x` and `bun test || true` both null
while `cd x && bun test` and `bun test 2>&1 | tail -5` both still resolve. A
zero-test invocation (`--watch`, `--collect-only`, `0 passed; 0 failed`) is
never treated as proof, and static checkers (`tsc`, `go vet`/`build`, `cargo
check`/`clippy`, `swift build`) parse their OWN diagnostics so a masked exit
code doesn't hide a real failure. A residual trade-off: a truncating pipe
(`cargo test --no-fail-fast 2>&1 | tail -5`) can still cut away a failing
target's own `test result: FAILED` line — `cargo test`'s parser also counts
cargo's own trailing `error: N targets failed` line as a fail floor, but a
pipe that removes ALL evidence remains an accepted risk, in exchange for
correctly resolving genuine `| tail`/`| grep` pipelines instead of nulling
every piped command. A receipt needs POSITIVE evidence (a
parsed summary or the tool's success line): output suppression
(`2>/dev/null`, ANY output redirection after the runner in its own
segment — not just `/dev/null` — for a static checker, a stdout redirect for
a test runner, or a pipe after a static checker), `--version`/`--help`/other
no-run flags, and silent/ambiguous output never count. **Threat model**: a
receipt guards against forgetting to verify and against honest shortcuts (a
discarded exit code, an informational invocation, a filtered run) — it is
NOT a security control against an agent that deliberately fabricates tool
output, same caveat as the `CONFIRM` code above.
(`src/tracking/receipt-runners.ts`, `src/tracking/receipt-command.ts`,
`src/tracking/receipts.ts`, `test/receipt-runners.test.ts`,
`test/receipts.test.ts`, `test/receipt-runners-forgery.test.ts`,
`test/receipt-runners-hardening.test.ts`,
`test/receipt-runners-evidence.test.ts`,
`test/receipt-runners-structural.test.ts`).
- **Decision-time lessons** — a `MEMORY/LESSON.md` bullet tagged with
`[TRIGGERS tool:… path:… error:… keyword:…]` is injected as `additionalContext`
the moment a matching call is about to repeat a known mistake, cooldown-guarded
Expand Down
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.95",
"version": "0.1.96",
"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
12 changes: 9 additions & 3 deletions src/policy/guards/bash-command-anchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@
* Command-position anchoring for the Bash write guard — the regex prefix that
* tells a real mutator invocation apart from a quoted/argument mention of the
* same token. Consumed by bash-write-patterns.ts (CODE_MUTATORS, CODE_COMMAND_WRITE).
* {@link WRAP}/{@link WRAP_ARG}/{@link ENV_PREFIX} are also exported for
* `src/tracking/receipt-runners.ts`, which composes its OWN anchor from the
* same parts minus the backtick separator — a backtick never actually starts
* a new command (it opens command substitution), so over-matching it as one
* is safe for a write guard (defense-in-depth) but forges a false PROOF of
* success for a verification receipt.
*/

/**
* Transparent exec wrappers that run another command as their argument
* (`env sed -i …`, `timeout 5 patch …`, `sudo -n tee …`, `xargs sed -i`). A code
* mutator behind one is still a code mutator — the wrapper is not a shield.
*/
const WRAP = "(?:env|timeout|nice|nohup|sudo|command|stdbuf|time|ionice|exec|xargs)";
export const WRAP = "(?:env|timeout|nice|nohup|sudo|command|stdbuf|time|ionice|exec|xargs)";

/**
* A wrapper's own leading arg tokens: `VAR=val` assignments, `-f`/`--flag`
Expand All @@ -19,7 +25,7 @@ const WRAP = "(?:env|timeout|nice|nohup|sudo|command|stdbuf|time|ionice|exec|xar
* alternative consumes ≥1 char and is followed by a mandatory `\s+`, so the
* repeated group is never zero-width (no `(X*)*` catastrophic shape).
*/
const WRAP_ARG = "(?:\\w+=\\S+|--?[^\\s-]\\S*|\\d+[smhd]?)";
export const WRAP_ARG = "(?:\\w+=\\S+|--?[^\\s-]\\S*|\\d+[smhd]?)";

/**
* Command-position anchor: line/segment start or a `;&|(` \` separator, then any
Expand Down Expand Up @@ -49,6 +55,6 @@ const WRAP_ARG = "(?:\\w+=\\S+|--?[^\\s-]\\S*|\\d+[smhd]?)";
* match a mutator/write pattern (recognizing a previously-invisible wrapped
* invocation); it can never turn an existing block/ask into an allow.
*/
const ENV_PREFIX = "(?:\\w+=\\S*\\s+)*";
export const ENV_PREFIX = "(?:\\w+=\\S*\\s+)*";

export const CMD: string = `(?:^|[\\n;&|(\x60])\\s*${ENV_PREFIX}(?:${WRAP}\\s+(?:${WRAP_ARG}\\s+)*)*`;
5 changes: 3 additions & 2 deletions src/runtime/handle-post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ export async function handlePost(ctx: PreContext): Promise<HandleOutcome> {
// Codex multi_agent_v2 `spawn_agent` -> same session track (no-op for every
// other harness / non-spawn tool / missing `agent_type`; see module doc).
await recordCodexSpawnEvidence(file, id, event.tool, event.input, opts.now);
// Verification receipts (tsc/bun test runs) — structured responses only
// (Kimi's string `tool_output` would forge a success receipt; see module).
// Verification receipts (static-check/test runs, see receipt-runners) —
// structured responses only (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` and Cursor `afterFileEdit` fan into per-file events for
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/lifecycle/agent-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function trackAgentMemory(data: Record<string, unknown>, home: string = h
// Window = TTL×5, matching the TaskCompleted receipt gate.
const windowMs = resolveTtlSec(process.env) * 1000 * 5;
const noReceipt = freshReceiptFromFile(trackFile(sessionId, defaultStateDir(process.cwd())), windowMs, now) === null;
const note = noReceipt ? " NO VERIFICATION RECEIPT — run tsc + tests before reporting done." : "";
const note = noReceipt ? " NO VERIFICATION RECEIPT — run your static checker + test suite before reporting done." : "";
return contextResponse("SubagentStop", `SNIPER VALIDATION REQUIRED: Agent '${agentType}' modified ${present.length} code file(s): ${present.join(", ")}. Run sniper agent now.${note}`);
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/runtime/lifecycle/task-completed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ function receiptGate(sid: string, files: string[], now: number, stateDir: string
const windowMs = resolveTtlSec(process.env) * 1000 * RECEIPT_TTL_MULTIPLIER;
if (freshReceiptFromFile(trackFile(sid, stateDir), windowMs, now)) return null;
const stopReason =
"VERIFICATION RECEIPT REQUIRED: code files changed but no fresh passing tsc/test receipt " +
"exists. Run `bun test` + `tsc --noEmit` (exit 0, 0 failures) and re-complete.";
"VERIFICATION RECEIPT REQUIRED: code files changed but no fresh passing verification receipt " +
"exists. Run your test suite and static checker (bun test + tsc, pytest + mypy, go test + go vet, " +
"cargo test + cargo check, phpunit/pest + phpstan, swift test, dart test) with exit 0 and 0 failures, " +
"then re-complete.";
return JSON.stringify({ continue: false, stopReason });
}

Expand Down
13 changes: 8 additions & 5 deletions src/runtime/receipt-capture.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Bash verification-receipt extraction for PostToolUse: a `tsc`/`bun test`
* run is parsed (exit code + output) into a signed receipt the TaskCompleted
* gate later demands. Structured responses ONLY: Kimi Code CLI sends
* Bash verification-receipt extraction for PostToolUse: a static-check/test
* run (see `receipt-runners`) is parsed (exit code + output) into a signed
* receipt the TaskCompleted gate later demands. Structured responses ONLY: Kimi Code CLI sends
* `tool_output` as a truncated STRING (no exit_code, no stdout/stderr
* channels) — parsing it would forge a success receipt (`exit 0`, empty
* output) for possibly-failed runs, so a non-object source is skipped.
Expand All @@ -28,7 +28,10 @@ export async function captureBashReceipt(
if (tool !== "Bash" || !command) return;
const r = (toolResult ?? response) as { exit_code?: unknown; stdout?: unknown; stderr?: unknown } | undefined;
if (!r || typeof r !== "object") return;
// D4: a missing/non-finite exit_code is NOT the same as exit 0 — crediting
// it would forge a passing receipt for a response shape that never
// actually reported success. Skip capture entirely rather than guess.
if (typeof r.exit_code !== "number" || !Number.isFinite(r.exit_code)) return;
const out = `${typeof r.stdout === "string" ? r.stdout : ""}\n${typeof r.stderr === "string" ? r.stderr : ""}`;
const exit = Number(r.exit_code ?? 0);
await captureReceipt(file, command, out, Number.isFinite(exit) ? exit : 0, now);
await captureReceipt(file, command, out, r.exit_code, now);
}
130 changes: 130 additions & 0 deletions src/tracking/receipt-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* @module receipt-command
* Command-SHAPE rules for {@link classifyReceipt} (`./receipts`): which
* command text is even eligible to be scored as a receipt at all, and which
* runner (if any) it resolves to. Split out of `./receipts` to keep it under
* the SOLID line ceiling.
*
* Threat model (explicit): these rules guard against FORGETTING to verify
* and against honest shortcuts — a run whose output/exit code was
* accidentally discarded, an informational invocation, a filtered run. They
* are NOT a security control against an agent that deliberately fabricates
* tool output, same caveat as the `CONFIRM` code in the README ("a guard
* against accidental/hasty …, not a security control against an adversarial
* agent").
*
* Structural rule (replaces the old `EXIT_MASK_RE` blacklist): the recognised
* runner must be the LAST command of the line — {@link lastListSegment}
* splits the unquoted command on `;`, `&&`, `||`, a lone job-control `&`, and
* newline (never a bare pipe, which still carries the runner's real output
* forward) and only the LAST segment is searched. `bun test && git commit -m
* x` and `bun test || true` both null: the runner sits in an EARLIER segment
* only. `cd x && bun test` and `bun test 2>&1 | tail -5` both still resolve:
* the runner is in (or is) the last segment either way.
*/
import type { RunnerSpec } from "./receipts";
import { RUNNERS } from "./receipt-runners";

/**
* A flag/subcommand that runs zero (or an unbounded, still-running) tests, or
* is purely INFORMATIONAL (version/help/config banner, no real job done):
* `--collect-only`/`--co` (pytest), `--dry-run`, `--list`/`--listTests`/
* `--list-tests` (jest/swift/dart), `--watch`/`--watchAll`/`--watch-all`
* (bun/vitest/jest never exit), `--no-run` (cargo test), `--version`/`-V`,
* `--help`/`-h`, `--init`/`--showConfig` (jest), `--fixtures` (pytest),
* `-count=0` (go test), `go build -n` (dry-run). `--skip-build` (swift test)
* is NOT here: it skips the BUILD step only, the tests still run — treating
* it as informational was a false negative (real defect, fixed).
*
* `(?!=false)` excludes `--watch=false`/`--watchAll=false`/`--watch-all=false`
* — Vitest's own config docs list `--watch=false` alongside `-w`/`--watch` as
* an explicit CLI form (the option defaults to `true`), and Jest's
* `--watchAll[=false]` is the documented way to force run-once mode on a
* boolean CLI flag.
*/
export const NO_RUN_RE: RegExp =
/(?:^|\s)--?(?:no-run|collect-only|co|watch(?:All|-all)?(?!=false)|listTests|list-tests|list|dry-run|version|V|help|h|init|showConfig|fixtures)\b|\btest:watch\b|(?:^|\s)-count=0\b|\bgo\s+build\s+-n\b/;

/** `>/dev/null` in any of its `>`/`2>`/`&>` forms — discarding a test
* runner's summary or a static checker's diagnostics destroys the only
* proof a receipt can offer, on either stream, regardless of kind. */
const DEV_NULL_RE = />\s*\/dev\/null/;

/** A single pipe (`|`, never `||`) — invalidates a STATIC-kind runner's
* "silence proves success" evidence (a pipe stage can reorder, truncate, or
* recount diagnostics before they reach the receipt). Harmless for a
* TEST-kind runner, whose counts come from parsing the piped-through text. */
const PIPE_RE = /(?<!\|)\|(?!\|)/;

/**
* Any output-redirection operator that is NOT a harmless fd-duplication
* (`2>&1`, `1>&2`, …): a bare `>`/`>>`, an fd-qualified `2>`/`2>>`, or the
* combined `&>`/`&>>` form. A STATIC-kind runner's only proof is silence —
* redirecting ANY stream after it, to ANY target (not just `/dev/null`),
* destroys that proof exactly like a pipe does. Subsumes the old `>&-`
* (fd-close) check: `(?!&\d)` only excludes a DIGIT target, so `>&-` still
* matches this.
*/
const REDIRECT_RE = /\d*>{1,2}(?!&\d)|&>{1,2}/;

/**
* A STDOUT-directed redirect only (`>`/`>>` with no leading fd number, or the
* combined `&>`/`&>>` form) — a TEST-kind runner's counts live in whatever
* the harness captured as its output; redirecting that away destroys the
* evidence exactly like a `/dev/null` redirect does. A bare `2>` (stderr-only,
* non-`/dev/null`) is deliberately left alone: {@link DEV_NULL_RE} already
* covers the one case (any stream to `/dev/null`) that matters regardless of
* which fd a given runner actually writes its summary to, and `2>&1` (fd
* duplication, no stream lost) must stay allowed.
*/
const TEST_REDIRECT_RE = /(?<![0-9])>{1,2}(?!&\d)|&>{1,2}/;

/** Shell LIST separators this rule splits on: `;`, `&&`, `||`, a lone
* job-control `&` (never part of `&&`, nor a `>&`/`&>` redirect form like
* `2>&1`/`>&-`/`&>file` — the lookaround excludes both), or a newline. A
* bare pipe `|` is deliberately absent: it still delivers the runner's real
* output into the next stage, so it never starts a new list segment here. */
const LIST_SEP_RE = /&&|\|\||;|(?<!>)&(?!>)|\n/g;

/** The LAST shell list segment of `unquoted` (see module doc): the only
* segment a recognised runner is allowed to be found in. */
export function lastListSegment(unquoted: string): string {
const parts = unquoted.split(LIST_SEP_RE);
return (parts[parts.length - 1] ?? unquoted).trim();
}

/** Strips ANSI SGR escapes (`\x1b[...m`) and `\r` from a captured output
* once, before any pass/fail parser runs — a colorized `bun test` summary
* (`\x1b[32m 12 pass\x1b[0m`) must parse identically to a plain one. */
export function stripAnsi(output: string): string {
return output.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\r/g, "");
}

/** Whether output suppression makes a matched runner's evidence untrustworthy,
* scoped to `tail` — the text of its OWN list segment AFTER the runner
* match (never earlier text, which belongs to a prior command). */
function isSuppressed(tail: string, kind: "tsc" | "test"): boolean {
if (DEV_NULL_RE.test(tail)) return true;
return kind === "tsc" ? PIPE_RE.test(tail) || REDIRECT_RE.test(tail) : TEST_REDIRECT_RE.test(tail);
}

/**
* Resolve `unquoted` to a recognised {@link RunnerSpec}, or `null` when it
* matches none — including a {@link NO_RUN_RE} zero-test/informational
* invocation, a runner that only appears in an EARLIER list segment (module
* doc structural rule), or a runner whose own segment suppresses its output
* after the match ({@link isSuppressed}). First matching `RUNNERS` entry
* (table order) within the last segment wins.
* @param unquoted - `unquotedShellText(command)` — never the raw command.
*/
export function matchRunner(unquoted: string): RunnerSpec | null {
if (NO_RUN_RE.test(unquoted)) return null;
const segment = lastListSegment(unquoted);
for (const runner of RUNNERS) {
const m = runner.cmd.exec(segment);
if (!m) continue;
const tail = segment.slice(m.index + m[0].length);
return isSuppressed(tail, runner.kind) ? null : runner;
}
return null;
}
Loading