From fcba8fda73510dc2b82eedd60e7201e47728883c Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Fri, 30 Oct 2026 02:23:21 +0100 Subject: [PATCH 1/2] fix(motion): harden video-motion store migration - keys/ directory symlink and owner trust check before and after migration and on key read/create - a migration failure now blocks only Motion-scoped calls and shows the real cause - when both legacy ~/.fuse-harness/motion and 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 --- src/policy/motion/auth-approval.ts | 7 +- src/policy/motion/auth-key.ts | 5 + src/policy/motion/guard-key.ts | 5 +- src/policy/motion/migrate.ts | 39 +++-- src/policy/motion/sensitive.ts | 11 +- src/policy/motion/store.ts | 22 ++- src/policy/motion/trust.ts | 72 +++++++++ src/runtime/motion/index.ts | 12 +- src/runtime/motion/store-failure.ts | 29 ++++ test/motion-migration-event-shapes.test.ts | 35 +++++ test/motion-migration-hardening.test.ts | 168 +++++++++++++++++++++ 11 files changed, 386 insertions(+), 19 deletions(-) create mode 100644 src/policy/motion/trust.ts create mode 100644 src/runtime/motion/store-failure.ts create mode 100644 test/motion-migration-event-shapes.test.ts create mode 100644 test/motion-migration-hardening.test.ts diff --git a/src/policy/motion/auth-approval.ts b/src/policy/motion/auth-approval.ts index 0b0d5b3..a36f963 100644 --- a/src/policy/motion/auth-approval.ts +++ b/src/policy/motion/auth-approval.ts @@ -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}$/; @@ -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; + } } diff --git a/src/policy/motion/auth-key.ts b/src/policy/motion/auth-key.ts index 7287925..4107126 100644 --- a/src/policy/motion/auth-key.ts +++ b/src/policy/motion/auth-key.ts @@ -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; } @@ -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); diff --git a/src/policy/motion/guard-key.ts b/src/policy/motion/guard-key.ts index 8a3ffed..f1560e2 100644 --- a/src/policy/motion/guard-key.ts +++ b/src/policy/motion/guard-key.ts @@ -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"; @@ -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]; diff --git a/src/policy/motion/migrate.ts b/src/policy/motion/migrate.ts index 114213a..bd87f15 100644 --- a/src/policy/motion/migrate.ts +++ b/src/policy/motion/migrate.ts @@ -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(); @@ -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); } @@ -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); } } diff --git a/src/policy/motion/sensitive.ts b/src/policy/motion/sensitive.ts index 0a11921..9b79550 100644 --- a/src/policy/motion/sensitive.ts +++ b/src/policy/motion/sensitive.ts @@ -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). */ @@ -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("#")); diff --git a/src/policy/motion/store.ts b/src/policy/motion/store.ts index b97d7d5..fcc5911 100644 --- a/src/policy/motion/store.ts +++ b/src/policy/motion/store.ts @@ -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"; @@ -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(); + +/** + * 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/`. */ export function projectStoreDir(root: string, home: string = homedir()): string { return join(motionHome(home), rootKey(root)); diff --git a/src/policy/motion/trust.ts b/src/policy/motion/trust.ts new file mode 100644 index 0000000..c5e8ad1 --- /dev/null +++ b/src/policy/motion/trust.ts @@ -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 | 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"); +} diff --git a/src/runtime/motion/index.ts b/src/runtime/motion/index.ts index 387966c..0cb23a8 100644 --- a/src/runtime/motion/index.ts +++ b/src/runtime/motion/index.ts @@ -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, event: NormalizedEvent, opts: HandleOptions, call: MotionCall, project: MotionProject | null): string { @@ -130,7 +132,7 @@ function lockedMotionHook(id: string, payload: Record, 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 { @@ -156,7 +158,13 @@ function lockedMotionHook(id: string, payload: Record, 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 }; } diff --git a/src/runtime/motion/store-failure.ts b/src/runtime/motion/store-failure.ts new file mode 100644 index 0000000..db109ac --- /dev/null +++ b/src/runtime/motion/store-failure.ts @@ -0,0 +1,29 @@ +/** + * @module motion/store-failure + * Scoping of a Motion store/keys failure: it blocks Motion-scoped calls only (inside a `.motion/` + * project, or touching the Motion store/keys); every other call proceeds as if Motion were absent. + */ +import { homedir } from "node:os"; +import { protectedMotionDirectory } from "../../policy/motion/guard-scope"; +import { motionLeftoverKeyNotice } from "../../policy/motion/store"; +import type { MotionStoreError } from "../../policy/motion/trust"; +import type { NormalizedEvent } from "../normalize"; + +/** + * Whether a store failure concerns Motion work. + * @param error - The store failure (key-directory errors are always Motion work). + * @param inProject - A Motion project was resolved for this call. + * @param event - Normalized event (command / file path are matched against the store/project markers). + * @param cwd - Working directory of the call. + * @param home - OS home. + */ +export function storeFailureIsMotionScoped(error: MotionStoreError, inProject: boolean, event: NormalizedEvent, cwd: string, home: string = homedir()): boolean { + if (error.scoped || inProject) return true; + if (/video-motion|\.motion/.test([event.filePath, event.command].join(" "))) return true; + try { return protectedMotionDirectory(cwd, home); } catch { return true; } +} + +/** One-line leftover-key notice for Motion context; empty when there is none or the store is unusable. */ +export function leftoverKeyContext(home: string): string { + try { return motionLeftoverKeyNotice(home) ?? ""; } catch { return ""; } +} diff --git a/test/motion-migration-event-shapes.test.ts b/test/motion-migration-event-shapes.test.ts new file mode 100644 index 0000000..fa3d75a --- /dev/null +++ b/test/motion-migration-event-shapes.test.ts @@ -0,0 +1,35 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { makeFixture, run } from "./motion-handle-fixture"; + +/** A broken Motion store inside a Motion project: each non-pre event answers in its own native shape, never as a PreToolUse decision. */ +const made: string[] = []; +afterAll(() => { for (const dir of made) rmSync(dir, { recursive: true, force: true }); }); + +function brokenStore(): { fx: ReturnType; keys: string } { + const fx = makeFixture(); + const target = realpathSync(mkdtempSync(join(tmpdir(), "motion-shape-"))); + made.push(target, fx.base); + const root = join(fx.home, ".fuse-harness", "video-motion"); + mkdirSync(root, { recursive: true, mode: 0o700 }); + symlinkSync(target, join(root, "keys")); + return { fx, keys: join(root, "keys") }; +} + +test("Stop, SubagentStop, PostToolUse: top-level block decision with the real cause, no permissionDecision", async () => { + const { fx, keys } = brokenStore(); + for (const event of ["Stop", "SubagentStop", "PostToolUse"]) { + const out = await run(fx, { hook_event_name: event, session_id: "sh1", agent_id: "a1", tool_name: "Bash", tool_use_id: "tu-sh", tool_input: { command: "ls" } }); + expect(out).not.toContain("permissionDecision"); + expect(JSON.parse(out).decision).toBe("block"); + expect(JSON.parse(out).reason).toContain(keys); + } +}); + +test("UserPromptSubmit keeps the base semantics: no PreToolUse-shaped output", async () => { + const { fx } = brokenStore(); + const out = await run(fx, { hook_event_name: "UserPromptSubmit", session_id: "sh2", prompt: "hello" }); + expect(out).not.toContain("permissionDecision"); +}); diff --git a/test/motion-migration-hardening.test.ts b/test/motion-migration-hardening.test.ts new file mode 100644 index 0000000..431f23d --- /dev/null +++ b/test/motion-migration-hardening.test.ts @@ -0,0 +1,168 @@ +import { afterAll, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { grantingMotionKey, readMotionKey } from "../src/policy/motion/auth-key"; +import { migrateMotionData, resetMotionMigrationMemo } from "../src/policy/motion/migrate"; +import { MotionStoreError } from "../src/policy/motion/trust"; +import { loadBannedTerms } from "../src/policy/motion/sensitive"; +import { motionHome, motionLeftoverKeyNotice } from "../src/policy/motion/store"; +import { bash, isDeny, makeFixture, run } from "./motion-handle-fixture"; + +/** Hardening of the 0.1.112 Motion migration: key-dir trust, Motion-scoped failures, downgrade, banned union, root symlink. */ +const made: string[] = []; +const fresh = (): string => { const dir = realpathSync(mkdtempSync(join(tmpdir(), "motion-mh-"))); made.push(dir); return dir; }; +afterAll(() => { for (const dir of made) rmSync(dir, { recursive: true, force: true }); }); +const harness = (home: string): string => join(home, ".fuse-harness"); +const vm = (home: string): string => join(harness(home), "video-motion"); + +/** Home whose `video-motion/keys` is a symlink to a directory outside the home. */ +function plantedKeys(): { home: string; outside: string } { + const home = fresh(), outside = fresh(); + mkdirSync(vm(home), { recursive: true, mode: 0o700 }); + symlinkSync(outside, join(vm(home), "keys")); + return { home, outside }; +} + +/** Home in the downgrade/re-upgrade state: both layouts hold a `p.key` with different bytes. */ +function conflictHome(): string { + const home = fresh(); + mkdirSync(join(harness(home), "motion-keys"), { recursive: true, mode: 0o700 }); + mkdirSync(join(vm(home), "keys"), { recursive: true, mode: 0o700 }); + writeFileSync(join(harness(home), "motion-keys/p.key"), "OLDKEY", { mode: 0o600 }); + writeFileSync(join(vm(home), "keys/p.key"), "NEWKEY", { mode: 0o600 }); + return home; +} + +test("1: a symlinked video-motion/keys is refused with an explicit reason and never written through", () => { + const { home, outside } = plantedKeys(); + resetMotionMigrationMemo(); + expect(() => migrateMotionData(home)).toThrow(MotionStoreError); + expect(() => migrateMotionData(home)).toThrow(/key directory.*symlink/); + expect(() => grantingMotionKey(join(home, "proj"), home)).toThrow(MotionStoreError); + expect(readdirSync(outside)).toEqual([]); + expect(() => readMotionKey(join(home, "proj"), home)).toThrow(MotionStoreError); +}); + +test("1: motion/keys carried along by the rename is caught after the migration", () => { + const home = fresh(), outside = fresh(); + mkdirSync(join(harness(home), "motion"), { recursive: true }); + symlinkSync(outside, join(harness(home), "motion/keys")); + resetMotionMigrationMemo(); + expect(() => migrateMotionData(home)).toThrow(/key directory.*symlink/); + expect(readdirSync(outside)).toEqual([]); +}); + +test("1: a loose keys directory is tightened to 0700 and an existing key must not be a symlink", () => { + const home = fresh(); + mkdirSync(join(vm(home), "keys"), { recursive: true }); + chmodSync(join(vm(home), "keys"), 0o755); + resetMotionMigrationMemo(); + const key = grantingMotionKey(join(home, "proj"), home); + expect(key?.length).toBe(32); + expect(statSync(join(vm(home), "keys")).mode & 0o077).toBe(0); + + const other = fresh(), target = join(fresh(), "t.key"); + writeFileSync(target, Buffer.alloc(32, 1), { mode: 0o600 }); + mkdirSync(join(vm(other), "keys"), { recursive: true, mode: 0o700 }); + const file = join(vm(other), "keys", readdirSync(join(vm(home), "keys"))[0]!); + symlinkSync(target, file); + expect(readMotionKey(join(home, "proj"), other)).toBeNull(); +}); + +test("2: a store failure never blocks a call outside Motion, but blocks Motion work with the real cause and path", async () => { + const fx = makeFixture(); + const outside = fresh(); + mkdirSync(vm(fx.home), { recursive: true, mode: 0o700 }); + symlinkSync(fresh(), join(vm(fx.home), "keys")); + expect(await run(fx, bash("s1", "ls"), "motion", outside)).toBe(""); + expect(await run(fx, { hook_event_name: "Stop", session_id: "s1" }, "motion", outside)).toBe(""); + const inside = await run(fx, bash("s2", "ls", { tool_use_id: "tu-2" })); + expect(isDeny(inside)).toBe(true); + expect(inside).toContain(join(vm(fx.home), "keys")); + expect(inside).toMatch(/symlink/); + const touching = await run(fx, bash("s3", `cat ${join(vm(fx.home), "banned.txt")}`), "motion", outside); + expect(isDeny(touching)).toBe(true); +}); + +test("2: a symlinked video-motion root and an unwritable harness dir are scoped to Motion too", async () => { + const fx = makeFixture(); + const outside = fresh(); + mkdirSync(harness(fx.home), { recursive: true }); + symlinkSync(fresh(), vm(fx.home)); + expect(await run(fx, bash("s1", "ls"), "motion", outside)).toBe(""); + const inside = await run(fx, bash("s2", "ls")); + expect(isDeny(inside)).toBe(true); + expect(inside).toContain(vm(fx.home)); + + const ro = makeFixture(); + mkdirSync(join(harness(ro.home), "motion/k"), { recursive: true }); + chmodSync(harness(ro.home), 0o500); + try { + expect(await run(ro, bash("s3", "ls"), "motion", outside)).toBe(""); + } finally { chmodSync(harness(ro.home), 0o700); } +}); + +test("3: differing keys in both layouts never lock out; the new directory stays authoritative and the old one untouched", async () => { + const home = conflictHome(); + resetMotionMigrationMemo(); + expect(() => motionHome(home)).not.toThrow(); + expect(readFileSync(join(vm(home), "keys/p.key"), "utf8")).toBe("NEWKEY"); + expect(readFileSync(join(harness(home), "motion-keys/p.key"), "utf8")).toBe("OLDKEY"); + const notice = motionLeftoverKeyNotice(home); + expect(notice).toContain(join(harness(home), "motion-keys")); + expect(notice).toMatch(/leftover/); + expect(notice).not.toMatch(/\n/); +}); + +test("3: the leftover notice shows in Motion context only", async () => { + const fx = makeFixture(); + mkdirSync(join(harness(fx.home), "motion-keys"), { recursive: true, mode: 0o700 }); + mkdirSync(join(vm(fx.home), "keys"), { recursive: true, mode: 0o700 }); + writeFileSync(join(harness(fx.home), "motion-keys/p.key"), "OLD", { mode: 0o600 }); + writeFileSync(join(vm(fx.home), "keys/p.key"), "NEW", { mode: 0o600 }); + const outside = fresh(); + expect(await run(fx, bash("s1", "ls"), "motion", outside)).toBe(""); + const inside = await run(fx, { hook_event_name: "UserPromptSubmit", session_id: "s2", prompt: "hello" }); + expect(inside).toContain("leftover"); + expect(inside).toContain(join(harness(fx.home), "motion-keys")); +}); + +test("4: banned terms are the union of both layouts until the old file is gone", () => { + const home = fresh(); + mkdirSync(join(harness(home), "motion"), { recursive: true }); + mkdirSync(vm(home), { recursive: true, mode: 0o700 }); + writeFileSync(join(harness(home), "motion/banned.txt"), "acme\nshared\n"); + writeFileSync(join(vm(home), "banned.txt"), "# c\nmine\nshared\n"); + resetMotionMigrationMemo(); + expect(loadBannedTerms(home).sort()).toEqual(["acme", "mine", "shared"]); + rmSync(join(harness(home), "motion/banned.txt")); + expect(loadBannedTerms(home).sort()).toEqual(["mine", "shared"]); +}); + +test("5: a symlinked ~/.fuse-harness inside the home keeps working; outside the home it fails closed", () => { + const inside = fresh(); + mkdirSync(join(inside, "real"), { recursive: true }); + symlinkSync(join(inside, "real"), harness(inside)); + resetMotionMigrationMemo(); + expect(() => motionHome(inside)).not.toThrow(); + + const away = fresh(), home = fresh(); + symlinkSync(away, harness(home)); + resetMotionMigrationMemo(); + expect(() => motionHome(home)).toThrow(MotionStoreError); + expect(() => motionHome(home)).toThrow(/outside the home/); +}); + +test("real CLI: a planted keys symlink does not block a Bash call outside any Motion project", () => { + const home = fresh(), cwd = fresh(); + mkdirSync(vm(home), { recursive: true, mode: 0o700 }); + symlinkSync(fresh(), join(vm(home), "keys")); + const bin = join(import.meta.dir, "..", "src", "cli", "bin.ts"); + const payload = JSON.stringify({ hook_event_name: "PreToolUse", session_id: "cli1", tool_name: "Bash", tool_use_id: "tu-cli", tool_input: { command: "ls" }, cwd }); + const out = spawnSync(process.execPath, [bin, "hook", "claude-code", "motion"], { input: payload, cwd, env: { ...process.env, HOME: home }, encoding: "utf8", timeout: 20000 }); + expect(out.status).toBe(0); + expect(out.stdout).not.toContain("deny"); + expect(existsSync(join(home, ".fuse-harness/video-motion/keys"))).toBe(true); +}, 25000); From ae995a86e987009bddcdc3772292461954f4e483 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Fri, 30 Oct 2026 02:23:40 +0100 Subject: [PATCH 2/2] chore: bump version and CHANGELOG to 0.1.113 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efa9638..e5345ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index df0d631..cd0c194 100644 --- a/package.json +++ b/package.json @@ -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",