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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https://

## [Unreleased]

## [0.1.107] - 2026-10-12

### Added

- **apex: the deny lists every missing prerequisite at once** (`src/policy/apex-missing.ts`, `src/runtime/gate-apex.ts`) — instead of stopping at the first failing gate, the deny now lists all missing prerequisites; the PRE-block lists only ref-free gates and never loads refs. Same block/allow verdict, same title; a single failure is byte-identical to before. Tests: `apex-deny-all-missing`.
- **solid: detect react, tanstack-start, astro, java, csharp, ruby and generic projects; the limit follows `FUSE_SOLID_MAX_LINES`** (`src/runtime/lifecycle/solid-detect.ts`, `solid-detect-extra.ts`) — only projects previously typed "unknown" get a type. `SOLID_FILE_LIMIT` = `FUSE_SOLID_MAX_LINES` when set, else the profile default; output with the variable unset is unchanged. Tests: `solid-detect-stacks`.

### Fixed

- **guards: the perl timeout idiom and pure reads no longer hit "perl in-place edit"** (`src/policy/guards/bash-write.ts`, `bash-write-patterns.ts`, `bash-write-inline.ts`, `bash-write-perl.ts`, `bash-write-perl-words.ts`) — verdict identical to 0.1.106 except two exemptions: pure-read programs whose `-p`/`-i` tokens are file names or program text, and `perl -e 'alarm N; exec @ARGV'` with a bare claude/codex/kimi/cursor/cursor-agent child (the existing exec CONFIRM still applies). 1387-command differential with real perl under 8 shells: 0 in-place edit loosened, 0 stricter. Tests: `bash-write-perl-inplace`, `bash-write-perl-narrow`.

### Known limits

- The perl rule keeps 0.1.106's existing gaps (e.g. `/usr/bin/perl -pi`, `bash -c "perl -pi …"`), deliberately unchanged.
- `test/multi-rdv-sim` scenario 01 can fail when `TMPDIR` is set (pre-existing, same on 0.1.106).

## [0.1.106] - 2026-10-11

### Fixed
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.106",
"version": "0.1.107",
"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
69 changes: 69 additions & 0 deletions src/policy/apex-missing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { apexAuthorizationGate } from "./apex-authorization";
import { brainstormGate, docConsultedGate, freshnessGate, POST_AUTH_GATES, PRE_AUTH_GATES, type ApexContext, type ApexGate } from "./apex";
import type { Prompt } from "../prompt/types";

/** The only gates known NOT to read the lazily loaded refs; any other gate (Check 1, SOLID refs, a future one) is treated as ref-dependent, so it is skipped when the refs are not loaded. */
const REF_FREE: ReadonlySet<ApexGate> = new Set([brainstormGate, freshnessGate, docConsultedGate]);

/** Full gate chain in evaluation order, derived from the exported sub-chains (Check 1 sits between PRE and POST) so a new gate cannot be silently omitted. */
const CHAIN: ReadonlyArray<{ gate: ApexGate; needsRefs: boolean }> = [...PRE_AUTH_GATES, apexAuthorizationGate, ...POST_AUTH_GATES].map((gate) => ({ gate, needsRefs: !REF_FREE.has(gate) }));

/** Compare two prompts by value (gates are deterministic, so the deciding gate re-yields the same prompt). */
const same = (a: Prompt, b: Prompt): boolean => JSON.stringify(a) === JSON.stringify(b);

/**
* Every APEX gate that currently fails EXCEPT the deciding one (`first`), in chain
* order (brainstorm, freshness, Check 1, Check 2, SOLID refs). Pure: it only calls
* the gates, never writes state. A gate that throws is omitted, never propagated.
* @param ctx - The same context the deciding gate saw (`refs` loaded when `refsKnown`).
* @param first - The prompt that already blocks.
* @param refsKnown - False when the SOLID refs could not be loaded: the two ref-dependent gates are then skipped.
* @returns The other failing gates' prompts.
*/
export function otherMissing(ctx: ApexContext, first: Prompt, refsKnown: boolean): Prompt[] {
const hits: Prompt[] = [];
for (const { gate, needsRefs } of CHAIN) {
if (needsRefs && !refsKnown) continue;
try {
const hit = gate(ctx);
if (hit && !same(hit, first)) hits.push(hit);
} catch {
// Listing is best-effort: an unevaluable gate is simply not listed.
}
}
return hits;
}

/**
* Fold the other missing prerequisites into the deciding prompt. The deciding
* gate's kind/title/ruleId/reason stay as-is; the reason gains one section naming
* each other gate (title + its actions) and `actions` becomes the first gate's
* actions then the others', deduplicated, order preserved. No others → `first`
* itself (byte-identical).
* @param first - The prompt of the first failing gate.
* @param others - The prompts of the other failing gates.
* @returns The merged prompt.
*/
export function mergeMissing(first: Prompt, others: Prompt[]): Prompt {
if (others.length === 0) return first;
const section = others.map((o) => [`- ${o.title}`, ...(o.actions ?? []).map((a) => ` ${a}`)].join("\n"));
const reason = [first.reason, "", `Also missing (${others.length} more):`, ...section].join("\n");
const actions = [...new Set([...(first.actions ?? []), ...others.flatMap((o) => o.actions ?? [])])];
return { ...first, reason, actions: actions.length ? actions : first.actions };
}

/**
* Block-path helper: `first` plus every other failing APEX prerequisite. Never
* throws and never writes state — on any failure it returns `first` unchanged.
* @param first - The prompt of the first failing gate.
* @param ctx - The context the gates ran on.
* @param refsKnown - False when the SOLID refs could not be loaded (see {@link otherMissing}).
* @returns The merged prompt, or `first` unchanged when nothing else fails or listing errors.
*/
export function listAllMissing(first: Prompt, ctx: ApexContext, refsKnown: boolean): Prompt {
try {
return mergeMissing(first, otherMissing(ctx, first, refsKnown));
} catch {
return first;
}
}
4 changes: 2 additions & 2 deletions src/policy/guards/bash-write-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ export const INLINE_JS_STDIN_ANCHOR: RegExp = new RegExp(
* PLUS modern features (`say`, `state`, etc.) and clusters the same way
* (`-nE`, `-plE`), so a bare-case check let `perl -E '...'` slip past
* unanchored. Fires on script content this static check CAN inspect. The
* existing unconditional `perl -[pi]i?` CODE_MUTATORS entry (in-place edit)
* is untouched and stays a separate, always-block motif.
* CODE_MUTATORS "perl in-place edit" entry (perlCarriesInPlace,
* bash-write-perl.ts) stays a separate motif, checked before this one.
*/
export const PERL_E_ANCHOR: RegExp = new RegExp(`${CMD}perl\\s+-[a-zA-Z]*[eE]\\b`);

Expand Down
9 changes: 7 additions & 2 deletions src/policy/guards/bash-write-patterns.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CMD } from "./bash-command-anchor";
import { perlCarriesInPlace } from "./bash-write-perl";

export {
RUBY_WRITES, JS_RUNTIME_WRITES, INLINE_JS_ANCHOR, INLINE_JS_STDIN_ANCHOR, PERL_E_ANCHOR, PERL_WRITES,
Expand Down Expand Up @@ -41,10 +42,14 @@ export const CODE_FILE_LITERAL: RegExp = new RegExp(`\\.(?:${CODE_EXT})\\b`, "i"
* {@link CMD}-anchored (start/separator + optional wrapper chain); only the
* structural heredoc-into-file motif is position-free.
*/
export const CODE_MUTATORS: readonly { re: RegExp; desc: string }[] = [
export const CODE_MUTATORS: readonly (
| { re: RegExp; desc: string; matches?: undefined }
| { matches: (cmd: string) => boolean; desc: string; re?: undefined }
)[] = [
{ re: new RegExp(`${CMD}python3?\\s+-\\s*<<`), desc: "Python heredoc input" },
{ re: new RegExp(`${CMD}sed\\b[^|]*\\s-i`), desc: "sed in-place edit" },
{ re: new RegExp(`${CMD}perl\\b[^|]*\\s-[pi]i?\\b`), desc: "perl in-place edit" },
// ae977a9's verdict, except for the narrow spec in bash-write-perl.ts (which owns the legacy regex).
{ matches: perlCarriesInPlace, desc: "perl in-place edit" },
{ re: new RegExp(`${CMD}awk\\b[^|]*-i\\s*inplace`), desc: "awk in-place edit" },
// `patch` as a command token (start/separator + optional wrapper chain), then a
// metachar/space/EOL. Never the bare word merely NAMING a path in a read-only
Expand Down
87 changes: 87 additions & 0 deletions src/policy/guards/bash-write-perl-words.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Shell word reader for the arguments of ONE `perl` invocation (consumed by
* bash-write-perl.ts). It does NOT decode shell syntax: decoding bash, zsh and
* friends faithfully is a losing game (`$'\U2d'`, `$'\cI'`, `$'\e'`, `\M-`,
* unicode dashes …). Instead "certain" is a strict WHITELIST, and everything
* outside it makes the whole invocation uncertain — the caller then returns
* ae977a9's legacy verdict.
*
* A word is certain only if it is made solely of:
* - unquoted characters in {@link SAFE} (`A-Za-z0-9_./:=,+@%-`),
* - plain single-quoted segments free of `\`, newline and `!` (fish reads `\'`
* and `\\` inside single quotes as escapes; csh/tcsh expand `!` and reject
* newlines in quotes),
* - double-quoted segments free of `\`, `$`, backtick, `!` and newline.
* Anything else (`$'…'`, `$"…"`, any backslash anywhere, `$`,
* backtick, `{ } * ? [ ] ~ # ! ^`, redirects, non-ASCII, control characters,
* CR/FF/VT, an unbalanced quote …) is uncertain.
*/

/** One word. */
export interface PerlWord {
/** Value of the certain part read so far (quotes removed). */
readonly text: string;
}

/** Result of reading one token. */
export interface PerlToken {
/** The word read (possibly only its certain prefix), or null at a boundary. */
readonly word: PerlWord | null;
/** True when the invocation's argument list ends here. */
readonly end: boolean;
/** Position to continue reading from. */
readonly next: number;
/** True when something outside the whitelist was met. */
readonly uncertain: boolean;
}

/** Unquoted characters a certain word may contain. */
const SAFE = /[A-Za-z0-9_./:=,+@%-]/;
/** Command boundaries that end an invocation with certainty. */
const SEPARATORS = "\n;&|)";
const REDIRECT_START = /(?:\d*|&)[<>]/y;

/**
* Reads the next token of a perl invocation's argument text.
* @param s - Full command string.
* @param from - Index to start reading at.
* @returns The next word, or an end marker (uncertain when the end is a
* redirect, `(` or a backtick rather than a plain separator/EOF).
*/
export function readPerlToken(s: string, from: number): PerlToken {
let start = from;
while (s[start] === " " || s[start] === "\t") start++;
const c = s[start];
if (c === undefined) return { word: null, end: true, next: start, uncertain: false };
REDIRECT_START.lastIndex = start;
if (REDIRECT_START.test(s)) return { word: null, end: true, next: start, uncertain: true };
if (SEPARATORS.includes(c)) return { word: null, end: true, next: start, uncertain: false };
return readWord(s, start);
}

/** Reads one word from its first char; stops at the first non-whitelisted construct. */
function readWord(s: string, start: number): PerlToken {
let i = start;
let text = "";
const stop = (): PerlToken => ({ word: { text }, end: true, next: s.length, uncertain: true });
while (i < s.length) {
const c = s[i] as string;
if (c === " " || c === "\t" || SEPARATORS.includes(c)) break;
if (SAFE.test(c)) { text += c; i++; continue; }
if (c === "'") {
const close = s.indexOf("'", i + 1);
if (close < 0 || /[\\\n!]/.test(s.slice(i + 1, close))) return stop();
text += s.slice(i + 1, close);
i = close + 1;
} else if (c === '"') {
const close = s.indexOf('"', i + 1);
const body = close < 0 ? "" : s.slice(i + 1, close);
if (close < 0 || /[\\$`!\n]/.test(body)) return stop();
text += body;
i = close + 1;
} else {
return stop();
}
}
return { word: { text }, end: false, next: i, uncertain: false };
}
106 changes: 106 additions & 0 deletions src/policy/guards/bash-write-perl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { CMD } from "./bash-command-anchor";
import { readPerlToken } from "./bash-write-perl-words";

/**
* "perl in-place edit" rule for CODE_MUTATORS. The verdict is ae977a9's
* ({@link LEGACY}, byte for byte) EXCEPT in exactly two cases, where the rule
* does not fire:
*
* (A) a pure-read program: one simple command (bash-write-perl-words.ts
* whitelist, no redirect/separator/expansion), first word exactly `perl`,
* switches only from `n l a F 0 w e E`, at least one `-e`/`-E` program,
* never `--` or a lone `-`, and NO program text containing `$^I`, `do`,
* `require`, `eval`, `use`, `exec`, `system`, `qx`, `readpipe`, `open`,
* `pipe`, `sysopen`, `rename`, `unlink`, `fork`, `syscall`, `IPC`,
* `INPLACE`, `print {`, a backtick or any `>`;
* (B) the owner's macOS timeout idiom: same command shape, the only program is
* exactly `[alarm N;] exec @ARGV[;]` and the child's first word is a bare
* agent CLI (claude, codex, kimi, cursor, cursor-agent).
*
* The other guards (the inline-perl `exec` ask) are untouched. Anything else —
* `-p`, `-i`, `-M`, `-x`, `-S`, `-d`, a path, another child — is LEGACY.
*/

/** ae977a9's rule, verbatim: the single source of the legacy verdict. */
const LEGACY: RegExp = new RegExp(`${CMD}perl\\b[^|]*\\s-[pi]i?\\b`);
const FIRST_WORD_PERL = /^[ \t]*perl[ \t]/;
const FORBIDDEN =
/\^I|\b(?:do|require|eval|use|exec|system|qx|readpipe|open|pipe|sysopen|rename|unlink|fork|syscall)\b|IPC|INPLACE|print\s*\{|`|>/;
const TIMEOUT_IDIOM = /^\s*(?:alarm\s+\d+\s*;\s*)?exec\s+@ARGV\s*;?\s*$/;
const AGENT_CLIS = new Set(["claude", "codex", "kimi", "cursor", "cursor-agent"]);

interface Cluster { ok: boolean; takesNext: boolean; isEval: boolean; attached: string }

/** Reads a switch cluster made only of `n l a F 0 w e E`; anything else is not ok. */
function readCluster(cluster: string): Cluster {
const res = (c: Partial<Cluster>): Cluster => ({ ok: true, takesNext: false, isEval: false, attached: "", ...c });
let k = 0;
while (k < cluster.length) {
const ch = cluster[k] as string;
const rest = cluster.slice(k + 1);
if (ch === "e" || ch === "E") return res({ isEval: true, takesNext: rest === "", attached: rest });
if (ch === "F") return res({});
k++;
if (ch === "0" && cluster[k] === "x") { k++; while (/[0-9a-fA-F]/.test(cluster[k] ?? "")) k++; }
else if (ch === "0" || ch === "l") while (/[0-7]/.test(cluster[k] ?? "")) k++;
else if (ch !== "n" && ch !== "a" && ch !== "w") return res({ ok: false });
}
return res({});
}

/** Index of the first non-blank char at or after `from`. */
function skip(s: string, from: number): number {
let i = from;
while (s[i] === " " || s[i] === "\t") i++;
return i;
}

/** True when every token from `from` to EOF is whitelist-certain and no word starts with `=`. */
function certainToEnd(s: string, from: number): boolean {
let pos = from;
for (;;) {
const start = skip(s, pos);
const tok = readPerlToken(s, pos);
if (tok.uncertain || (tok.word && s[start] === "=")) return false;
if (tok.end) return s[tok.next] === undefined;
pos = tok.next;
}
}

/** True for case (A) or (B): the in-place rule must not fire. */
function exempt(cmd: string): boolean {
const head = FIRST_WORD_PERL.exec(cmd);
if (!head || !certainToEnd(cmd, head[0].length - 1)) return false;
let pos = head[0].length - 1;
const programs: string[] = [];
for (;;) {
const start = skip(cmd, pos);
const tok = readPerlToken(cmd, pos);
const text = tok.word?.text ?? "";
pos = tok.next;
if (tok.end || !tok.word || !text.startsWith("-")) {
if (programs.length === 0) return false;
if (!programs.some((p) => FORBIDDEN.test(p))) return true;
const child = readPerlToken(cmd, start).word?.text ?? "";
return programs.length === 1 && TIMEOUT_IDIOM.test(programs[0] as string) && AGENT_CLIS.has(child);
}
if (text === "--" || text === "-") return false;
const c = readCluster(text.slice(1));
if (!c.ok) return false;
if (c.isEval) programs.push(c.attached);
if (!c.takesNext) continue;
const arg = readPerlToken(cmd, pos);
pos = arg.next;
if (!arg.word) return false;
programs[programs.length - 1] = arg.word.text;
}
}

/**
* True when the "perl in-place edit" rule fires: ae977a9's verdict, except for
* the two exempt cases above.
* @param cmd - Raw Bash command string.
*/
export function perlCarriesInPlace(cmd: string): boolean {
return LEGACY.test(cmd) && !exempt(cmd);
}
2 changes: 1 addition & 1 deletion src/policy/guards/bash-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function bashWriteGuard(ctx: GuardContext): Prompt | null {
const unquoted = unquotedShellText(cmd);
const redirects = shellOutputRedirects(cmd).filter((redirect) => redirect.target !== "/dev/null");

const mutator = CODE_MUTATORS.find((m) => m.re.test(cmd));
const mutator = CODE_MUTATORS.find((m) => (m.matches ? m.matches(cmd) : m.re.test(cmd)));
if (mutator) return blockCodeWrite(`${mutator.desc} — Use Edit/Write tools instead`);
if (CODE_COMMAND_WRITE.test(cmd)) return blockCodeWrite("tee/dd into a code file — Use Edit/Write tools instead");
if (PYTHON_C_ANCHOR.test(cmd) && PYTHON_WRITES.test(cmd)) {
Expand Down
9 changes: 6 additions & 3 deletions src/runtime/gate-apex.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { evaluateApex, PRE_AUTH_GATES, POST_AUTH_GATES, type ApexContext } from "../policy/apex";
import { apexAuthorizationGate } from "../policy/apex-authorization";
import { listAllMissing } from "../policy/apex-missing";
import { isTrivialEdit, protectedPathGate, SKIP_DIRS } from "../policy/trivial-edits";
import { FAIL_CLOSED } from "../policy/guards";
import { agentsFresh, recordTarget, recordTrivialEdit, trivialCount, type SessionTrack } from "../tracking/session-state";
Expand Down Expand Up @@ -112,7 +113,8 @@ export async function apexScopedGate(input: GateInput, track: SessionTrack, wind
try {
// (4) Brainstorm + explore/research freshness (pre-existing harness gates; neither reads refs).
const pre = evaluateApex(ctx, PRE_AUTH_GATES);
if (pre) return pre;
// The refs stay unloaded on a PRE block (lazy contract, src/refs/lazy.ts): only the ref-free gates are listed alongside it. Nothing is written.
if (pre) return listAllMissing(pre, ctx, false);
} catch {
return FAIL_CLOSED;
}
Expand All @@ -124,10 +126,11 @@ export async function apexScopedGate(input: GateInput, track: SessionTrack, wind
if (authDeny) {
const set_at = new Date(input.now).toISOString();
await withTrack(input.trackFile, (fresh) => recordTarget(fresh, { project: input.cwd ?? "", framework: input.framework, set_by: "apex-authorization", set_at }));
return authDeny;
return listAllMissing(authDeny, ctx, true);
}
// (6) Check 2 (doc consulted once per session) + SOLID refs.
return evaluateApex(ctx, POST_AUTH_GATES);
const post = evaluateApex(ctx, POST_AUTH_GATES);
return post && listAllMissing(post, ctx, true);
} catch {
return FAIL_CLOSED;
}
Expand Down
Loading
Loading