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.113] - 2026-10-30

### Fixed

- **motion: harden the video-motion store migration** (`src/policy/motion/migrate.ts`, `store.ts`, `trust.ts`, `auth-key.ts`, `auth-approval.ts`, `guard-key.ts`, `sensitive.ts`, `src/runtime/motion/index.ts`, `store-failure.ts`) — the `keys/` directory gets a symlink and owner trust check before and after migration and on every key read/create; a migration failure now blocks only Motion-scoped calls and reports the real cause instead of breaking unrelated tools; when both the legacy `~/.fuse-harness/motion` and the new `~/.fuse-harness/video-motion` exist, the new layout wins and the legacy one stays protected; `banned.txt` is the union of both layouts; protected-pre detection only applies to `pre` events. Tests: `motion-migration-hardening`, `motion-migration-event-shapes`.

## [0.1.112] - 2026-10-30

### 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.112",
"version": "0.1.113",
"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
7 changes: 6 additions & 1 deletion src/policy/motion/auth-approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ApprovableStage, MotionApproval } from "../interfaces/motion";
import { canonicalFilePath, canonicalRoot } from "../../runtime/prd/prd-canon";
import { rootKey } from "./hash";
import { grantingMotionKey, readMotionKey } from "./auth-key";
import { MotionStoreError } from "./trust";
import { recordMotionGrant, registeredMotionGrant } from "./state-witness";

const HEX = /^[a-f0-9]{64}$/;
Expand Down Expand Up @@ -37,5 +38,9 @@ export function validMotionApproval(root: string, a: MotionApproval | null | und
if (!key) return false;
const expected = createHmac("sha256", key).update(canonical(a)).digest();
return timingSafeEqual(Buffer.from(a.signature, "hex"), expected);
} catch { return false; }
} catch (error) {
// An untrusted key/store layout is not "no approval": surface the real cause (Motion-scoped fail-closed).
if (error instanceof MotionStoreError) throw error;
return false;
}
}
5 changes: 5 additions & 0 deletions src/policy/motion/auth-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
import { randomBytes, randomUUID } from "node:crypto";
import { closeSync, constants, fchmodSync, fsyncSync, fstatSync, linkSync, mkdirSync, openSync, readSync, unlinkSync, writeSync } from "node:fs";
import { motionKeyDirectory, motionKeyPath } from "./store";
import { assertTrustedKeyDir } from "./trust";

/** Read a private regular 32-byte key; missing, linked, oversized or permissive keys fail closed. */
export function readMotionKey(root: string, home: string): Buffer | null {
let fd: number | undefined;
assertTrustedKeyDir(motionKeyDirectory(home));
try {
fd = openSync(motionKeyPath(root, home), constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
const stat = fstatSync(fd);
if (!stat.isFile() || stat.size !== 32 || (stat.mode & 0o777) !== 0o600) return null;
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return null;
const key = Buffer.alloc(32);
return readSync(fd, key, 0, 32, 0) === 32 ? key : null;
} catch { return null; }
Expand All @@ -20,10 +23,12 @@ export function readMotionKey(root: string, home: string): Buffer | null {
export function grantingMotionKey(root: string, home: string): Buffer | null {
let fd: number | undefined;
const target = motionKeyPath(root, home), temporary = `${target}.${randomUUID()}.tmp`;
assertTrustedKeyDir(motionKeyDirectory(home));
try {
const existing = readMotionKey(root, home);
if (existing) return existing;
mkdirSync(motionKeyDirectory(home), { recursive: true, mode: 0o700 });
assertTrustedKeyDir(motionKeyDirectory(home));
fd = openSync(temporary, "wx", 0o600);
fchmodSync(fd, 0o600);
const key = randomBytes(32);
Expand Down
5 changes: 3 additions & 2 deletions src/policy/motion/guard-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { homedir } from "node:os";
import type { Prompt } from "../../prompt/types";
import type { NormalizedEvent } from "../../runtime/normalize";
import { canonicalFilePath } from "../../runtime/prd/prd-canon";
import { motionKeyDirectory } from "./store";
import { plainKeyDirectory } from "./trust";
import { namesMotionKeys } from "./guard-key-patterns";
import { isUnder } from "./paths";
import { MOTION_WRITE_TOOLS } from "./constants";
Expand All @@ -12,7 +12,8 @@ import { commandContexts, homePath } from "./command-context";

/** Deny direct, canonical alias, glob and variable references to private signing keys. */
export function motionKeyViolation(event: NormalizedEvent, cwd: string, home: string = homedir()): Prompt | null {
const directory = canonicalFilePath(motionKeyDirectory(home));
// Guards never migrate or validate the store: a broken layout must not turn into a global block.
const directory = canonicalFilePath(plainKeyDirectory(home));
const names = [event.filePath, ...(event.files ?? []).map((f) => f.filePath)];
for (const field of ["path", "file_path", "directory", "pattern", "glob", "command", "patch"]) {
const value = event.input[field];
Expand Down
39 changes: 28 additions & 11 deletions src/policy/motion/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync } from "node:fs";
import { join } from "node:path";
import { fuseHarnessHome } from "../../runtime/home-state";
import { MotionStoreError, assertTrustedHarnessRoot, assertTrustedKeyDir } from "./trust";

/** Homes already handled by this process (the move is idempotent, this only saves syscalls). */
const done = new Set<string>();
Expand Down Expand Up @@ -63,12 +64,22 @@ export function migrateMotionData(home: string): void {
const oldStore = join(base, "motion");
const oldKeys = join(base, "motion-keys");
const root = join(base, "video-motion");
if (stat(oldStore) || stat(oldKeys)) {
moveDirectory(oldStore, root);
if (stat(oldKeys) && ensurePrivateDir(root)) moveDirectory(oldKeys, join(root, "keys"));
const keys = join(root, "keys");
try {
assertTrustedHarnessRoot(base, home);
assertTrustedKeyDir(keys, false);
if (stat(oldStore) || stat(oldKeys)) {
moveDirectory(oldStore, root);
if (stat(oldKeys) && ensurePrivateDir(root)) moveDirectory(oldKeys, keys);
}
assertTrustedRoot(root);
assertTrustedKeyDir(keys, false);
} catch (error) {
if (error instanceof MotionStoreError) throw error;
const code = (error as NodeJS.ErrnoException).code;
throw new MotionStoreError(`Motion data migration failed (${error instanceof Error ? error.message : String(error)}).`, base, false, code);
}
assertTrustedRoot(root);
assertNoKeyConflict(oldKeys, join(root, "keys"));
assertNoKeyConflict(oldKeys, keys);
done.add(home);
}

Expand All @@ -79,19 +90,25 @@ function assertTrustedRoot(root: string): void {
const foreign = typeof process.getuid === "function" && info.uid !== process.getuid();
// A regular file at the root cannot host data; the store readers already treat it as "no approval".
if (info.isSymbolicLink() || foreign || (info.isDirectory() && (Number(info.mode) & 0o022) !== 0)) {
throw new Error(`Motion root ${root} is a symlink, not owned by this user or writable by others; refusing to use it`);
throw new MotionStoreError(`Motion root ${root} is a symlink, not owned by this user or writable by others; refusing to use it.`, root);
}
}

/** Fail closed when a leftover old key and a new key share a name but differ: one of them may be planted. */
/** A same-name key differs between the old and the new key directory (downgrade/re-upgrade leftover); the new one stays authoritative. */
export class MotionKeyConflictError extends Error {
constructor(readonly keyName: string, readonly oldKeys: string) {
super(`Motion key ${keyName} exists with different content in the old and the new key directory; the new one is authoritative`);
}
}

/** Throw {@link MotionKeyConflictError} when a leftover old key and a new key share a name but differ. */
function assertNoKeyConflict(oldKeys: string, newKeys: string): void {
const old = stat(oldKeys);
if (!old || !old.isDirectory() || old.isSymbolicLink() || !stat(newKeys)) return;
for (const name of readdirSync(oldKeys)) {
if (!name.endsWith(".key") || !stat(join(newKeys, name))) continue;
if (!readFileSync(join(oldKeys, name)).equals(readFileSync(join(newKeys, name)))) {
throw new Error(`Motion key ${name} exists with different content in the old and the new key directory; the owner must resolve it`);
}
const a = stat(join(oldKeys, name)), b = stat(join(newKeys, name));
if (!name.endsWith(".key") || !a || !b || !a.isFile() || !b.isFile()) continue;
if (!readFileSync(join(oldKeys, name)).equals(readFileSync(join(newKeys, name)))) throw new MotionKeyConflictError(name, oldKeys);
}
}

Expand Down
11 changes: 10 additions & 1 deletion src/policy/motion/sensitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { stripProgramBodies } from "./program-bodies";
import { fuseHarnessHome } from "../../runtime/home-state";
import { motionHome } from "./store";

/** Currency amount: `12 €`, `1 299,50 EUR`, `$1,299.00`, `£ 40`. Horizontal spaces only (incl. NBSP / narrow NBSP). */
Expand Down Expand Up @@ -40,8 +41,16 @@ export function bannedListPath(home: string = homedir()): string {

/** Banned terms (trimmed, non-empty, `#` comments skipped); `[]` when the file is absent/unreadable. */
export function loadBannedTerms(home: string = homedir()): string[] {
// Union of the new list and a not-yet-migrated pre-relocation list: terms are never silently dropped.
const terms = new Set(readTerms(bannedListPath(home)));
for (const term of readTerms(join(fuseHarnessHome(home), "motion", "banned.txt"))) terms.add(term);
return [...terms];
}

/** Terms of one banned list file; `[]` when absent/unreadable. */
function readTerms(path: string): string[] {
try {
return readFileSync(bannedListPath(home), "utf8")
return readFileSync(path, "utf8")
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
Expand Down
22 changes: 20 additions & 2 deletions src/policy/motion/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { atomicWrite } from "../../util/json-io";
import { fuseHarnessHome, sanitizeSessionId } from "../../runtime/home-state";
import { rootKey } from "./hash";
import { recordObject } from "../../util/record-object";
import { migrateMotionData } from "./migrate";
import { MotionKeyConflictError, migrateMotionData } from "./migrate";

/** Path fragment of the store — what the self-approval guard and PROTECTED_FRAGMENTS match on. */
export const MOTION_STORE_FRAGMENT = ".fuse-harness/video-motion";
Expand All @@ -35,10 +35,28 @@ export function motionKeyPath(root: string, home: string = homedir()): string {

/** `~/.fuse-harness/video-motion` (store, `keys/`, user-level `banned.txt`); migrates the pre-relocation layout on first resolve. */
export function motionHome(home: string = homedir()): string {
migrateMotionData(home);
try { migrateMotionData(home); leftovers.delete(home); }
catch (error) {
// Downgrade/re-upgrade leftover: never lock out. The new directory stays authoritative, the old one untouched.
if (!(error instanceof MotionKeyConflictError)) throw error;
leftovers.set(home, error.oldKeys);
}
return join(fuseHarnessHome(home), "video-motion");
}

/** Homes whose old key directory holds a differing same-name key (a leftover of a downgrade), by old key dir. */
const leftovers = new Map<string, string>();

/**
* One-line notice for Motion context when an old key directory is a leftover next to the authoritative new one.
* @returns The notice, or `null` when there is no leftover.
*/
export function motionLeftoverKeyNotice(home: string = homedir()): string | null {
motionHome(home);
const old = leftovers.get(home);
return old ? `Motion notice: ${old} is a leftover key directory from an older version; approvals signed with its keys are not valid (the new key directory is authoritative).` : null;
}

/** Per-project store dir: `~/.fuse-harness/video-motion/<sha256(root)[:16]>`. */
export function projectStoreDir(root: string, home: string = homedir()): string {
return join(motionHome(home), rootKey(root));
Expand Down
72 changes: 72 additions & 0 deletions src/policy/motion/trust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @module motion/trust
* Trust checks for the Motion store layout: the key directory, key files and the
* `~/.fuse-harness` root. Every failure is a {@link MotionStoreError} naming the
* real cause and the exact path the owner must fix, so callers can fail closed
* for Motion work only and say why.
*/
import { chmodSync, lstatSync, realpathSync } from "node:fs";
import { join, sep } from "node:path";
import { fuseHarnessHome } from "../../runtime/home-state";

/** A Motion store/keys layout problem; `path` is what the owner must fix. */
export class MotionStoreError extends Error {
/** Underlying errno code when the failure came from a syscall (keeps native-denial routing intact). */
code?: string;
constructor(message: string, readonly path: string, readonly scoped: boolean = false, code?: string) {
super(`${message} Fix: ${path}`);
this.name = "MotionStoreError";
if (code) this.code = code;
}
}

const uid = (): number | null => (typeof process.getuid === "function" ? process.getuid() : null);

/** lstat that never follows links; `null` when absent. */
function lstatOrNull(path: string): ReturnType<typeof lstatSync> | null {
try { return lstatSync(path); }
// ENOTDIR: a parent is a regular file (the store readers already treat that as "no data").
catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT" || code === "ENOTDIR") return null; throw error; }
}

/**
* Trust check for the private key directory: a real directory (no symlink), owned by this user,
* with group/other bits tightened to 0o700. Absent is fine (created later by the grant path).
* @param dir - `~/.fuse-harness/video-motion/keys`.
*/
export function assertTrustedKeyDir(dir: string, scoped: boolean = true): void {
const info = lstatOrNull(dir);
if (!info) return;
const me = uid();
if (info.isSymbolicLink() || !info.isDirectory()) throw new MotionStoreError(`Motion key directory ${dir} is a symlink or not a directory; refusing to read or write keys through it.`, dir, scoped);
if (me !== null && info.uid !== me) throw new MotionStoreError(`Motion key directory ${dir} is not owned by this user; refusing to use it.`, dir, scoped);
if ((Number(info.mode) & 0o077) !== 0) {
try { chmodSync(dir, 0o700); }
catch { throw new MotionStoreError(`Motion key directory ${dir} is accessible to group/others and could not be tightened to 0700.`, dir, scoped); }
}
}

/**
* A symlinked `~/.fuse-harness` is tolerated only when its target stays inside the OS home and is owned
* by this user; a plain directory (or an absent one) is left as is.
* @param base - `~/.fuse-harness`.
* @param home - OS home it must resolve under.
*/
export function assertTrustedHarnessRoot(base: string, home: string): void {
const info = lstatOrNull(base);
if (!info || !info.isSymbolicLink()) return;
const me = uid();
let target: string, realHome: string;
try { target = realpathSync(base); realHome = realpathSync(home); }
catch { throw new MotionStoreError(`Motion harness root ${base} is a dangling symlink.`, base); }
const inside = target === realHome || target.startsWith(realHome.endsWith(sep) ? realHome : realHome + sep);
const owner = lstatOrNull(target);
if (!inside || !owner || (me !== null && owner.uid !== me)) {
throw new MotionStoreError(`Motion harness root ${base} is a symlink to ${target}, outside the home or owned by another user; refusing to use it.`, base);
}
}

/** Path of the key directory without triggering the migration (guards must never throw on a broken store). */
export function plainKeyDirectory(home: string): string {
return join(fuseHarnessHome(home), "video-motion", "keys");
}
12 changes: 10 additions & 2 deletions src/runtime/motion/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { commandContexts } from "../../policy/motion/command-context";
import { canonicalFilePath } from "../prd/prd-canon";
import { findReservation, loadBudget, releaseRender } from "../../policy/motion/budget";
import { existsSync } from "node:fs";
import { MotionStoreError } from "../../policy/motion/trust";
import { leftoverKeyContext, storeFailureIsMotionScoped } from "./store-failure";

/** Dispatch on the host-native event, reduced to its meaning for motion. */
function motionStdout(id: string, payload: Record<string, unknown>, event: NormalizedEvent, opts: HandleOptions, call: MotionCall, project: MotionProject | null): string {
Expand Down Expand Up @@ -130,7 +132,7 @@ function lockedMotionHook(id: string, payload: Record<string, unknown>, event: N
const stdout = terminalStdout ?? motionStdout(id, payload, event, opts, call, project);
// Pre may add a receipt in a separate state object; reload before updating protected metadata.
for (const state of sessionMotionStates(event.sessionId, home)) refreshMotionState(state, home);
return { stdout: appendMotionContext(id, respondAs, stdout, [coldNotice, ...(kind === "prompt" ? messages : [])].filter(Boolean).join("\n")), exit: 0 };
return { stdout: appendMotionContext(id, respondAs, stdout, [coldNotice, project || protectedPre ? leftoverKeyContext(opts.home ?? homedir()) : "", ...(kind === "prompt" ? messages : [])].filter(Boolean).join("\n")), exit: 0 };
} catch (err) {
if (kind === "pre" && dispatchedPre && !preHadReceipt && activeProject && activeCall?.event.toolUseId) {
try {
Expand All @@ -156,7 +158,13 @@ function lockedMotionHook(id: string, payload: Record<string, unknown>, event: N
try { for (const state of sessionMotionStates(event.sessionId, opts.home ?? homedir())) refreshMotionState(state, opts.home ?? homedir()); } catch { /* retain fail-closed response */ }
if (gate) return { stdout: respond(id, gate, respondAs), exit: 0 };
}
const reason = "The protected motion operation could not be verified safely; retry after resolving the internal error.";
let reason = "The protected motion operation could not be verified safely; retry after resolving the internal error.";
if (err instanceof MotionStoreError) {
// A store/keys failure fails closed for Motion work only, with the real cause and the exact path to fix.
if (!storeFailureIsMotionScoped(err, activeProject !== null || protectedPre, event, opts.cwd, opts.home ?? homedir())) return { stdout: "", exit: 0 };
reason = `Motion store unusable: ${err.message}`;
if (kind === "pre") protectedPre = true;
}
const stdout = protectedPre ? respond(id, { kind: "block", title: "Motion protected decision failed", reason }, respondAs) : kind === "post" || kind === "failure" || kind === "stop" || kind === "subagentStop" ? motionStateResponse(id, respondAs, reason) : "";
return { stdout, exit: 0 };
}
Expand Down
Loading
Loading