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.109] - 2026-10-14

### Fixed

- **motion: key-word heuristic no longer blocks unrelated commands** (`src/policy/motion/guard-key.ts`) — the Motion signing key guard fired in every session with fuse-motion enabled on any command containing the word "key" next to `$ * ? [` (python lambdas, SQL columns, ssh scripts). That branch protected nothing a simple glob variant did not already bypass and is removed; the glob/variable + `.fuse-har` heuristic and every path-based protection of the signing key directory are unchanged. Tests: `motion-key-word-rule`.

## [0.1.108] - 2026-10-12

### Changed
Expand Down
3 changes: 3 additions & 0 deletions MEMORY/LESSON.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,6 @@
- [2026-09-05 13:14] Post-patch line count by hunk arithmetic (`Σ new−old`) exact on 4000 random cases, off by one at edge (`countLines` swallows FILE's final `\n`, not hunk's) → predict file size after patch: APPLY patch to real text, count once with same function as disk count; never sum segment deltas. [TRIGGERS path:src/policy/edit-outcome.ts,src/adapters/codex/apply-patch.ts keyword:countLines,delta,hunk,post-patch]

- [2026-10-04 22:48] Child-track merge (Cursor): `recordDoc` cross-credited `target`, `max(parentAt, childAt)` inherited ANOTHER session's stamp, stale read became fresh → to merge proof credit only present framework via `creditDocConsultation`; take max only if target session itself earned stamp (`sessions` AND `doc_sessions`). [TRIGGERS path:src/freshness/child-evidence-merge.ts keyword:merge,recordDoc,target,doc_consulted]

- [2026-10-14 00:00] (×4) Motion key guard false-positive fix: rewrite + 3 "proven-inert" exemption rounds each REFUTED by challenger (in-word quotes, GLOBIGNORE + `~+/..`, failed `cd nope;` cwd drift, OLDPWD var, deep glob through symlink, `rg -L`) → guard FP fix = new = old AND NOT exempt by construction (old-ALLOW→new-BLOCK = 0 automatic); exemption = allowlist of exact owner FP shapes, never general shell-path modelling; every flip shell-executed bash+zsh vs planted secret before verdict; challenger after EACH round, never trust executor "reach=0". [TRIGGERS path:src/policy/motion/guard-key.ts,src/policy/motion/key-exempt.ts keyword:guard,exempt,false positive,faux positif,glob]
- [2026-10-14 10:51] 6 rounds building exemption allowlist for blunt motion guard word-heuristic (word + glob/var anywhere in cmd); real usage (cd prefix, python lambda, SQL column) still blocked 16/16; owner furious: plugin-scoped guard firing in unrelated sessions → first ask "does heuristic protect anything real?" (trivial variant like k*s glob already passed) → if not, remove heuristic, keep only path-based blocks; never pile exemptions on worthless rule.
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.108",
"version": "0.1.109",
"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
2 changes: 1 addition & 1 deletion src/policy/motion/guard-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function motionKeyViolation(event: NormalizedEvent, cwd: string, home: st
if (!name) return false;
if (name.includes(MOTION_KEY_FRAGMENT) || /\.fuse-harness\/motion[*?\[]/.test(name)) return true;
if (/\.fuse-harness\/(?:[*?\[]|$)/.test(name)) return true;
if (/[$*?[]/.test(name) && /(?:\.fuse-har|\bkeys?\b)/.test(name)) return true;
if (/[$*?[]/.test(name) && /\.fuse-har/.test(name)) return true;
const path = canonicalFilePath(homePath(name, cwd, home));
return isUnder(path, directory) || ((/grep|glob|search/i.test(event.tool) || MOTION_WRITE_TOOLS.has(event.tool)) && isUnder(directory, path));
});
Expand Down
53 changes: 53 additions & 0 deletions test/motion-key-word-rule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { normalizeEvent } from "../src/runtime/normalize";
import { motionKeyViolation } from "../src/policy/motion/guard-key";
import { motionKeyDirectory } from "../src/policy/motion/store";

const home = realpathSync(mkdtempSync(join(tmpdir(), "motion-keyword-")));
const keyDir = motionKeyDirectory(home);
mkdirSync(keyDir, { recursive: true });
const cwd = join(home, "work");
mkdirSync(cwd, { recursive: true });

/** Run the key guard for one tool call and return the verdict kind (null = allowed). */
const verdict = (tool: string, input: Record<string, unknown>): string | null =>
motionKeyViolation(normalizeEvent("claude-code", { tool_name: tool, tool_input: input }), cwd, home)?.kind ?? null;
const bash = (command: string): string | null => verdict("Bash", { command });

test("ALLOW 1 ssh + python with lambda key= sort and slicing", () => {
expect(bash(`ssh -o BatchMode=yes root@10.10.11.9 "python3 -c \\"import sqlite3,re; R=[r for r in rows]; print(sorted(kinds.items(),key=lambda x:-x[1])[:12])\\""`)).toBeNull();
});
test("ALLOW 2 python sort(key=lambda) with slicing", () => {
expect(bash(`cd /tmp/x && python3 -c "out.sort(key=lambda r: r[0]); print(out[:5])"`)).toBeNull();
});
test("ALLOW 3 sqlite3 query selecting a key column", () => {
expect(bash(`cd /tmp/x && sqlite3 -readonly g/state.vscdb "select count(*), substr(key,1,12) from cursorDiskKV where key like 'bubbleId:%'"`)).toBeNull();
});
test("ALLOW 4 python json keys() slicing", () => {
expect(bash(`python3 -c "import json; d=json.load(open('a.json')); print(sorted(d.keys())[:60])"`)).toBeNull();
});

test("BLOCK 5 literal key file paths", () => {
expect(bash("cat ~/.fuse-harness/motion-keys/x.key")).toBe("block");
expect(bash(`cat ${join(keyDir, "x.key")}`)).toBe("block");
});
test("BLOCK 6 glob and variable paths to the harness directory", () => {
for (const command of ["cat ~/.fuse-har*/motion*/*", "cat $HOME/.fuse-harness/*", "cat ~/.fuse-harness/m*/*", "ls ~/.fuse-harness/"]) {
expect(bash(command)).toBe("block");
}
});
test("BLOCK 7 variable indirection into the harness directory", () => {
expect(bash("D=$HOME/.fuse-harness; cat $D/m*/*")).toBe("block");
});
test("BLOCK 8 Read, Grep and Glob tools", () => {
expect(verdict("Read", { file_path: join(keyDir, "x.key") })).toBe("block");
expect(verdict("Grep", { path: keyDir, pattern: "." })).toBe("block");
expect(verdict("Glob", { pattern: join(home, ".fuse-harness/*") })).toBe("block");
});
test("BLOCK 9 recursive grep and rg over the harness directory", () => {
expect(bash("grep -r foo ~/.fuse-harness")).toBe("block");
expect(bash("rg foo ~/.fuse-harness/motion-keys")).toBe("block");
});
Loading