diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a699039b86..08f3aaa85f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "genesis-tools", - "version": "1.0.107", + "version": "1.0.109", "description": "Plugins for GenesisTools CLI development and management", "owner": { "name": "genesiscz", @@ -11,7 +11,7 @@ { "name": "genesis-tools", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", - "version": "1.0.107", + "version": "1.0.109", "author": { "name": "GenesisTools" }, @@ -24,6 +24,16 @@ "./skills/" ] }, + { + "name": "genesis-tools-mods", + "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", + "version": "1.1.0", + "author": { + "name": "GenesisTools" + }, + "source": "./plugins/genesis-tools-mods", + "category": "development" + }, { "name": "genesis-tools-server", "description": "A single Claude Code command for Linux server security auditing: log analysis, intrusion detection, malware scanning, and rootkit detection. Not an MCP server, despite the name.", diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json new file mode 100644 index 0000000000..9b02302176 --- /dev/null +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "genesis-tools-mods", + "version": "1.1.0", + "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Configured in the hooks file: tools agents hooks config set mods.fullDiff|mods.idleCompact true|false.", + "author": { + "name": "GenesisTools" + } +} diff --git a/plugins/genesis-tools-mods/hooks/hooks.json b/plugins/genesis-tools-mods/hooks/hooks.json new file mode 100644 index 0000000000..f4cb24bf8d --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/hooks.json @@ -0,0 +1,3 @@ +{ + "modules": ["./register.tsx"] +} diff --git a/plugins/genesis-tools-mods/hooks/idle.ts b/plugins/genesis-tools-mods/hooks/idle.ts new file mode 100644 index 0000000000..6968c99fab --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/idle.ts @@ -0,0 +1,111 @@ +/** The idle-compaction mod's settings, from the `mods` section of the hooks file. */ +export interface IdleCompactSettings { + enabled: boolean; + /** Minutes after the last main-loop turn. Under 60, so the compaction still reads the 1-hour cache. */ + minutes: number; + /** Below this many context tokens a compaction saves too little to pay for itself. */ + minTokens: number; +} + +/** + * 56 minutes: Claude Code's own idle compaction fires at 54 (0.9 of the hour) and goes first; the cache + * written by the last request expires at 60. 200,000 tokens is the built-in's floor + * (`CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS` on this machine). + */ +export const IDLE_COMPACT_DEFAULTS: IdleCompactSettings = { enabled: true, minutes: 56, minTokens: 200_000 }; + +/** The start of the notice the mod appends. GenesisClaude's session-analyzer `idle-compact.ts scan` counts it. */ +export const IDLE_NOTICE = "Compacted while idle"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function wholeIn(value: unknown, min: number, max: number): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max; +} + +/** + * `mods.idleCompact`, `mods.idleCompactMinutes` and `mods.idleCompactMinTokens` from the hooks file + * (`tools agents hooks config set mods.idleCompact false --write`). A missing or broken file, and a value + * of the wrong kind, leave that value's default. + */ +export function idleCompactSettings(hooksFile: string | undefined): IdleCompactSettings { + let mods: unknown; + + try { + const stored: unknown = hooksFile === undefined ? undefined : JSON.parse(hooksFile); + mods = isRecord(stored) ? stored.mods : undefined; + } catch { + mods = undefined; + } + + const held = isRecord(mods) ? mods : {}; + + return { + enabled: typeof held.idleCompact === "boolean" ? held.idleCompact : IDLE_COMPACT_DEFAULTS.enabled, + minutes: wholeIn(held.idleCompactMinutes, 1, 59) ? held.idleCompactMinutes : IDLE_COMPACT_DEFAULTS.minutes, + minTokens: wholeIn(held.idleCompactMinTokens, 0, Number.MAX_SAFE_INTEGER) + ? held.idleCompactMinTokens + : IDLE_COMPACT_DEFAULTS.minTokens, + }; +} + +/** + * Whether a compaction leaves nothing for the timer to save: a main-loop one that ran. A `precompute` + * installs nothing (the engine keeps its summary for a later threshold compaction), and it resolves after + * the turn that started it, so cancelling on it would drop the timer of exactly the largest sessions. + */ +export function compactionEndsIdleWait({ + agentId, + trigger, + skipped, +}: { + agentId: string | undefined; + trigger: string; + skipped: boolean; +}): boolean { + return agentId === undefined && trigger !== "precompute" && !skipped; +} + +/** Why the timer did not compact, or `undefined` to compact. */ +export function idleCompactSkip({ + settings, + surfaces, + tokens, +}: { + settings: IdleCompactSettings; + surfaces: number; + tokens: number | undefined; +}): string | undefined { + if (!settings.enabled) { + return "mods.idleCompact is off"; + } + + if (surfaces === 0) { + return "no surface draws the session (claude -p)"; + } + + if (tokens === undefined || tokens < settings.minTokens) { + return `context ${tokens ?? "unknown"} tokens, under the floor of ${settings.minTokens}`; + } + + return undefined; +} + +export function idleNotice({ + minutes, + tokensBefore, + tokensAfter, +}: { + minutes: number; + tokensBefore: number | undefined; + tokensAfter: number | undefined; +}): string { + const sizes = + tokensBefore !== undefined && tokensAfter !== undefined + ? ` (${tokensBefore.toLocaleString("en-US")} to ${tokensAfter.toLocaleString("en-US")} tokens)` + : ""; + + return `${IDLE_NOTICE}, before the prompt cache expired: genesis-tools-mods after ${minutes} min${sizes}`; +} diff --git a/plugins/genesis-tools-mods/hooks/mods.test.ts b/plugins/genesis-tools-mods/hooks/mods.test.ts new file mode 100644 index 0000000000..f092db2d93 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/mods.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "bun:test"; +import { + compactionEndsIdleWait, + IDLE_COMPACT_DEFAULTS, + IDLE_NOTICE, + idleCompactSettings, + idleCompactSkip, + idleNotice, +} from "./idle"; +import { fullDiffEnabled, sectionsOf, sectionsOfCalls } from "./sections"; + +const NOTE = [ + "# Diff of toolu_x", + "", + "```sh", + "sed -i s/a/b/ src/a.ts src/b.ts", + "```", + "", + "## /repo/src/a.ts (+1 -1)", + "", + "```diff", + "diff --git a/a.ts b/a.ts", + "index 111..222 100644", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1,2 +1,2 @@", + "-a", + "+b", + " same", + "```", + "", + "## /repo/src/b.ts (+0 -1)", + "", + "```diff", + "@@ -1,1 +0,0 @@", + "-gone", + "```", + "", + "## Changed, past the file cap", + "- /repo/src/c.ts", +].join("\n"); + +describe("the full-diff mod's note reader", () => { + it("reads each file of a note from its first hunk, and nothing else", () => { + const sections = sectionsOf(NOTE); + + expect(sections.map((section) => section.path)).toEqual(["/repo/src/a.ts", "/repo/src/b.ts"]); + expect(sections[0]?.patch).toBe("@@ -1,2 +1,2 @@\n-a\n+b\n same"); + expect(sections[1]?.patch).toBe("@@ -1,1 +0,0 @@\n-gone"); + expect(sectionsOf("# Diff of toolu_y\n\n```sh\nls\n```\n")).toEqual([]); + }); + + it("reads a group's notes in call order, skips a call with no note, and stops at the budget", async () => { + const notes: Record = { first: NOTE, third: NOTE }; + const read = async (id: string) => { + const note = notes[id]; + + if (note === undefined) { + throw new Error(`no note for ${id}`); + } + + return note; + }; + + const all = await sectionsOfCalls({ ids: ["first", "second", "third"], read, maxChars: 1_000 }); + + expect(all.map((section) => `${section.call} ${section.path}`)).toEqual([ + "first /repo/src/a.ts", + "first /repo/src/b.ts", + "third /repo/src/a.ts", + "third /repo/src/b.ts", + ]); + + const cut = await sectionsOfCalls({ ids: ["first", "third"], read, maxChars: 40 }); + + expect(cut.map((section) => section.path)).toEqual(["/repo/src/a.ts"]); + }); + + it("is on unless the hooks file sets mods.fullDiff to false", () => { + expect(fullDiffEnabled(undefined)).toBe(true); + expect(fullDiffEnabled("not json")).toBe(true); + expect(fullDiffEnabled('{"mods":{"fullDiff":"no"}}')).toBe(true); + expect(fullDiffEnabled('{"mods":{"fullDiff":false}}')).toBe(false); + expect(fullDiffEnabled('{"diff":{}}')).toBe(true); + }); +}); + +describe("idle compaction", () => { + it("reads its settings from the mods section and keeps the default for a bad value", () => { + expect(idleCompactSettings(undefined)).toEqual(IDLE_COMPACT_DEFAULTS); + expect(idleCompactSettings("not json")).toEqual(IDLE_COMPACT_DEFAULTS); + expect( + idleCompactSettings( + JSON.stringify({ mods: { idleCompact: false, idleCompactMinutes: 50, idleCompactMinTokens: 0 } }) + ) + ).toEqual({ enabled: false, minutes: 50, minTokens: 0 }); + // 60 minutes or more would fire after the cache expired. + expect(idleCompactSettings(JSON.stringify({ mods: { idleCompactMinutes: 60, idleCompact: "no" } }))).toEqual( + IDLE_COMPACT_DEFAULTS + ); + }); + + it("compacts only when on, drawn and over the floor", () => { + const settings = { enabled: true, minutes: 56, minTokens: 200_000 }; + + expect(idleCompactSkip({ settings, surfaces: 1, tokens: 300_000 })).toBeUndefined(); + expect(idleCompactSkip({ settings: { ...settings, enabled: false }, surfaces: 1, tokens: 300_000 })).toBe( + "mods.idleCompact is off" + ); + expect(idleCompactSkip({ settings, surfaces: 0, tokens: 300_000 })).toContain("no surface"); + expect(idleCompactSkip({ settings, surfaces: 1, tokens: 199_999 })).toContain("under the floor"); + expect(idleCompactSkip({ settings, surfaces: 1, tokens: undefined })).toContain("unknown"); + }); + + it("a precompute, a skipped and a subagent compaction leave the armed timer alone", () => { + expect(compactionEndsIdleWait({ agentId: undefined, trigger: "auto", skipped: false })).toBe(true); + expect(compactionEndsIdleWait({ agentId: undefined, trigger: "manual", skipped: false })).toBe(true); + expect(compactionEndsIdleWait({ agentId: undefined, trigger: "precompute", skipped: false })).toBe(false); + expect(compactionEndsIdleWait({ agentId: undefined, trigger: "auto", skipped: true })).toBe(false); + expect(compactionEndsIdleWait({ agentId: "a1", trigger: "auto", skipped: false })).toBe(false); + }); + + it("writes a notice that starts with the text the session scan counts", () => { + const text = idleNotice({ minutes: 56, tokensBefore: 550_000, tokensAfter: 40_000 }); + + expect(text.startsWith(IDLE_NOTICE)).toBe(true); + expect(text).toContain("550,000 to 40,000 tokens"); + expect(idleNotice({ minutes: 56, tokensBefore: undefined, tokensAfter: 1 })).not.toContain("tokens)"); + }); +}); diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx new file mode 100644 index 0000000000..17590fdc53 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -0,0 +1,185 @@ +import type { EngineInterface, Register, Timer } from "claude-code"; +import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice } from "./idle"; +import { type CallSection, fullDiffEnabled, sectionsOfCalls } from "./sections"; + +// The full-diff mod. When the diff hook (`tools agents hooks`) cut a Bash call's message, it wrote the +// whole diff to ~/.genesis-tools/agents/hook-diffs/.md. This draws those hunks with Claude +// Code's own Code element under the call's row, so the colours, the gutters and the wrapping are the +// engine's and nothing counts against the 10,000-character hook message. +// +// πŸ›‘ Two rows, two hooks. Outside `--verbose` and the ctrl+o transcript the engine draws a group of calls +// as ONE `ToolGroup` line ("ran 3 shell commands") and no `ToolUse` row at all (`ToolGroup.isExpanded` +// in the engine types), so the diffs go under that line. An expanded group draws each call as its own +// `ToolUse` row, and the diff goes under the call. A spike that hooked only `ToolResult`, then only +// `ToolUse`, drew nothing in the normal view (checked on screen 2026-10-15). + +// One drawing may hold 100,000 characters in all (CodeProps.source); stay well inside it. +const MAX_CHARS = 60_000; + +/** `/.genesis-tools/agents`; `GENESIS_TOOLS_HOME` replaces the home, as it does for every tool. */ +async function agentsDir($: EngineInterface): Promise { + const home = (await $.env.get("GENESIS_TOOLS_HOME")) ?? (await $.env.get("HOME")); + + return home ? `${home}/.genesis-tools/agents` : undefined; +} + +async function readHooksFile($: EngineInterface, dir: string | undefined): Promise { + return dir ? $.fs.read(`${dir}/hooks.json`).catch(() => undefined) : undefined; +} + +async function fullDiffs($: EngineInterface, ids: readonly string[]): Promise { + const dir = ids.length > 0 ? await agentsDir($) : undefined; + + if (!dir) { + return []; + } + + const hooksFile = await readHooksFile($, dir); + + if (!fullDiffEnabled(hooksFile)) { + return []; + } + + return sectionsOfCalls({ ids, read: (id) => $.fs.read(`${dir}/hook-diffs/${id}.md`), maxChars: MAX_CHARS }); +} + +// The idle-compaction mod. Claude Code's own idle compaction fires once, 54 minutes after the last +// request, and gives up at the first gate that refuses (`near_limit` refuses while the account's last reply +// carried a rate-limit warning, even after the window reset). Then the 1-hour cache expires and the next +// prompt pays for the whole context again. This timer compacts at `mods.idleCompactMinutes` (56) after the +// last main-loop turn, unless a compaction already happened. A reload of the mod drops the timer; the next +// turn arms it again. +let idleTimer: Timer | undefined; + +function stopIdleTimer(): void { + idleTimer?.cancel(); + idleTimer = undefined; +} + +async function compactIfIdle($: EngineInterface): Promise { + idleTimer = undefined; + + // Read again at the fire time, so `config set mods.idleCompact false` stops a timer already armed. + const settings = idleCompactSettings(await readHooksFile($, await agentsDir($))); + const [surfaces, usage] = await Promise.all([$.session.surfaces(), $.session.usage()]); + + if (idleCompactSkip({ settings, surfaces: surfaces.length, tokens: usage.context.tokens }) !== undefined) { + return; + } + + try { + // A full compaction: only the engine's threshold path uses a ready precomputed summary, so this one + // makes its own summary request (over the still-warm cache) and a ready precompute is dropped. + const compacted = await $.session.compact({}); + + if (compacted.skip !== undefined) { + return; + } + + const text = idleNotice({ + minutes: settings.minutes, + tokensBefore: compacted.tokensBefore, + tokensAfter: compacted.tokensAfter, + }); + + await $.session.append({ message: { type: "system", content: [{ type: "text", text }] } }); + } catch { + // `compact` rejects while a turn runs: the person came back, and that turn arms the timer again. + } +} + +export const register: Register = (on) => { + on("turn.start", (_$, e, next) => { + stopIdleTimer(); + + return next(e); + }); + + on("turn.complete", async ($, e, next) => { + const done = await next(e); + + if (e.agentId === undefined) { + stopIdleTimer(); + + const settings = idleCompactSettings(await readHooksFile($, await agentsDir($))); + + if (settings.enabled) { + idleTimer = $.clock.after(settings.minutes * 60_000, () => { + void compactIfIdle($); + }); + } + } + + return done; + }); + + on("session.compact", async (_$, e, next) => { + const result = await next(e); + + // The person's /compact, the engine's own auto or idle compaction: nothing is left to save. + if (compactionEndsIdleWait({ agentId: e.agentId, trigger: e.trigger, skipped: result.skip !== undefined })) { + stopIdleTimer(); + } + + return result; + }); + + on("ui.render", { component: "ToolGroup" }, async ($, e, next) => { + const drawn = await next(e); + + if (e.props.isExpanded) { + return drawn; + } + + const ids = e.props.calls.flatMap((call) => + call.tool === "Bash" && !call.isRunning && call.tool_use_id ? [call.tool_use_id] : [] + ); + const sections = await fullDiffs($, ids); + + if (sections.length === 0) { + return drawn; + } + + const { Box, Code, Text } = $.ui.resolve(e); + + return ( + + {drawn} + {sections.map((section) => ( + + {section.path} + + + ))} + + ); + }); + + on("ui.render", { component: "ToolUse" }, async ($, e, next) => { + const drawn = await next(e); + + if (e.props.tool !== "Bash" || e.props.isRunning) { + return drawn; + } + + const sections = await fullDiffs($, [e.props.tool_use_id]); + + if (sections.length === 0) { + return drawn; + } + + const { Box, Code, Text } = $.ui.resolve(e); + + return ( + + {drawn} + {sections.map((section) => ( + + {section.path} + + + ))} + + ); + }); +}; diff --git a/plugins/genesis-tools-mods/hooks/sections.ts b/plugins/genesis-tools-mods/hooks/sections.ts new file mode 100644 index 0000000000..ed1f8ca70d --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/sections.ts @@ -0,0 +1,96 @@ +/** One file of a diff-hook full-diff note: its path and its unified-diff hunks. */ +export interface Section { + path: string; + patch: string; +} + +export interface CallSection extends Section { + call: string; +} + +/** + * The files of `~/.genesis-tools/agents/hook-diffs/.md`, which `tools agents hooks` writes when + * a message cuts lines: `## (+a -b)`, a blank line, then the hunks in a ```diff fence. + */ +const SECTION = /^## (.+?) \(\+\d+ -\d+\)\n\n```diff\n([\s\S]*?)\n```$/gm; + +/** + * The hunks only. The note's fence starts with git's `diff --git` and `index` lines, which the Code + * element's diff parser is not documented to read past (it reads past `---` and `+++`), so the patch + * starts at the first hunk header. + */ +function hunksOf(patch: string): string { + const at = patch.search(/^@@ /m); + + return at < 0 ? "" : patch.slice(at); +} + +export function sectionsOf(text: string): Section[] { + return [...text.matchAll(SECTION)] + .map((match) => ({ path: match[1] ?? "", patch: hunksOf(match[2] ?? "") })) + .filter((section) => section.patch.length > 0); +} + +/** + * The sections of several calls' notes, in call order, while their patches fit `maxChars` together. + * A call with no note (the hook did not cut its message, or it changed nothing) adds nothing. + */ +export async function sectionsOfCalls({ + ids, + read, + maxChars, +}: { + ids: readonly string[]; + read: (id: string) => Promise; + maxChars: number; +}): Promise { + const found: CallSection[] = []; + let spent = 0; + + for (const call of ids) { + let note: string; + + try { + note = await read(call); + } catch { + continue; + } + + for (const section of sectionsOf(note)) { + spent += section.patch.length; + + if (spent > maxChars) { + return found; + } + + found.push({ ...section, call }); + } + } + + return found; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * `mods.fullDiff` from the hooks file (`~/.genesis-tools/agents/hooks.json`), which + * `tools agents hooks config set mods.fullDiff false` writes. A file that is missing, unreadable or + * not JSON, and a value that is not a boolean, leave the default: on. + */ +export function fullDiffEnabled(hooksFile: string | undefined): boolean { + if (hooksFile === undefined) { + return true; + } + + try { + const stored: unknown = JSON.parse(hooksFile); + const mods = isRecord(stored) ? stored.mods : undefined; + const value = isRecord(mods) ? mods.fullDiff : undefined; + + return typeof value === "boolean" ? value : true; + } catch { + return true; + } +} diff --git a/plugins/genesis-tools/.claude-plugin/plugin.json b/plugins/genesis-tools/.claude-plugin/plugin.json index dcd975f2fb..9413ff0080 100644 --- a/plugins/genesis-tools/.claude-plugin/plugin.json +++ b/plugins/genesis-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools", - "version": "1.0.107", + "version": "1.0.109", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools/skills/handoff-to/references/grok.md b/plugins/genesis-tools/skills/handoff-to/references/grok.md index 8fd6a6d4ab..9c45776dfd 100644 --- a/plugins/genesis-tools/skills/handoff-to/references/grok.md +++ b/plugins/genesis-tools/skills/handoff-to/references/grok.md @@ -60,14 +60,14 @@ tools grok sessions [--json] | Intent | How | Verified behavior | |---|---|---| | read-only (review, second opinion) | `--readonly` | Worker gets only `read_file,list_dir,grep` β€” it physically lacks edit and terminal tools; edit attempts are denied and files stay untouched | -| implementation (default) | no flag | Headless default is **Auto mode β€” a project jail**: edits and commands inside `--cwd` run without approval; any write outside the project is blocked with a denial message the worker sees and reports | -| full trust | not exposed | On purpose. For risky work, give the worker a disposable worktree and keep the default jail | +| implementation (default) | no flag | The harness passes `--always-approve` on every writable turn: edits and commands run without approval. There is no project jail any more: on grok 1.0.52 a write to a path under $HOME was allowed in 0 ms with or without the flag (2026-10-15). Without the flag, a nested `sh -c` or a `;` chain could resolve as `cancelled` and end the whole turn | +| full trust | same as the default | For risky work, give the worker a disposable worktree: nothing else bounds its writes | πŸ›‘ Never reach past the harness for safety flags β€” two raw-grok flags LOOK like safety dials and are not (both verified): `--permission-mode plan` does not restrict a headless run (the worker edited a file under it), and `--disallowed-tools` silently refuses to remove `run_terminal_command` (the worker appended via the shell instead). -There are no mid-turn approvals. The Auto-mode jail plus the checkpoint contract below are the only brakes. +There are no mid-turn approvals or project jail. A disposable worktree keeps changes away from the live checkout; the checkpoint contract below tells the worker when to stop, but does not enforce a filesystem boundary. -πŸ›‘ **A dispatch the user did not ask for is `--readonly`.** The skill triggers proactively, and the default mode edits and runs commands inside `--cwd` with no confirmation. So when *you* decided to hand the task off, pass `--readonly`, or give the worker a disposable worktree (`git worktree add`) that is not the user's live checkout. A writable run against a real working tree needs the user to have asked for the handoff, or to have said yes to it. Being inside the project jail is not consent: the jail only stops writes *outside* `--cwd`, and everything the user cares about is inside it. +πŸ›‘ **A dispatch the user did not ask for is `--readonly`.** The skill triggers proactively, and the default mode edits and runs commands inside `--cwd` with no confirmation. So when *you* decided to hand the task off, pass `--readonly`, or give the worker a disposable worktree (`git worktree add`) that is not the user's live checkout. A writable run against a real working tree needs the user to have asked for the handoff, or to have said yes to it. The default grants broad write access; `--cwd` selects a working directory and does not constrain filesystem access. ### πŸ›‘ `--readonly` costs more than editing @@ -75,10 +75,10 @@ The read-only worker gets `read_file,list_dir,grep` and **no terminal tool at al So for a read-only grok review, plan for it up front: -- If the deliverable is a file (Obsidian vault, report.md), do **not** pass `--readonly`. Use the default jail with `--cwd` at the note folder, or write `/tmp` and let the orchestrator copy. Same class of miss as Codex `--write deny` on 2026-08-31. +- If the deliverable is a file (Obsidian vault, report.md), do **not** pass `--readonly`. Use a disposable worktree and let the orchestrator copy the report to the note folder. Same class of miss as Codex `--write deny` on 2026-08-31. - Make **inline output the deliverable** only when the review is actually inline. Say so in the brief; do not ask for a file. - Expect **zero executed verification**. If the worker reports on test quality or runtime behavior, it inferred that from reading. Say so when you relay it, or re-run the claim yourself. -- If the review genuinely needs to run something, do not reach for `--writable`. Give the worker a disposable worktree and let it run in the default Auto-mode jail there. +- If the review genuinely needs to run something, do not reach for `--writable`. Give the worker a disposable worktree and let it run there under the checkpoint contract, with broad write access. ## Checkpoints diff --git a/src/agents/README.md b/src/agents/README.md index dd483a793f..d4f717c50a 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -183,7 +183,14 @@ Every row carries its syntax colour and is wrapped at the terminal width before rows are bought first, together with the context row right above and below each change, then the other context, nearest first. The full-width fill is bought last, from what is left. -`diff.highlight` sets the syntax colour, through `bat` (one run per syntax in a message): +`diff.highlighter` picks what paints the syntax colour. `hljs` (default) is highlight.js in process, +in Claude Code's own dark palette (Monokai Extended, 24 scopes): it loads in about 7 ms once, then 0.3 ms a +call, and on three samples of this repository wrote about half the characters of `bat` (5,539 against +10,934 for 90 lines of TypeScript; 146 colour switches against 387). `bat` is one process per syntax in a +message (25 ms median) in `bat`'s own theme. A file type the painter does not know (`.txt`, `.env`), a text +none of whose scopes has a colour in that palette (Markdown), and every light theme go to `bat`. + +`diff.highlight` sets how much colour is written, for either painter (`bat` is spawned once per syntax in a message): | Mode | Characters | Risk | |---|---|---| @@ -195,10 +202,37 @@ Every mode drops escape codes that change nothing (a reset before a new colour, colour on blanks); that loses no colour, so there is no raw mode. A message that cuts lines links the call's full diff (`diff.fullDiff`: `genesis`, `path` or `off`). The `genesis` link is printed only while the browser router routes Markdown on this Mac; otherwise the message names the path. -`tools agents hooks diff show ` prints it too. +`tools agents hooks diff show ` prints it too. The message starts with the call's command, dim: +its first `diff.commandLines` lines (3; 0 shows none), each cut to 80 characters, then how many lines were +left out. Claude Code folds a finished call into "ran N shell commands" and leaves only the hook message +under the fold, so without them nothing says which command changed the files. + +**The full-diff mod.** `tools agents hooks mods install --write` installs the plugin `genesis-tools-mods` +from this repository's marketplace (`plugins/genesis-tools-mods/`). It is a Claude Code mod: when a hook +message cut lines, it draws the call's whole diff (the `hook-diffs/.md` note) with Claude Code's own +diff element under the call's row, so the 10,000-character message limit no longer hides the rest. A running +session needs `/reload-plugins`. `tools agents hooks mods` shows whether it is installed; +`tools agents hooks config set mods.fullDiff false --write` turns it off without an uninstall (the mod reads +the hooks file). It draws nothing while `diff.fullDiff` is `off`. The mod API is early access in Claude Code +(2.1.287 and later), so a release can change it; the mod then draws nothing and the hook message is unchanged. + +**The idle-compaction mod.** The same plugin compacts an idle Claude session 56 minutes after its last turn +(`mods.idleCompactMinutes`, 1 to 59), while the 1-hour prompt cache is still warm, so the next prompt does not +pay for the whole context again. Claude Code's own idle compaction tries once at 54 minutes and gives up at its +first refused check (a rate-limit warning on the last reply is enough); the mod does not run that check, so a +compaction can be spent while the account is in its warning. It skips a session that compacted after its last +turn, a context under `mods.idleCompactMinTokens` (200,000) and a session with no screen (`claude -p`), and it +appends a notice that starts `Compacted while idle`. `tools agents hooks config set mods.idleCompact false --write` +turns it off, also for a timer already armed. A reload of the mod drops the timer until the next turn ends. + +A file or a whole folder moved with `mv` or +`git mv` is one line, `old/ β†’ new/ (284 files)`, when the new copies match the old ones byte for byte +(up to 4,000 files; a file over 4 MiB is not compared). `diff.fill` sets how far a changed row's band reaches. `block` (default) fills to the widest row of -the file. `terminal` fills to the pane's width, like Claude Code's own diff; but a hook message is fixed +the file, but never past `diff.fillMax` columns (72; 0 lifts the limit): without that, one long line made +every row as wide as the pane, and every row wrapped into an empty band row when the pane got narrower. +A row longer than the limit is not padded. `terminal` fills to the pane's width, like Claude Code's own diff; but a hook message is fixed text, so when the pane later gets narrower, every row wraps into an empty band row. `none` ends the band with the text. diff --git a/src/agents/commands/hooks.ts b/src/agents/commands/hooks.ts index e0df16ead9..726f14b7ff 100644 --- a/src/agents/commands/hooks.ts +++ b/src/agents/commands/hooks.ts @@ -41,6 +41,7 @@ import { uninstallHooks, wiringStatus, } from "../lib/hooks/install"; +import { claudePlugin, MODS_PLUGIN, MODS_SCOPES, type ModsScope, modsPluginState } from "../lib/hooks/mods"; import { nativeDiffForced, nativeDiffState, setNativeDiff } from "../lib/hooks/native-diff"; import { SETTABLE_KEYS, type SetResult, setHooksConfig } from "../lib/hooks/set-config"; import { registerRuleCommands, registerRulePicker, ruleState, shortPath } from "./hooks-rules"; @@ -524,6 +525,8 @@ export function registerHooksCommands(program: Command): void { } }); + registerMods(hooks); + const diffCommand = hooks.command("diff").description("The Bash diff hook"); diffCommand @@ -709,3 +712,104 @@ export function registerHooksCommands(program: Command): void { } }); } + +/** + * `hooks mods`: the Claude Code mods of this repository (the `genesis-tools-mods` plugin). They share the + * hooks file: `mods.fullDiff` switches the full-diff mod, which reads the diff hook's `hook-diffs` notes, and + * `mods.idleCompact` the idle-compaction mod. + */ +function registerMods(hooks: Command): void { + const mods = hooks + .command("mods") + .description( + `Claude Code mods (${MODS_PLUGIN}): status, install, uninstall. Settings: mods.* in the hooks config` + ); + + mods.command("status", { isDefault: true }) + .description("Whether the mods plugin is installed and enabled, and what the hooks config sets for it") + .action(() => { + const list = claudePlugin(["list", "--json"]); + + if (!list.ok) { + ui.err(`claude plugin list failed: ${list.stderr.trim() || list.stdout.trim()}`); + process.exitCode = 1; + return; + } + + const state = modsPluginState(list.stdout); + const config = loadHooksConfig(); + const fullDiff = diffFor(config, "claude").fullDiff; + + ui.raw( + `${pc.bold(MODS_PLUGIN)}: ${state.installed ? (state.enabled ? pc.green("installed, enabled") : pc.yellow("installed, disabled")) : pc.red("not installed")}` + ); + + if (state.installed) { + ui.raw( + pc.dim( + ` scope ${state.scope ?? "?"} Β· version ${state.version ?? "?"} Β· read from ${state.readFrom ?? "its install copy"}` + ) + ); + } + + ui.raw(`mods.fullDiff: ${config.mods.fullDiff ? pc.green("on") : pc.dim("off")}`); + ui.raw( + `mods.idleCompact: ${config.mods.idleCompact ? pc.green("on") : pc.dim("off")}${pc.dim(` Β· after ${config.mods.idleCompactMinutes} min Β· floor ${config.mods.idleCompactMinTokens} tokens`)}` + ); + + if (config.mods.fullDiff && fullDiff === "off") { + ui.raw(pc.yellow(`diff.fullDiff is off, so no call has a full-diff note for the mod to draw.`)); + } + + if (!state.installed) { + ui.raw(`Install: ${toolCommand("agents hooks mods install", "--write")}`); + } + }); + + mods.command("install") + .description("Install the mods plugin from this repository's marketplace") + .option("--scope ", `Where Claude Code records it: ${MODS_SCOPES.join(", ")}`, "user") + .option("--write", "Actually install; without it this is a dry run") + .action((options: { scope: string; write?: boolean }) => { + const scope = MODS_SCOPES.find((candidate): candidate is ModsScope => candidate === options.scope); + + if (!scope) { + ui.raw(suggestEnumFlag("tools agents hooks mods install", "--scope", MODS_SCOPES)); + process.exitCode = 1; + return; + } + + runPluginCommand({ args: ["install", MODS_PLUGIN, "--scope", scope], write: Boolean(options.write) }); + }); + + mods.command("uninstall") + .description("Uninstall the mods plugin") + .option("--write", "Actually uninstall; without it this is a dry run") + .action((options: { write?: boolean }) => { + runPluginCommand({ args: ["uninstall", MODS_PLUGIN], write: Boolean(options.write) }); + }); +} + +function runPluginCommand({ args, write }: { args: string[]; write: boolean }): void { + const shown = `claude plugin ${args.join(" ")}`; + + if (!write) { + ui.raw(`would run: ${shown}`); + ui.raw(pc.yellow("dry run: nothing was changed. Pass --write to apply.")); + return; + } + + const run = claudePlugin(args); + const said = `${run.stdout}${run.stderr}`.trim(); + + if (!run.ok) { + ui.err(`${shown} failed${said ? `: ${said}` : ""}`); + process.exitCode = 1; + return; + } + + ui.raw(said || `${shown}: done`); + ui.raw( + pc.dim("A running Claude Code session picks it up after /reload-plugins; a new session loads it by itself.") + ); +} diff --git a/src/agents/commands/message.ts b/src/agents/commands/message.ts index 3dc8b24e36..d05760d9aa 100644 --- a/src/agents/commands/message.ts +++ b/src/agents/commands/message.ts @@ -15,7 +15,7 @@ import type { AgentRecord, MessageEvent } from "../lib/types"; const log = logger.child({ component: "agents:message" }); -interface MessageOpts { +export interface MessageOpts { from?: string; to?: string; body?: string; @@ -26,6 +26,22 @@ interface MessageOpts { session?: string; } +/** + * The body may also come as positional words (`tools agents message --to x "text"`), the way agents and request + * files keep writing it; before, those words were rejected and only the help printed. Exactly one source counts. + */ +export function withPositionalBody(words: string[], opts: MessageOpts): MessageOpts { + if (words.length === 0) { + return opts; + } + + if (opts.body !== undefined || opts.bodyFile !== undefined) { + throw new FriendlyError("the message text was given twice", "Pass it once: as words, --body or --body-file."); + } + + return { ...opts, body: words.join(" ") }; +} + function resolveBody(opts: MessageOpts): string | undefined { if (opts.bodyFile) { if (opts.body) { @@ -180,6 +196,7 @@ export function registerMessageCommand(program: Command): void { "Send a bus message to agents logged into this bus session (direct, broadcast, reply, or pure ack). " + "To reach a running Claude, Codex or Grok session that is not on the bus, use `tools message `" ) + .argument("[body...]", "Message body, the same as --body") .option("--from ", "Sender agent β€” name or id") .option("--to ", "Recipient agents β€” comma-separated names or ids; empty = broadcast") .option("--body ", "Message body (omit + --reply for pure ack)") @@ -191,7 +208,7 @@ export function registerMessageCommand(program: Command): void { .option("--meta ", "Optional JSON object") .option("--private", "Mark as private (v1: stored, not enforced)") .option("--session ", "Override session resolution") - .action(async (opts: MessageOpts) => { - await runMessage(opts); + .action(async (words: string[], opts: MessageOpts) => { + await runWithFriendlyErrors(async () => runMessageImpl(withPositionalBody(words, opts))); }); } diff --git a/src/agents/lib/hooks/config.ts b/src/agents/lib/hooks/config.ts index 90dbde7448..5486c917b5 100644 --- a/src/agents/lib/hooks/config.ts +++ b/src/agents/lib/hooks/config.ts @@ -58,6 +58,13 @@ export interface DiffConfig { * loses no colour, so there is no raw mode. */ highlight: HighlightMode; + /** + * What paints the syntax colour. `hljs` (default): highlight.js in process, in Claude Code's own dark + * palette (`diff/syntax.ts`): 7.4 ms to load once, then 0.3 ms a call. `bat`: one `bat` process per + * syntax in a message (24.8 ms median) in `bat`'s own theme. A file type `syntax.ts` does not know, and + * every light theme, fall back to `bat`. + */ + highlighter: Highlighter; /** * How far a changed row's band reaches. `block` (default): the widest row of the file, so a pane * made narrower after the message was printed wraps only the rows that are long anyway. `terminal`: @@ -65,6 +72,14 @@ export interface DiffConfig { * narrower every row wraps into an empty band row. `none`: the band ends with the text. */ fill: FillMode; + /** + * The most columns a `block` fill pads a row to, the gutter included; 0 lifts the limit. Without it + * one long changed line widened every row of the file to the pane, and a hook message is fixed text: + * once the pane was narrower than when the message was drawn, every padded row wrapped into an empty + * band row (2026-10-15 11:54: rows of 106 columns, shown in a pane of 76). A row longer than this is + * not padded and its band ends with the text. + */ + fillMax: number; /** * How a block looks; see `diff/theme.ts`. `claude` copies Claude Code's own Bash diff in the * theme Claude Code is set to, so the hook can replace that diff (`bashEditDiffEnabled: false`) @@ -139,6 +154,12 @@ export interface DiffConfig { * costs far more than its visible width. */ maxMessageChars: number; + /** + * How many lines of the call's command head its message, dim, each cut to 80 characters; 0 shows + * none. Claude Code folds a finished call into "ran N shell commands" and leaves only the hook + * message under the fold, so without them nothing says which command changed the files. + */ + commandLines: number; /** * Which KINDS of change are printed. `source` is anything that is not one of the others. * @@ -241,6 +262,27 @@ export interface AgentsTalkConfig { hint: boolean; } +/** + * The Claude Code mods of the `genesis-tools-mods` plugin (`tools agents hooks mods install`). The plugin + * reads this section of the hooks file itself, so a change here needs no reinstall. + */ +export interface ModsConfig { + /** + * Draw the whole diff of a call whose hook message was cut, with Claude Code's own diff element, + * under the call's row. Reads `hook-diffs/.md`, so it needs `diff.fullDiff` not `off`. + */ + fullDiff: boolean; + /** + * Compact an idle Claude session `idleCompactMinutes` after its last turn, before the 1-hour prompt + * cache expires, when the engine's own idle compaction did not (it gives up at its first refused gate, + * such as a rate-limit warning). Skips a context under `idleCompactMinTokens`. + */ + idleCompact: boolean; + /** 1 to 59: after the engine's own try at 54 minutes, before the cache expires at 60. */ + idleCompactMinutes: number; + idleCompactMinTokens: number; +} + /** * The Stop-phase reminder about commits that are not pushed yet. It fires for a branch with an * upstream when MORE than `maxCommits` commits are unpushed, or when the oldest unpushed commit @@ -259,6 +301,7 @@ export interface HooksConfig { diff: DiffConfig; decisions: DecisionsHookConfig; agentsTalk: AgentsTalkConfig; + mods: ModsConfig; unpushed: UnpushedConfig; logPath: string; /** @@ -345,7 +388,9 @@ export const DEFAULT_HOOKS_CONFIG: HooksConfig = { maxCaptureMB: 8, maxCaptureFileMB: 0.256, highlight: "256", + highlighter: "hljs", fill: "block", + fillMax: 72, theme: "claude", columns: "auto", fullDiff: "genesis", @@ -359,6 +404,7 @@ export const DEFAULT_HOOKS_CONFIG: HooksConfig = { dedupeAcrossSessions: true, othersSummary: "line", maxMessageChars: 9_900, + commandLines: 3, categories: { source: true, formatting: true, log: false, generated: false }, harnesses: { // πŸ›‘ OFF for Grok, because Grok cannot show a diff. Observed 2026-09-22 on a real @@ -380,6 +426,7 @@ export const DEFAULT_HOOKS_CONFIG: HooksConfig = { staleness: { warnAfterMinutes: 30, alarmAfterMinutes: 120, notify: true }, }, agentsTalk: { hint: true }, + mods: { fullDiff: true, idleCompact: true, idleCompactMinutes: 56, idleCompactMinTokens: 200_000 }, unpushed: { enabled: true, maxCommits: 3, maxAgeMinutes: 60, remindEveryMinutes: 30 }, logPath: defaultLogPath(), // πŸ›‘ `shadow: true` AND `logCommands: "shadow"` together mean `keepsCommand()` is true out @@ -559,7 +606,15 @@ function mergeDiff(stored: StoredHooksConfig["diff"]): DiffConfig { fallback: base.maxCaptureFileMB, }), highlight: highlightOr(stored?.highlight, base.highlight), + highlighter: HIGHLIGHTERS.find((name) => name === stored?.highlighter) ?? base.highlighter, fill: FILL_MODES.find((mode) => mode === stored?.fill) ?? base.fill, + fillMax: countOr({ field: "diff.fillMax", value: stored?.fillMax, fallback: base.fillMax, min: 0 }), + commandLines: countOr({ + field: "diff.commandLines", + value: stored?.commandLines, + fallback: base.commandLines, + min: 0, + }), theme: themeOr(stored?.theme, base.theme), columns: columnsOr(stored?.columns, base.columns), fullDiff: FULL_DIFF_MODES.find((mode) => mode === stored?.fullDiff) ?? base.fullDiff, @@ -600,6 +655,9 @@ function mergeDiff(stored: StoredHooksConfig["diff"]): DiffConfig { }; } +export const HIGHLIGHTERS = ["hljs", "bat"] as const; +export type Highlighter = (typeof HIGHLIGHTERS)[number]; + export const FILL_MODES = ["block", "terminal", "none"] as const; export type FillMode = (typeof FILL_MODES)[number]; @@ -925,6 +983,7 @@ export function mergeStoredConfig(stored: StoredHooksConfig): HooksConfig { diff: mergeDiff(stored.diff), decisions: mergeDecisions(stored.decisions), agentsTalk: { hint: boolOr(stored.agentsTalk?.hint, DEFAULT_HOOKS_CONFIG.agentsTalk.hint) }, + mods: mergeMods(stored.mods), unpushed: mergeUnpushed(stored.unpushed), shadow: boolOr(stored.shadow, DEFAULT_HOOKS_CONFIG.shadow), logCommands: logCommandsOr(stored.logCommands, DEFAULT_HOOKS_CONFIG.logCommands), @@ -940,6 +999,28 @@ export function mergeStoredConfig(stored: StoredHooksConfig): HooksConfig { const STOP_HOOK_MODES: readonly DecisionStopHook[] = ["off", "warn", "block"]; /** Field by field, like `diff`: a stored value of the wrong type falls back to the default. */ +function mergeMods(stored: Partial | undefined): ModsConfig { + const base = DEFAULT_HOOKS_CONFIG.mods; + const minutes = countOr({ + field: "mods.idleCompactMinutes", + value: stored?.idleCompactMinutes, + fallback: base.idleCompactMinutes, + }); + + return { + fullDiff: boolOr(stored?.fullDiff, base.fullDiff), + idleCompact: boolOr(stored?.idleCompact, base.idleCompact), + // The mod reads the file itself and drops 60 or more the same way. + idleCompactMinutes: minutes < 60 ? minutes : base.idleCompactMinutes, + idleCompactMinTokens: countOr({ + field: "mods.idleCompactMinTokens", + value: stored?.idleCompactMinTokens, + fallback: base.idleCompactMinTokens, + min: 0, + }), + }; +} + function mergeUnpushed(stored: Partial | undefined): UnpushedConfig { const base = DEFAULT_HOOKS_CONFIG.unpushed; diff --git a/src/agents/lib/hooks/diff-log.ts b/src/agents/lib/hooks/diff-log.ts index 6235cd779c..f4a5cba50e 100644 --- a/src/agents/lib/hooks/diff-log.ts +++ b/src/agents/lib/hooks/diff-log.ts @@ -369,33 +369,43 @@ function readChunk( } /** - * Reads `file` from the end until every selected call has its command, a line is older than - * `floor`, or the start of the file. Each read is one fixed chunk. Only lines that name a - * still-wanted call are parsed. + * The calls whose message holds a selected background task's blocks and has not been read yet. + * Only a task that printed something: its flusher's message is then not empty. + */ +function pendingFlushers(calls: Map): Set { + return new Set( + [...calls.values()].flatMap((entry) => + entry.flushedBy && entry.task && entry.decision === "emitted" && !entry.blocks ? [entry.flushedBy] : [] + ) + ); +} + +/** + * Reads `file` from the end until every selected call has its command and every pending flusher + * its message, a line is older than `floor`, or the start of the file. Each read is one fixed + * chunk. Only lines that name a still-wanted call or flusher are parsed. Both sets are shared + * across the session's files, because a subagent's call can flush a task the main transcript ran. */ function hydrateTail({ file, calls, agent, wanted, + flushers, floor, }: { file: string; calls: Map; agent: string | undefined; wanted: Set; + flushers: Set; floor: number; }): { parsed: number; linesSeen: number; widestRead: number } { let parsed = 0; let linesSeen = 0; let widestRead = 0; let fd: number; - // A background task's blocks are in the message of the later call that printed them. - const flushers = new Set( - [...calls.values()].flatMap((entry) => - entry.flushedBy && wanted.has(entry.toolUseId) ? [entry.flushedBy] : [] - ) - ); + const searching = () => wanted.size > 0 || flushers.size > 0; try { fd = openSync(file, "r"); @@ -407,7 +417,7 @@ function hydrateTail({ let end = fstatSync(fd).size; let held: Buffer = Buffer.alloc(0); - while (wanted.size > 0 && end > 0) { + while (searching() && end > 0) { const chunk = readChunk(fd, end, held); held = chunk.held; widestRead = Math.max(widestRead, chunk.read); @@ -435,7 +445,11 @@ function hydrateTail({ parsed += 1; try { - readRow({ row: SafeJSON.parse(line, { strict: true }), calls, agent }); + const printed = readRow({ row: SafeJSON.parse(line, { strict: true }), calls, agent }); + + if (printed) { + flushers.delete(printed); + } } catch { // The line names a selected call and is not one JSON value. } @@ -444,12 +458,12 @@ function hydrateTail({ wanted.delete(id); } - if (wanted.size === 0) { + if (!searching()) { break; } } - if (aged || chunk.done || wanted.size === 0) { + if (aged || chunk.done || !searching()) { break; } @@ -511,8 +525,9 @@ function field(value: unknown, key: string): unknown { /** * Fills the transcript fields of Claude entries in place. The main transcript is read from the - * end, and a subagent file is opened only when a selected call still has no command and the file - * was modified inside the horizon. Returns how much was opened, so a bound can be tested. + * end, and a subagent file is opened only when a selected call still has no command, or a selected + * background task's flusher no message, and the file was modified inside the horizon. Returns how + * much was opened, so a bound can be tested. */ export function extendFromTranscripts(entries: DiffLogEntry[]): { files: number; @@ -546,25 +561,26 @@ export function extendFromTranscripts(entries: DiffLogEntry[]): { } const wanted = new Set(calls.keys()); + const flushers = pendingFlushers(calls); const floor = oldestAt(calls) - TRANSCRIPT_HORIZON_MS; - const mainCounts = hydrateTail({ file: main, calls, agent: undefined, wanted, floor }); + const mainCounts = hydrateTail({ file: main, calls, agent: undefined, wanted, flushers, floor }); files += 1; parsed += mainCounts.parsed; linesSeen += mainCounts.linesSeen; widestRead = Math.max(widestRead, mainCounts.widestRead); - if (wanted.size === 0) { + if (wanted.size === 0 && flushers.size === 0) { continue; } for (const file of recentSubagents(main, floor)) { - const counts = hydrateTail({ file, calls, agent: basename(file, ".jsonl"), wanted, floor }); + const counts = hydrateTail({ file, calls, agent: basename(file, ".jsonl"), wanted, flushers, floor }); files += 1; parsed += counts.parsed; linesSeen += counts.linesSeen; widestRead = Math.max(widestRead, counts.widestRead); - if (wanted.size === 0) { + if (wanted.size === 0 && flushers.size === 0) { break; } } @@ -581,7 +597,7 @@ function readRow({ row: unknown; calls: Map; agent: string | undefined; -}): void { +}): string | undefined { const content = field(field(row, "message"), "content"); for (const item of Array.isArray(content) ? content : []) { @@ -629,14 +645,14 @@ function readRow({ const flushed = [...calls.values()].filter((item) => item.flushedBy === id && item.task && !item.blocks); if (stdout.length === 0 || ((!entry || entry.blocks) && flushed.length === 0)) { - return; + return undefined; } try { const message = text(field(SafeJSON.parse(stdout, { strict: true }), "systemMessage")); if (message.length === 0) { - return; + return undefined; } const read = readMessage(message); @@ -655,10 +671,14 @@ function readRow({ item.notes = part.notes; } } + + return id; } catch { // Another hook's output on the same event. } } + + return undefined; } function localTime(at: string): string { diff --git a/src/agents/lib/hooks/diff/diff.test.ts b/src/agents/lib/hooks/diff/diff.test.ts index 3c549234f5..72d247dd4b 100644 --- a/src/agents/lib/hooks/diff/diff.test.ts +++ b/src/agents/lib/hooks/diff/diff.test.ts @@ -4,8 +4,10 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, + renameSync, rmSync, statSync, symlinkSync, @@ -14,6 +16,7 @@ import { } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, join, relative } from "node:path"; +import { withSpawnCounter } from "@app/benchmark/lib/spawn-counter"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { stripAnsi } from "@genesiscz/utils/string"; @@ -56,13 +59,16 @@ import { captureChunks, capturedBefore, capturedBefores, + commandLines, composePostDiff, type DiffDecision, flushDeferred, noteFileToolInput, + renamesIn, runDiffPost, silentReason, } from "./run"; +import { createSyntaxEngineLoader, languageOf, syntaxRows } from "./syntax"; import { resolvePalette } from "./theme"; import { writerChanges } from "./writers"; @@ -3565,6 +3571,28 @@ describe("the claude theme: Claude Code's own Bash diff, drawn by the hook", () expect(widths[0]).toBeLessThan(40); }); + it("stops a block fill at fillMax, so one long changed row cannot widen every row to the pane", () => { + const long = `-old ${"x".repeat(95)}`; + const patch = ["@@ -1,3 +1,3 @@", long, "-short one", "+short two", " same"].join("\n"); + const rendered = renderPatch(patch, palette); + const block: DiffBlock = { + head: "head", + body: rendered.body, + dress: themedDress({ palette, path: "x.ts", width: 120 }), + }; + const shown = (fillMax: number) => + assembleMessage([block], { ...quiet, fillMax, maxMessageChars: 100_000 }) + .split("\n") + .map((line) => stripAnsi(line)) + .filter((line) => /short (one|two)/.test(line)) + .map((line) => Bun.stringWidth(line)); + + expect(DEFAULT_HOOKS_CONFIG.diff.fillMax).toBe(72); + expect(shown(72)).toEqual([72, 72]); + // 0 lifts the limit: the short rows reach the long row's width, as before. + expect(shown(0).every((width) => width > 100)).toBe(true); + }); + it("wraps a long row under its own content, band and colour carried onto every part", () => { const long = `const message = "${"x".repeat(50)}";`; const patch = ["@@ -1,1 +1,1 @@", "-const message = 1;", `+${long}`].join("\n"); @@ -3780,6 +3808,108 @@ describe("files the harness writes during every call", () => { }); }); +describe("syntax colour from highlight.js", () => { + const palette = resolvePalette("claude-dark"); + + if (!palette) { + throw new Error("claude-dark has a palette"); + } + + it("caches a missing optional language and still loads a different language", () => { + const loads: string[] = []; + const registered = new Set(); + const painter = { + getLanguage: (language: string) => registered.has(language), + registerLanguage: (language: string) => { + registered.add(language); + }, + highlight: () => ({ value: "painted" }), + }; + const load = createSyntaxEngineLoader((name) => { + loads.push(name); + if (name.endsWith("/missing")) { + throw new Error("fixture missing optional language"); + } + return painter; + }); + expect(load("missing")).toBeNull(); + expect(load("missing")).toBeNull(); + expect(load("typescript")).toBe(painter); + expect(loads).toEqual([ + "highlight.js/lib/core", + "highlight.js/lib/languages/missing", + "highlight.js/lib/languages/typescript", + ]); + }); + + it("colours a TypeScript row in Claude Code's own palette", () => { + const rows = syntaxRows({ + text: 'const label = "a"; // note\nreturn 42;', + path: "x.ts", + mode: "truecolor", + }); + + expect(rows).toHaveLength(2); + // const is a storage word, a string and a comment have their own colours. + expect(rows?.[0]).toContain("\u001b[38;2;102;217;239mconst"); + expect(rows?.[0]).toContain('\u001b[38;2;230;219;116m"a"'); + expect(rows?.[0]).toContain("\u001b[38;2;117;113;94m// note"); + // return is a control keyword, 42 a number. + expect(rows?.[1]).toContain("\u001b[38;2;249;38;114mreturn"); + expect(rows?.[1]).toContain("\u001b[38;2;190;132;255m42"); + }); + + it("writes 256-colour codes in the 256 mode, and no truecolor code", () => { + const rows = syntaxRows({ text: "let n = 1;", path: "x.ts", mode: "256" }); + + expect(rows?.[0]).toContain("\u001b[38;5;"); + expect(rows?.[0]).not.toContain("\u001b[38;2;"); + }); + + it("closes a token that spans lines at each line end and opens it again on the next", () => { + const rows = syntaxRows({ text: "/* one\n two */\nlet a = 1;", path: "x.ts", mode: "256" }); + + expect(rows).toHaveLength(3); + expect(stripAnsi(rows?.[1] ?? "")).toBe(" two */"); + // The comment colour (117,113,94) is index 102 in the 256 palette, on both of its lines. + expect(rows?.[0]).toContain("\u001b[38;5;102m"); + expect(rows?.[1]).toContain("\u001b[38;5;102m"); + expect(rows?.[2]).not.toContain("\u001b[38;5;102m"); + }); + + it("keeps the text exactly, markup characters included", () => { + const text = 'if (a < b && c > "d\'s") { return `x${1}`; }'; + const rows = syntaxRows({ text, path: "x.ts", mode: "256" }); + + expect(stripAnsi(rows?.join("\n") ?? "")).toBe(text); + }); + + it("leaves a text to bat when none of its scopes has a colour in the palette", () => { + expect(syntaxRows({ text: "# Title\n\n- a bullet", path: "note.md", mode: "256" })).toBeNull(); + }); + + it("knows its languages by extension or name, and nothing else", () => { + expect(languageOf("a/b.TSX")).toBe("typescript"); + expect(languageOf("Makefile")).toBe("makefile"); + expect(languageOf("notes.txt")).toBeNull(); + expect(syntaxRows({ text: "x", path: "notes.txt", mode: "256" })).toBeNull(); + }); + + it("hands a file type it does not know to bat, and the whole themed block still carries colour", () => { + const rendered = renderPatch(["@@ -1,1 +1,1 @@", "-alpha", "+beta"].join("\n"), palette); + const block: DiffBlock = { + head: "head", + body: rendered.body, + dress: themedDress({ palette, path: "notes.unknownext", width: 0 }), + }; + const message = assembleMessage([block], { ...DEFAULT_HOOKS_CONFIG.diff, maxMessageChars: 100_000 }); + + expect(DEFAULT_HOOKS_CONFIG.diff.highlighter).toBe("hljs"); + expect(stripAnsi(message)).toContain("-alpha"); + expect(stripAnsi(message)).toContain("+beta"); + }); +}); + describe("tools agents hooks diff log", () => { it("reads back what a message showed: each file, its changed lines shown, and the notes", () => { const message = [ @@ -3971,6 +4101,50 @@ describe("tools agents hooks diff log", () => { expect(early.command).toContain("EARLY-COMMAND"); // Each read stays one 64 KiB chunk. Growing the window made the next read 256 KiB. expect(earlyStats.widestRead).toBeLessThanOrEqual(64 * 1024); + + // A background task run in the main transcript and printed by a subagent's later call: + // its blocks are in that subagent's transcript, read after the task's command is found. + const flushedSession = "gt-bound-flushed"; + const now = new Date().toISOString(); + + writeFileSync( + join(project, `${flushedSession}.jsonl`), + `${toolLine("toolu_task", "bun /x/long.ts", now)}\n` + ); + mkdirSync(join(project, flushedSession, "subagents"), { recursive: true }); + + const message = [ + "background task bgflush ended (bun /x/long.ts):", + "Added src/flushed.ts (+1 -0)", + " 1 +flushed", + ].join("\n"); + + writeFileSync( + join(project, flushedSession, "subagents", "agent-flusher.jsonl"), + `${SafeJSON.stringify({ + type: "attachment", + timestamp: now, + attachment: { + type: "hook_success", + hookEvent: "PostToolUse", + toolUseID: "toolu_flusher", + stdout: SafeJSON.stringify({ systemMessage: message }), + }, + })}\n` + ); + + const flushed: DiffLogEntry = { + ...entry, + session: flushedSession, + toolUseId: "toolu_task", + task: "bgflush", + flushedBy: "toolu_flusher", + }; + + extendFromTranscripts([flushed]); + + expect(flushed.command).toContain("bun /x/long.ts"); + expect(flushed.blocks?.map((block) => block.path)).toEqual(["src/flushed.ts"]); }); } finally { rmSync(home, { recursive: true, force: true }); @@ -4159,6 +4333,51 @@ describe("the misfires of 2026-10-14", () => { } }); + it("heads the call's own message with dim command lines, each cut to 80 characters, as diff.commandLines sets", () => { + const target = join(repo, "command-line-probe.ts"); + const long = `sed -i s/a/b/ ${target} # ${"x".repeat(80)}`; + + try { + const quiet = composePostDiff({ payload: begin({ command: "true" }), config: plain }); + + expect(quiet.message).toBe(""); + + const call = begin({ command: `${long}\nsecond line` }); + + writeFileSync(target, "COMMAND-LINE\n"); + + const { message } = composePostDiff({ payload: call, config: plain }); + const [first, second, third] = stripAnsi(message).split("\n"); + + expect(message.slice(0, 6)).toBe("\u001b[2m$ "); + expect(first).toBe(`$ ${long.slice(0, 79)}…`); + expect(second).toBe(" second line"); + expect(third).toMatch(/^Added .*command-line-probe\.ts \(\+1 -0\)$/); + expect( + readMessage(message) + .blocks.map((block) => block.path) + .join(" ") + ).toContain("command-line-probe.ts"); + + const off = { ...plain, diff: { ...plain.diff, commandLines: 0 } }; + const again = begin({ command: long }); + + writeFileSync(target, "COMMAND-LINE\nAGAIN\n"); + + const bare = composePostDiff({ payload: again, config: off }).message; + + expect(stripAnsi(bare)).toMatch(/^Updated .*command-line-probe\.ts \(\+1 -0\)/); + } finally { + rmSync(target, { force: true }); + } + + const heredoc = "bun x.ts <<'EOF'\nbody\nEOF"; + + expect(stripAnsi(commandLines(heredoc, 3))).toBe("$ bun x.ts <<'EOF'\n body\n EOF"); + expect(stripAnsi(commandLines(heredoc, 1))).toBe("$ bun x.ts <<'EOF'\n … 2 more line(s)"); + expect(commandLines(heredoc, 0)).toBe(""); + }); + it("puts the call's own diff before the background tasks it prints, so diff log can split them", () => { const project = `gt-bg-order-${process.pid}`; const tasks = join(tmpdir(), `claude-${process.getuid?.() ?? 0}`, project, "gt-diff-test", "tasks"); @@ -4269,6 +4488,167 @@ describe("the misfires of 2026-10-14", () => { git(["commit", "-qm", "owned done"]); }); + it("pairs one real destination only once when files and status use symlink aliases", () => { + writeFileSync(join(repo, "alias-a.ts"), "identical alias fixture\n"); + writeFileSync(join(repo, "alias-b.ts"), "identical alias fixture\n"); + git(["add", "alias-a.ts", "alias-b.ts"]); + git(["commit", "-qm", "alias sources"]); + const alias = join(repo, "checkout-alias"); + symlinkSync(repo, alias, "dir"); + try { + renameSync(join(repo, "alias-a.ts"), join(repo, "alias-new.ts")); + rmSync(join(repo, "alias-b.ts")); + const pairs = renamesIn({ + root: repo, + base: "HEAD", + files: [ + { path: join(repo, "alias-a.ts"), root: repo, deleted: true, untracked: false }, + { path: join(repo, "alias-b.ts"), root: repo, deleted: true, untracked: false }, + { path: join(alias, "alias-new.ts"), root: repo, deleted: false, untracked: true }, + ], + entries: statusEntries(repo), + }); + expect(pairs).toHaveLength(1); + expect(pairs[0]?.to).toBe(join(realpathSync(repo), "alias-new.ts")); + } finally { + rmSync(alias); + git(["add", "alias-a.ts", "alias-b.ts", "alias-new.ts"]); + git(["commit", "-qm", "alias move done"]); + } + }); + + it("bounds rename directory scans before expansion and shares the remaining candidate budget", async () => { + const dirs = ["budget-a", "budget-b", "budget-c"]; + for (const dir of dirs) { + mkdirSync(join(repo, dir)); + writeFileSync(join(repo, dir, "one.ts"), "one\n"); + writeFileSync(join(repo, dir, "two.ts"), "two\n"); + } + try { + const entries = statusEntries(repo).filter((entry) => entry.path.startsWith("budget-")); + expect(entries).toHaveLength(3); + const gone = (count: number) => + Array.from({ length: count }, (_, index) => ({ + path: join(repo, `deleted-${index}.ts`), + root: repo, + deleted: true, + untracked: false, + })); + const known = ["one.ts", "two.ts"].map((name) => ({ + path: join(repo, "budget-a", name), + root: repo, + deleted: false, + untracked: true, + })); + const overKnown = await withSpawnCounter(async () => + renamesIn({ + root: repo, + base: "HEAD", + files: [...gone(3_999), ...known], + entries, + }) + ); + const overDirectories = await withSpawnCounter(async () => + renamesIn({ + root: repo, + base: "HEAD", + files: gone(3_997), + entries, + }) + ); + const disappeared = await withSpawnCounter(async () => + renamesIn({ + root: repo, + base: "HEAD", + files: gone(3_997), + entries: Array.from({ length: 50 }, (_, index) => ({ + ...entries[0], + path: `budget-missing-${index}/`, + })), + }) + ); + expect(overKnown.result).toEqual([]); + expect(overDirectories.result).toEqual([]); + expect(disappeared.result).toEqual([]); + expect([overKnown.count, overDirectories.count, disappeared.count]).toEqual([0, 2, 3]); + } finally { + for (const dir of dirs) { + rmSync(join(repo, dir), { recursive: true, force: true }); + } + } + }); + + it("pairs a whole folder moved with one mv, past the expansion cap and the old scan limit", () => { + const names = Array.from({ length: 450 }, (_, index) => `note-${String(index).padStart(3, "0")}.md`); + + mkdirSync(join(repo, "moved-src", "inner"), { recursive: true }); + + for (const [index, name] of names.entries()) { + writeFileSync(join(repo, "moved-src", index % 2 === 0 ? name : `inner/${name}`), `body of ${name}\n`); + } + + git(["add", "moved-src"]); + git(["commit", "-qm", "folder to move"]); + + const past = new Date(Date.now() - 3_600_000); + + for (const dir of ["moved-src", "moved-src/inner"]) { + for (const entry of readdirSync(join(repo, dir), { withFileTypes: true })) { + if (entry.isFile()) { + utimesSync(join(repo, dir, entry.name), past, past); + } + } + } + + const current = begin({ command: "mv moved-src moved-dst" }); + + renameSync(join(repo, "moved-src"), join(repo, "moved-dst")); + + const decision = runDiffPost({ payload: current, config: plain }); + const message = decision.message ?? ""; + + try { + expect(message).toContain("450 file(s) moved unchanged"); + expect(message).toContain("moved-src/ β†’ moved-dst/ (450 files)"); + expect(message).not.toContain("Deleted"); + expect(decision.files).toEqual([]); + } finally { + git(["add", "-A", "moved-src", "moved-dst"]); + git(["commit", "-qm", "folder moved"]); + } + }); + + it("shows a single moved file with both paths, and a renamed one too", () => { + const text = "single\n"; + + writeFileSync(join(repo, "single-a.ts"), text); + writeFileSync(join(repo, "single-b.ts"), `${text}two\n`); + git(["add", "single-a.ts", "single-b.ts"]); + git(["commit", "-qm", "singles"]); + mkdirSync(join(repo, "single-dir"), { recursive: true }); + + const past = new Date(Date.now() - 3_600_000); + + utimesSync(join(repo, "single-a.ts"), past, past); + utimesSync(join(repo, "single-b.ts"), past, past); + + const current = begin({ command: "mv single-a.ts single-dir/single-a.ts && mv single-b.ts single-c.ts" }); + + renameSync(join(repo, "single-a.ts"), join(repo, "single-dir", "single-a.ts")); + renameSync(join(repo, "single-b.ts"), join(repo, "single-c.ts")); + + const message = runDiffPost({ payload: current, config: plain }).message ?? ""; + + try { + expect(message).toContain("2 file(s) moved unchanged"); + expect(message).toContain("single-a.ts β†’ single-dir/single-a.ts"); + expect(message).toContain("single-b.ts β†’ single-c.ts"); + } finally { + git(["add", "-A", "single-dir", "single-c.ts", "single-a.ts", "single-b.ts"]); + git(["commit", "-qm", "singles moved"]); + } + }); + it("does not announce a rename another actor owns", () => { writeFileSync(join(repo, "foreign-src.ts"), "moved\nbody\n"); git(["add", "foreign-src.ts"]); diff --git a/src/agents/lib/hooks/diff/render.ts b/src/agents/lib/hooks/diff/render.ts index 61b84240d7..018191d97e 100644 --- a/src/agents/lib/hooks/diff/render.ts +++ b/src/agents/lib/hooks/diff/render.ts @@ -4,6 +4,7 @@ import { env } from "@genesiscz/utils/env"; import type { DiffConfig, FillMode } from "../config"; import type { DiffCategory } from "./classify"; import type { ChangedFile } from "./collect"; +import { syntaxRows } from "./syntax"; import type { Palette } from "./theme"; const DIM = "\u001b[2m"; @@ -597,6 +598,19 @@ function paintThemed(blocks: DiffBlock[], config: DiffConfig): void { // theme, or the default Monokai foregrounds would sit on near-white bands. const theme = first.palette.light ? ["--theme=GitHub"] : []; const input = group.flatMap((block) => block.body.map((line) => line.content ?? "")); + + // highlight.js paints a dark theme in Claude Code's own colours, in process. A language it does + // not have, or a light theme, goes to `bat` below. + const own = + config.highlighter === "hljs" && !first.palette.light + ? syntaxRows({ text: input.join("\n"), path: first.path, mode: config.highlight }) + : null; + + if (own && own.length === input.length) { + assignPainted(group, own); + continue; + } + const run = spawnSync( "bat", [ @@ -620,19 +634,23 @@ function paintThemed(blocks: DiffBlock[], config: DiffConfig): void { continue; } - const rows = run.stdout.split("\n"); - let at = 0; + assignPainted(group, run.stdout.split("\n")); + } +} - for (const block of group) { - if (block.dress) { - block.dress.painted = block.body.map((line, index) => { - const row = rows[at + index]; - return line.kind && row !== undefined ? compactColour(row) : undefined; - }); - } +/** Gives each block of a group its rows of the one painted listing, compacted (`dress.painted`). */ +function assignPainted(group: DiffBlock[], rows: string[]): void { + let at = 0; - at += block.body.length; + for (const block of group) { + if (block.dress) { + block.dress.painted = block.body.map((line, index) => { + const row = rows[at + index]; + return line.kind && row !== undefined ? compactColour(row) : undefined; + }); } + + at += block.body.length; } } @@ -832,12 +850,14 @@ function textLength(text: string): number { /** * The right edge of a `block` fill: the widest kept CHANGED row of the file plus two columns (a context - * row carries no band, and one long wrapped comment made the block as wide as the pane). A hook message - * is fixed text, so a band padded to the terminal's width wrapped into an empty band row under every - * row once the pane got narrower (2026-10-15 01:17: rows of 163 columns, padded for a 170-column pane - * that was 167 columns by then). A block-wide band wraps only where the text itself would. + * row carries no band), and never past `fillMax` columns in all (the gutter is `INDENT` of them). A hook + * message is fixed text, so a band padded to the terminal's width wrapped into an empty band row under + * every row once the pane got narrower (2026-10-15 01:17: rows of 163 columns, padded for a 170-column + * pane that was 167 by then). One long changed line made the block as wide as the pane all the same + * (11:54: 30 rows padded to 106 columns for one 98-column comment, shown in a pane of 76), hence the cap. + * A row past the cap is not padded; its band ends with the text. */ -function blockWidth(body: PatchLine[], held: Set | undefined): number { +function blockWidth(body: PatchLine[], held: Set | undefined, fillMax: number): number { let widest = 0; for (const at of held ?? []) { @@ -848,7 +868,7 @@ function blockWidth(body: PatchLine[], held: Set | undefined): number { } } - return widest + 2; + return fillMax > 0 ? Math.min(widest + 2, Math.max(0, fillMax - INDENT)) : widest + 2; } /** @@ -862,12 +882,14 @@ function dressBlocks({ tinted, budget, fill, + fillMax, }: { blocks: DiffBlock[]; kept: Set[]; tinted: Map[]; budget: number; fill: FillMode; + fillMax: number; }): number { let left = budget; @@ -882,7 +904,7 @@ function dressBlocks({ return; } - const fillTo = fill === "block" ? blockWidth(block.body, kept[index]) : undefined; + const fillTo = fill === "block" ? blockWidth(block.body, kept[index], fillMax) : undefined; for (const at of [...(kept[index] ?? [])].sort((first, second) => first - second)) { const line = block.body[at]; @@ -984,7 +1006,7 @@ export function fitMessage(blocks: DiffBlock[], config: DiffConfig): { message: } } - left = dressBlocks({ blocks, kept, tinted, budget: left, fill: config.fill }); + left = dressBlocks({ blocks, kept, tinted, budget: left, fill: config.fill, fillMax: config.fillMax }); blocks.forEach((block, index) => { const held = kept[index]; diff --git a/src/agents/lib/hooks/diff/run.ts b/src/agents/lib/hooks/diff/run.ts index 76ee9c1abd..fd80528a2a 100644 --- a/src/agents/lib/hooks/diff/run.ts +++ b/src/agents/lib/hooks/diff/run.ts @@ -28,7 +28,7 @@ import { } from "../../changes/log"; import { gitObjectSink } from "../../changes/objects"; import { type DiffConfig, diffFor, type HooksConfig } from "../config"; -import { committedPaths, gitOut, lastHistoryMove, objectId, statusOf } from "../git"; +import { committedPaths, gitOut, lastHistoryMove, objectId, statusOf, untrackedFilesIn } from "../git"; import { hookDiag } from "../log"; import { callDir, safeSegment } from "../paths"; import { type HookPayload, normalizeEvent } from "../payload"; @@ -193,8 +193,41 @@ function siblingNamed({ ownDir, since, actor }: { ownDir: string; since: number; return found; } -/** More files than this in one root, and renames are not looked for: two git spawns cover them all. */ -const RENAME_SCAN_LIMIT = 400; +/** + * More files than this in one root, and renames are not looked for. The git work is a few spawns of + * at most `RENAME_ARGS` paths each, whatever the count; the limit bounds the files hashed (a vault + * folder of 284 notes moved with one `mv` is 568 candidates). + */ +const RENAME_SCAN_LIMIT = 4_000; +const RENAME_ARGS = 400; +/** A file this large is not hashed for pairing: reading it costs more than a moved copy is worth. */ +const RENAME_MAX_BYTES = 4 * 1024 * 1024; + +function inChunks(items: T[], size: number): T[][] { + const chunks: T[][] = []; + + for (let at = 0; at < items.length; at += size) { + chunks.push(items.slice(at, at + size)); + } + + return chunks; +} + +function commonSuffix(first: string, second: string): number { + const left = first.split("/"); + const right = second.split("/"); + let shared = 0; + + while ( + shared < left.length && + shared < right.length && + left[left.length - 1 - shared] === right[right.length - 1 - shared] + ) { + shared += 1; + } + + return shared; +} /** * Files past `maxFiles` whose patch still goes into the full diff, as a multiple of `maxFiles` * (45 at the default 15). Each costs one `git diff` process in the post hook (7.2 ms p50, 11.3 ms @@ -209,7 +242,7 @@ const OVERFLOW_PATCHES_PER_FILE = 3; * fixtures folder, 18 blocks); now one line names them. Two git spawns, only when a root has both * a deletion and another change. */ -function renamesIn({ +export function renamesIn({ root, base, files, @@ -224,32 +257,87 @@ function renamesIn({ to: string; }> { const gone = files.filter((file) => file.deleted); + + if (gone.length === 0) { + return []; + } + // From `git status`, not from `files`: `git mv` and `mv` keep the file's old mtime, so the moved // copy is older than the call and the mtime filter behind `files` drops it (2026-10-15: the test - // passed only when the write and the move fell in one second). - const fresh = [ - ...new Set([ - ...files.filter((file) => !file.deleted).map((file) => file.path), - ...entries - .filter((entry) => (entry.kind === "untracked" && !entry.path.endsWith("/")) || entry.index === "A") - .map((entry) => join(root, entry.path)), - ]), - ]; - - if (gone.length === 0 || fresh.length === 0 || fresh.length + gone.length > RENAME_SCAN_LIMIT) { + // passed only when the write and the move fell in one second). A folder moved whole is one + // collapsed `? new-folder/` entry; it is expanded here, past `untrackedExpansionCap`, because the + // cap bounds what is shown and a rename costs no row. + const freshPaths = new Set(); + let remaining = RENAME_SCAN_LIMIT - gone.length; + if (remaining <= 0) { return []; } - const blobs = new Map(); - const listing = gitOut(root, ["ls-tree", "-z", base, "--", ...gone.map((file) => relative(root, file.path))], { - quiet: true, + const addKnown = (path: string): boolean => { + const normalized = canonical(path); + if (!freshPaths.has(normalized)) { + freshPaths.add(normalized); + remaining--; + } + return remaining >= 0; + }; + for (const file of files) { + if (!file.deleted && !addKnown(file.path)) { + return []; + } + } + for (const entry of entries) { + if ( + ((entry.kind === "untracked" && !entry.path.endsWith("/")) || entry.index === "A") && + !addKnown(join(root, entry.path)) + ) { + return []; + } + } + + // Discovery shares one work budget with the known candidates. A stale empty directory costs + // one slot too, so many vanished directories cannot cause unbounded synchronous git spawns. + for (const entry of entries) { + if (entry.kind !== "untracked" || !entry.path.endsWith("/")) { + continue; + } + if (remaining === 0) { + return []; + } + const names = untrackedFilesIn(root, entry.path, remaining + 1); + if (!names || names.length > remaining) { + return []; + } + remaining -= Math.max(1, names.length); + for (const name of names) { + freshPaths.add(canonical(join(root, name))); + } + } + const fresh = [...freshPaths].filter((path) => { + try { + return statSync(path).size <= RENAME_MAX_BYTES; + } catch { + return false; + } }); - for (const entry of listing.split("\0")) { - const match = /^\d+ blob ([0-9a-f]+)\t(.+)$/.exec(entry); + if (fresh.length === 0) { + return []; + } + + const blobs = new Map(); + + for (const chunk of inChunks(gone, RENAME_ARGS)) { + const listing = gitOut(root, ["ls-tree", "-z", base, "--", ...chunk.map((file) => relative(root, file.path))], { + quiet: true, + }); + + for (const entry of listing.split("\0")) { + const match = /^\d+ blob ([0-9a-f]+)\t(.+)$/.exec(entry); - if (match?.[1] && match[2]) { - blobs.set(match[1], join(root, match[2])); + if (match?.[1] && match[2]) { + blobs.set(match[1], [...(blobs.get(match[1]) ?? []), join(root, match[2])]); + } } } @@ -257,17 +345,31 @@ function renamesIn({ return []; } - const hashes = gitOut(root, ["hash-object", "--", ...fresh], { quiet: true }).split("\n"); + const hashes: string[] = []; + + for (const chunk of inChunks(fresh, RENAME_ARGS)) { + const out = gitOut(root, ["hash-object", "--", ...chunk], { quiet: true }).split("\n"); + + hashes.push(...chunk.map((_, index) => out[index]?.trim() ?? "")); + } + const pairs: Array<{ from: string; to: string }> = []; fresh.forEach((path, index) => { - const hash = hashes[index]?.trim() ?? ""; - const from = blobs.get(hash); + const candidates = blobs.get(hashes[index] ?? ""); - if (from) { - pairs.push({ from, to: path }); - blobs.delete(hash); + if (!candidates || candidates.length === 0) { + return; } + + // Files with the same content (empty placeholders, copies) pair with the deletion that shares + // the most trailing path with the new name, so a folder move keeps each file in its own place. + const best = candidates.reduce((chosen, candidate) => + commonSuffix(candidate, path) > commonSuffix(chosen, path) ? candidate : chosen + ); + + pairs.push({ from: best, to: path }); + candidates.splice(candidates.indexOf(best), 1); }); return pairs; @@ -292,11 +394,51 @@ function ownedRename( return fromKind !== "others" && fromKind !== "ambiguous" && toKind !== "others" && toKind !== "ambiguous"; } +/** + * The note for files that moved unchanged. Two or more files that moved from one folder to one + * other with their names kept are one entry, `old/ β†’ new/ (284 files)`; a lone file shows both paths. + */ function renameLine(root: string, pairs: Array<{ from: string; to: string }>): string { - const shown = pairs.slice(0, 3).map((pair) => `${relative(root, pair.from)} β†’ ${relative(root, pair.to)}`); - const more = pairs.length > 3 ? ` and ${pairs.length - 3} more` : ""; + const prefix = (path: string, shared: number): string => { + const parts = path.split("/"); + + return parts.slice(0, parts.length - shared).join("/") || "."; + }; + const moves = pairs.map((pair) => { + const from = relative(root, pair.from); + const to = relative(root, pair.to); + const shared = commonSuffix(from, to); + + // A renamed file shares no trailing segment; a moved one shares at least its name. + return { from, to, key: shared >= 1 ? `${prefix(from, shared)}/ β†’ ${prefix(to, shared)}/` : null }; + }); + const folders = new Map(); + + for (const move of moves) { + if (move.key) { + folders.set(move.key, (folders.get(move.key) ?? 0) + 1); + } + } + + const entries: string[] = []; + const named = new Set(); - return `${pairs.length} file(s) moved unchanged in ${basename(root)}: ${shown.join(", ")}${more}`; + for (const move of moves) { + const count = move.key ? (folders.get(move.key) ?? 0) : 0; + + if (move.key && count >= 2) { + if (!named.has(move.key)) { + named.add(move.key); + entries.push(`${move.key} (${count} files)`); + } + } else { + entries.push(`${move.from} β†’ ${move.to}`); + } + } + + const more = entries.length > 3 ? ` and ${entries.length - 3} more` : ""; + + return `${pairs.length} file(s) moved unchanged in ${basename(root)}: ${entries.slice(0, 3).join(", ")}${more}`; } /** @@ -422,6 +564,40 @@ function fitDecision(decision: DiffDecision, room: number | undefined): DiffDeci return { ...decision, message, ...(stats ? { stats } : {}) }; } +/** The most characters of one command line that `commandLines` shows, the "…" included. */ +const COMMAND_CHARS = 80; + +/** + * The dim lines above a call's own message: the first `count` lines of its command, each cut to + * `COMMAND_CHARS`, then how many lines were left out (`diff.commandLines`; 0 shows none). Claude Code + * folds a finished call into "ran N shell commands" and leaves only the hook message under the fold, + * so without them nothing says which command changed the files. + */ +export function commandLines(command: string, count: number): string { + const lines = command + .trim() + .split("\n") + .map((line) => line.replace(/\p{Cc}/gu, " ").trimEnd()); + + if (count <= 0 || !lines[0]) { + return ""; + } + + const shown = lines.slice(0, count).map((line) => { + const chars = [...line]; + + return chars.length > COMMAND_CHARS ? `${chars.slice(0, COMMAND_CHARS - 1).join("")}…` : line; + }); + const left = lines.length - shown.length; + const rows = [ + `$ ${shown[0]}`, + ...shown.slice(1).map((line) => ` ${line}`), + ...(left > 0 ? [` … ${left} more line(s)`] : []), + ]; + + return rows.map((row) => `\u001b[2m${row}\u001b[0m`).join("\n"); +} + /** Headings plus messages, in the order `composePostDiff` joins them after the call's own diff. */ export function deferredMessageParts(flushed: DiffDecision[]): string[] { return flushed.flatMap((item) => @@ -449,11 +625,11 @@ export function composePostDiff({ const flushedParts = deferredMessageParts(flushed); // `+ 2` per part is the `\n\n` between parts, including the one after this call's own diff. const flushedChars = flushedParts.reduce((total, part) => total + part.length + 2, 0); - const decision = fitDecision( - runDiffPost({ payload, config, record, maxChars: Math.max(0, maxChars - flushedChars) }), - Math.max(0, maxChars - flushedChars) - ); - const message = [...(decision.message ? [decision.message] : []), ...flushedParts].join("\n\n"); + const heading = commandLines(payload.command, diffFor(config, payload.harness).commandLines); + const room = Math.max(0, maxChars - flushedChars - (heading ? heading.length + 1 : 0)); + const decision = fitDecision(runDiffPost({ payload, config, record, maxChars: room }), room); + const own = decision.message ? [heading ? `${heading}\n${decision.message}` : decision.message] : []; + const message = [...own, ...flushedParts].join("\n\n"); return { flushed, decision, message }; } diff --git a/src/agents/lib/hooks/diff/syntax.ts b/src/agents/lib/hooks/diff/syntax.ts new file mode 100644 index 0000000000..adc4a6c05f --- /dev/null +++ b/src/agents/lib/hooks/diff/syntax.ts @@ -0,0 +1,284 @@ +import { basename } from "node:path"; +import { hookDiag } from "../log"; + +type Rgb = readonly [number, number, number]; + +/** + * Claude Code's dark syntax palette ("Monokai Extended"), read from the diff module of the 2.1.296 + * binary on 2026-10-15: highlight.js scope names, truecolor. A scope that is not here takes the nearest + * ancestor that is, by its dotted prefix (`title.function` falls back to `title`), and one with no + * ancestor keeps the text's default colour. + */ +const PALETTE: Record = { + keyword: [249, 38, 114], + operator: [249, 38, 114], + built_in: [166, 226, 46], + type: [166, 226, 46], + title: [166, 226, 46], + "title.function": [166, 226, 46], + "title.class": [166, 226, 46], + attr: [166, 226, 46], + attribute: [166, 226, 46], + literal: [190, 132, 255], + number: [190, 132, 255], + symbol: [190, 132, 255], + string: [230, 219, 116], + regexp: [230, 219, 116], + params: [253, 151, 31], + comment: [117, 113, 94], + meta: [117, 113, 94], + variable: [255, 255, 255], + property: [255, 255, 255], + punctuation: [248, 248, 242], + subst: [248, 248, 242], +}; + +/** A `keyword` token with one of these words is a storage word, not a control keyword. */ +const STORAGE_WORDS = new Set([ + "const", + "let", + "var", + "function", + "class", + "type", + "interface", + "enum", + "namespace", + "module", + "def", + "fn", + "func", + "struct", + "trait", + "impl", +]); +const STORAGE: Rgb = [102, 217, 239]; + +/** The highlight.js language module for a file extension. Every module named here exists in highlight.js 11. */ +const BY_EXTENSION: Record = { + ts: "typescript", + tsx: "typescript", + mts: "typescript", + cts: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + json: "json", + jsonc: "json", + md: "markdown", + markdown: "markdown", + swift: "swift", + py: "python", + sh: "bash", + bash: "bash", + zsh: "bash", + css: "css", + scss: "scss", + less: "less", + html: "xml", + xml: "xml", + svg: "xml", + plist: "xml", + yml: "yaml", + yaml: "yaml", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + kts: "kotlin", + rb: "ruby", + php: "php", + sql: "sql", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + hpp: "cpp", + cs: "csharp", + m: "objectivec", + mm: "objectivec", + lua: "lua", + graphql: "graphql", + gql: "graphql", + toml: "ini", + ini: "ini", + diff: "diff", + patch: "diff", +}; +const BY_NAME: Record = { Dockerfile: "dockerfile", Makefile: "makefile" }; + +/** The highlight.js language of a path, or `null` when this painter does not know it (`bat` then paints). */ +export function languageOf(path: string): string | null { + const name = basename(path); + const named = BY_NAME[name]; + + if (named) { + return named; + } + + const dot = name.lastIndexOf("."); + + return dot > 0 ? (BY_EXTENSION[name.slice(dot + 1).toLowerCase()] ?? null) : null; +} + +interface Engine { + highlight(code: string, options: { language: string; ignoreIllegals: boolean }): { value: string }; + registerLanguage(name: string, language: unknown): void; + getLanguage(name: string): unknown; +} + +/** Missing optional modules fall back once, without repeated loads or diagnostics. */ +export function createSyntaxEngineLoader( + load: (name: string) => unknown = require +): (language: string) => Engine | null { + let engine: Engine | undefined; + const failed = new Set(); + return (language) => { + if (failed.has(language)) { + return null; + } + try { + engine ??= load("highlight.js/lib/core") as Engine; + if (!engine) { + throw new Error("highlight.js did not load"); + } + if (!engine.getLanguage(language)) { + engine.registerLanguage(language, load(`highlight.js/lib/languages/${language}`)); + } + return engine; + } catch (error) { + failed.add(language); + hookDiag("highlight.js language could not load, so bat does", { err: error, language }); + return null; + } + }; +} + +const engineFor = createSyntaxEngineLoader(); + +/** The xterm 256-colour index nearest to a colour: the grey ramp for greys, else the 6x6x6 cube. */ +function nearest256([red, green, blue]: Rgb): number { + if (red === green && green === blue) { + if (red < 8) { + return 16; + } + + return red > 248 ? 231 : Math.round(((red - 8) / 247) * 24) + 232; + } + + const level = (value: number) => Math.round((value / 255) * 5); + + return 16 + 36 * level(red) + 6 * level(green) + level(blue); +} + +function colourCode(colour: Rgb, mode: "256" | "truecolor"): string { + return mode === "truecolor" + ? `\u001b[38;2;${colour[0]};${colour[1]};${colour[2]}m` + : `\u001b[38;5;${nearest256(colour)}m`; +} + +const ENTITIES: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", +}; + +function decode(text: string): string { + return text.replace(/&(?:amp|lt|gt|quot|#x27);/g, (entity) => ENTITIES[entity] ?? entity); +} + +/** + * `hljs-title function_` is the scope `title.function`; `hljs-keyword` is `keyword`. The first class names + * the scope and a later one ending in an underscore is its sub-scope. + */ +function scopeOf(classes: string): string { + const [first = "", ...rest] = classes.split(" "); + const base = first.startsWith("hljs-") ? first.slice(5) : first; + const sub = rest.find((name) => name.endsWith("_")); + + return sub ? `${base}.${sub.slice(0, -1)}` : base; +} + +function paletteColour(scope: string): Rgb | undefined { + return PALETTE[scope] ?? PALETTE[scope.split(".")[0] ?? ""]; +} + +const TOKENS = /|<\/span>|[^<]+/g; + +/** + * One syntax-coloured string per line of `text`, in the colours of Claude Code's own diff, or `null` when + * the language is not one this painter has, highlight.js fails, or none of its scopes has a colour in this + * palette (Markdown headings and bullets are not among the 24: `bat` colours them, so `bat` paints). Colours + * are foreground only, each run + * closed with `ESC[0m`; the caller runs the row through `compactColour`. A token that spans lines (a block + * comment) is closed at the line end and opened again on the next line. + */ +export function syntaxRows({ + text, + path, + mode, +}: { + text: string; + path: string; + mode: "256" | "truecolor"; +}): string[] | null { + const language = languageOf(path); + + if (!language) { + return null; + } + + try { + const painter = engineFor(language); + if (!painter) { + return null; + } + const html = painter.highlight(text, { language, ignoreIllegals: true }).value; + const stack: Array<{ scope: string; colour: Rgb | undefined }> = []; + const rows: string[] = []; + let row = ""; + + for (const token of html.matchAll(TOKENS)) { + if (token[1] !== undefined) { + const scope = scopeOf(token[1]); + + stack.push({ scope, colour: paletteColour(scope) ?? stack.at(-1)?.colour }); + continue; + } + + if (token[0] === "") { + stack.pop(); + continue; + } + + const top = stack.at(-1); + + token[0].split("\n").forEach((piece, index) => { + if (index > 0) { + rows.push(row); + row = ""; + } + + if (piece.length === 0) { + return; + } + + const shown = decode(piece); + const colour = top?.scope === "keyword" && STORAGE_WORDS.has(shown) ? STORAGE : top?.colour; + + row += colour ? `${colourCode(colour, mode)}${shown}\u001b[0m` : shown; + }); + } + + rows.push(row); + + return rows.some((painted) => painted.includes("\u001b[")) ? rows : null; + } catch (err) { + hookDiag("highlight.js could not colour a diff, so bat does", { err, path }); + + return null; + } +} diff --git a/src/agents/lib/hooks/hooks.test.ts b/src/agents/lib/hooks/hooks.test.ts index 48916722d5..6eb9d2a25a 100644 --- a/src/agents/lib/hooks/hooks.test.ts +++ b/src/agents/lib/hooks/hooks.test.ts @@ -54,6 +54,7 @@ import { wiringStatus, } from "./install"; import { logDecision, releaseRotationLock, setDiagLogPath, setMaxLogBytes, takeRotationLock } from "./log"; +import { MODS_PLUGIN, modsPluginState } from "./mods"; import { nativeDiffState } from "./native-diff"; import { matchesGlob, resolveOutcome } from "./outcome"; import { callDir, hookDataRoot, safeSegment, sessionDir } from "./paths"; @@ -633,6 +634,56 @@ describe("applySetting", () => { expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "rules.find-from-root", "loud")).toThrow("takes one of"); }); + it("diff.commandLines and mods.fullDiff are set, checked and loaded", () => { + expect(DEFAULT_HOOKS_CONFIG.diff.commandLines).toBe(3); + expect(DEFAULT_HOOKS_CONFIG.mods.fullDiff).toBe(true); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "diff.commandLines", "0").diff.commandLines).toBe(0); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.fullDiff", "false").mods.fullDiff).toBe(false); + expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "diff.commandLines", "-1")).toThrow(); + expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "mods.fullDiff", "off")).toThrow("true or false"); + + const path = join(mkdtempSync(join(tmpdir(), "gt-cfg-mods-")), "hooks.json"); + + writeFileSync(path, SafeJSON.stringify({ diff: { commandLines: 5 }, mods: { fullDiff: false } })); + expect(loadHooksConfig(path).diff.commandLines).toBe(5); + expect(loadHooksConfig(path).mods.fullDiff).toBe(false); + }); + + it("mods.idleCompact* are set, checked and loaded", () => { + expect(DEFAULT_HOOKS_CONFIG.mods.idleCompactMinutes).toBe(56); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompact", "false").mods.idleCompact).toBe(false); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinutes", "50").mods.idleCompactMinutes).toBe(50); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinTokens", "0").mods.idleCompactMinTokens).toBe(0); + expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinutes", "60")).toThrow("1 to 59"); + + const path = join(mkdtempSync(join(tmpdir(), "gt-cfg-idle-")), "hooks.json"); + + writeFileSync(path, SafeJSON.stringify({ mods: { idleCompactMinutes: 75, idleCompactMinTokens: 1000 } })); + expect(loadHooksConfig(path).mods).toEqual({ + fullDiff: true, + idleCompact: true, + idleCompactMinutes: 56, + idleCompactMinTokens: 1000, + }); + }); + + it("reads the mods plugin's row from claude plugin list --json", () => { + const listed = SafeJSON.stringify([ + { id: "other@somewhere", enabled: true }, + { id: MODS_PLUGIN, enabled: false, scope: "user", version: "1.0.0", readFromFolder: "/repo/plugins/x" }, + ]); + + expect(modsPluginState(listed)).toEqual({ + installed: true, + enabled: false, + scope: "user", + version: "1.0.0", + readFrom: "/repo/plugins/x", + }); + expect(modsPluginState("[]")).toEqual({ installed: false, enabled: false }); + expect(modsPluginState("not json")).toEqual({ installed: false, enabled: false }); + }); + it("agentsTalk.hint is a boolean switch that ships on and survives a load", () => { expect(DEFAULT_HOOKS_CONFIG.agentsTalk.hint).toBe(true); expect(applySetting(DEFAULT_HOOKS_CONFIG, "agentsTalk.hint", "false").agentsTalk.hint).toBe(false); diff --git a/src/agents/lib/hooks/mods.ts b/src/agents/lib/hooks/mods.ts new file mode 100644 index 0000000000..243dd9117b --- /dev/null +++ b/src/agents/lib/hooks/mods.ts @@ -0,0 +1,74 @@ +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger } from "@genesiscz/utils/logger"; + +/** + * The plugin of Claude Code mods in this repository's marketplace (`plugins/genesis-tools-mods/`). The + * marketplace is a folder, so an installed copy is read from the checkout and an edit needs only + * `/reload-plugins`, never a reinstall. + */ +export const MODS_PLUGIN = "genesis-tools-mods@genesis-tools"; + +export const MODS_SCOPES = ["user", "project", "local"] as const; +export type ModsScope = (typeof MODS_SCOPES)[number]; + +export interface ModsPluginState { + installed: boolean; + enabled: boolean; + scope?: string; + version?: string; + /** The folder Claude Code reads it from (a folder marketplace), when it says. */ + readFrom?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** The mods plugin's row in `claude plugin list --json`, which is an array of installed plugins. */ +export function modsPluginState(listJson: string): ModsPluginState { + let rows: unknown; + + try { + rows = SafeJSON.parse(listJson, { strict: true }); + } catch (err) { + logger.debug({ err }, "claude plugin list --json did not parse"); + return { installed: false, enabled: false }; + } + + const row: unknown = Array.isArray(rows) + ? rows.find((item: unknown) => isRecord(item) && item.id === MODS_PLUGIN) + : undefined; + + if (!isRecord(row)) { + return { installed: false, enabled: false }; + } + + const field = (key: string): unknown => row[key]; + const text = (key: string): string | undefined => { + const value = field(key); + return typeof value === "string" ? value : undefined; + }; + + return { + installed: true, + enabled: field("enabled") === true, + scope: text("scope"), + version: text("version"), + readFrom: text("readFromFolder"), + }; +} + +export interface ClaudePluginRun { + ok: boolean; + stdout: string; + stderr: string; +} + +/** `claude plugin `, waited for; the caller reads `ok` and the output. */ +export function claudePlugin(args: string[]): ClaudePluginRun { + const command = ["claude", "plugin", ...args]; + logger.debug({ command }, "Running the Claude Code plugin command"); + const run = Bun.spawnSync(command, { stdout: "pipe", stderr: "pipe" }); + + return { ok: run.exitCode === 0, stdout: run.stdout.toString(), stderr: run.stderr.toString() }; +} diff --git a/src/agents/lib/hooks/set-config.ts b/src/agents/lib/hooks/set-config.ts index 215d4cbf59..8e6f735b86 100644 --- a/src/agents/lib/hooks/set-config.ts +++ b/src/agents/lib/hooks/set-config.ts @@ -4,6 +4,7 @@ import { FILL_MODES, type HarnessName, HIGHLIGHT_MODES, + HIGHLIGHTERS, type HookOutcome, type HooksConfig, hooksConfigPath, @@ -43,7 +44,10 @@ export const SETTABLE_KEYS = [ "diff.othersSummary", "diff.theme", "diff.highlight", + "diff.highlighter", "diff.fill", + "diff.fillMax", + "diff.commandLines", "diff.columns", "diff.fullDiff", "diff.replaceNative", @@ -64,6 +68,8 @@ export const SETTABLE_KEYS = [ "decisions.injectAnswers", "decisions.staleness.", "agentsTalk.hint", + "mods.fullDiff", + "mods.", ] as const; /** One `decisions.*` key. The stop hook takes off|warn|block; `harnesses` a comma list. */ @@ -314,6 +320,17 @@ export function applySetting( ); } + if (key === "diff.highlighter") { + const name = HIGHLIGHTERS.find((candidate) => candidate === value); + + if (!name) { + throw new Error(`${key} takes ${HIGHLIGHTERS.join(", ")}, not ${SafeJSON.stringify(value)}`); + } + + next.diff.highlighter = name; + return next; + } + if (key === "diff.fill") { const mode = FILL_MODES.find((candidate) => candidate === value); @@ -346,6 +363,16 @@ export function applySetting( return next; } + if (key === "diff.fillMax") { + next.diff.fillMax = asCount(key, value, 0); + return next; + } + + if (key === "diff.commandLines") { + next.diff.commandLines = asCount(key, value, 0); + return next; + } + if (key === "diff.fullDiff") { const mode = FULL_DIFF_MODES.find((candidate) => candidate === value); @@ -395,6 +422,27 @@ export function applySetting( return next; } + if (key === "mods.fullDiff" || key === "mods.idleCompact") { + next.mods = { ...config.mods, [key.slice("mods.".length)]: asBoolean(key, value) }; + return next; + } + + if (key === "mods.idleCompactMinutes") { + const minutes = asCount(key, value); + + if (minutes > 59) { + throw new Error(`${key} takes 1 to 59: at 60 the 1-hour prompt cache has expired`); + } + + next.mods = { ...config.mods, idleCompactMinutes: minutes }; + return next; + } + + if (key === "mods.idleCompactMinTokens") { + next.mods = { ...config.mods, idleCompactMinTokens: asCount(key, value, 0) }; + return next; + } + if (key === "maxLogMB") { next.maxLogMB = asMegabytes(key, value); return next; diff --git a/src/agents/tests/request.test.ts b/src/agents/tests/request.test.ts index 2a7529e650..f1b2956c0b 100644 --- a/src/agents/tests/request.test.ts +++ b/src/agents/tests/request.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { env } from "@genesiscz/utils/env"; import { watchFileFeed } from "@genesiscz/utils/fs/file-feed-watcher"; +import { withPositionalBody } from "../commands/message"; import { appendFeed, appendMessage, FeedLogCursor, readFeed } from "../lib/feed"; import { ensureSessionDir, sessionPaths } from "../lib/paths"; import { sendRequest } from "../lib/request"; @@ -154,3 +155,12 @@ describe("agents request", () => { }); }); }); + +describe("agents message body", () => { + test("positional words are the body, and a second source is refused", () => { + expect(withPositionalBody(["hello", "there"], { to: "worker" }).body).toBe("hello there"); + expect(withPositionalBody([], { body: "kept" }).body).toBe("kept"); + expect(() => withPositionalBody(["twice"], { body: "already" })).toThrow("given twice"); + expect(() => withPositionalBody(["twice"], { bodyFile: "/tmp/x" })).toThrow("given twice"); + }); +}); diff --git a/src/ai-spend/lib/fixtures/claude-code-bills.json b/src/ai-spend/lib/fixtures/claude-code-bills.json new file mode 100644 index 0000000000..84ca4ae514 --- /dev/null +++ b/src/ai-spend/lib/fixtures/claude-code-bills.json @@ -0,0 +1,146 @@ +[ + { + "model": "claude-opus-5-5", + "at": "2026-10-11", + "tokens": { + "input": 238, + "output": 67818, + "cacheRead": 21249331, + "cacheWrite": 232053, + "cacheWrite1h": 232053 + }, + "claudeCodeCostUsd": 7.4636022 + }, + { + "model": "claude-opus-5-5", + "at": "2026-10-11", + "tokens": { + "input": 118, + "output": 92109, + "cacheRead": 14891726, + "cacheWrite": 296648, + "cacheWrite1h": 296648 + }, + "claudeCodeCostUsd": 7.194181199999998 + }, + { + "model": "claude-opus-5-5", + "at": "2026-10-12", + "tokens": { + "input": 16, + "output": 9398, + "cacheRead": 428029, + "cacheWrite": 76394, + "cacheWrite1h": 76394 + }, + "claudeCodeCostUsd": 0.8847818 + }, + { + "model": "claude-sonnet-5-5", + "at": "2026-10-06", + "tokens": { + "input": 180, + "output": 110981, + "cacheRead": 21509927, + "cacheWrite": 346133, + "cacheWrite1h": 346133 + }, + "claudeCodeCostUsd": 6.796687400000002 + }, + { + "model": "claude-sonnet-5-5", + "at": "2026-10-11", + "tokens": { + "input": 67, + "output": 17888, + "cacheRead": 7655905, + "cacheWrite": 753726, + "cacheWrite1h": 753726 + }, + "claudeCodeCostUsd": 3.9595085 + }, + { + "model": "claude-sonnet-5-5", + "at": "2026-10-06", + "tokens": { + "input": 37, + "output": 20214, + "cacheRead": 2353059, + "cacheWrite": 156605, + "cacheWrite1h": 156605 + }, + "claudeCodeCostUsd": 1.2992458000000002 + }, + { + "model": "claude-fable-5-1", + "at": "2026-09-15", + "tokens": { + "input": 548, + "output": 35668, + "cacheRead": 3873257, + "cacheWrite": 475608, + "cacheWrite1h": 475608 + }, + "claudeCodeCostUsd": 12.269354250000003 + }, + { + "model": "claude-fable-5-1", + "at": "2026-09-08", + "tokens": { + "input": 454, + "output": 7325, + "cacheRead": 1770609, + "cacheWrite": 128526, + "cacheWrite1h": 128526 + }, + "claudeCodeCostUsd": 3.38396225 + }, + { + "model": "claude-fable-5-1", + "at": "2026-09-15", + "tokens": { + "input": 428, + "output": 6877, + "cacheRead": 1614573, + "cacheWrite": 93271, + "cacheWrite1h": 93271 + }, + "claudeCodeCostUsd": 2.617193250000001 + }, + { + "model": "claude-sonnet-5", + "at": "2026-09-16", + "tokens": { + "input": 36, + "output": 8337, + "cacheRead": 3383028, + "cacheWrite": 214393, + "cacheWrite1h": 214393 + }, + "claudeCodeCostUsd": 1.6176196000000003 + }, + { + "model": "claude-sonnet-5", + "at": "2026-10-01", + "tokens": { + "input": 4, + "output": 1890, + "cacheRead": 203321, + "cacheWrite": 206473, + "cacheWrite1h": 206473 + }, + "claudeCodeCostUsd": 0.8854642 + }, + { + "model": "claude-sonnet-5", + "at": "2026-09-29", + "tokens": { + "input": 10, + "output": 1165, + "cacheRead": 574176, + "cacheWrite": 147605, + "cacheWrite1h": 147605 + }, + "claudeCodeCostUsd": 0.7169251999999999 + } +] diff --git a/src/ai-spend/lib/pricing.test.ts b/src/ai-spend/lib/pricing.test.ts index ac31be9d46..ed43598b2b 100644 --- a/src/ai-spend/lib/pricing.test.ts +++ b/src/ai-spend/lib/pricing.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { byId } from "@genesiscz/utils/ai/catalog"; -import { DEFAULT_PRICING, priceFor, resolvePrice } from "./pricing"; +import claudeCodeBills from "./fixtures/claude-code-bills.json"; +import { costOf, DEFAULT_PRICING, priceFor, resolvePrice } from "./pricing"; import { eventCostEstimate } from "./reports/cost"; import type { SpendEvent } from "./reports/types"; @@ -78,6 +79,23 @@ describe("pricing table", () => { }); }); +describe("Claude Code's own bills", () => { + // Real per-model token sums from 12 local Claude Code sessions (2026-09-08 to 10-12), each next to the costUSD + // Claude Code itself recorded in that session's `cost-state` line. Only rows whose requests all sit in one price + // band and one dated rate were kept, so the sum prices like the requests did. Every row's cache writes are + // one-hour writes, which is what proves the 2x-input rate. Numbers only: no session id, path or content. + test.each(claudeCodeBills.map((bill) => [bill.model, bill.at, bill] as const))( + "%s on %s costs what Claude Code billed", + (model, at, bill) => { + const entry = priceFor(model, DEFAULT_PRICING); + expect(entry).not.toBeNull(); + + const ours = costOf(bill.tokens, resolvePrice(entry!, { at: new Date(at), contextTokens: 0 })); + expect(Math.abs(ours - bill.claudeCodeCostUsd) / bill.claudeCodeCostUsd).toBeLessThan(1e-6); + } + ); +}); + describe("what a recorded cost means", () => { // 1M fresh input, 1M cached, 100K output: grok-4.7 at list is $2 + $0.50 + $0.60. const turn = (overrides: Partial = {}): SpendEvent => ({ diff --git a/src/ai/commands/gate/index.ts b/src/ai/commands/gate/index.ts index 8234db2acb..5fbe3481fd 100644 --- a/src/ai/commands/gate/index.ts +++ b/src/ai/commands/gate/index.ts @@ -8,9 +8,12 @@ import { grantsPath, isGateProvider, listGrants, + listVaultGrants, readAuditTail, requestAccountAccess, revokeGrants, + revokeVaultGrants, + vaultGrantsPath, } from "@genesiscz/utils/ai/gate"; import { suggestCommand, suggestEnumFlag } from "@genesiscz/utils/cli"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; @@ -135,9 +138,10 @@ export function registerGateCommands(program: Command): void { .option("--json", "Machine-readable output") .action((opts: { json?: boolean }) => { const grants = listGrants(); + const vault = listVaultGrants(); if (opts.json) { - out.result(grants); + out.result({ grants, vault }); return; } @@ -156,8 +160,24 @@ export function registerGateCommands(program: Command): void { } out.println(table.toString()); + + renderCliHeader("Vault key", `${vault.length} remembered by GenesisTools.app`); + const vaultTable = createBoxTable(["TOOL", "EXECUTABLE", "SCRIPT", "UNTIL", "BY"]); + + for (const grant of vault) { + vaultTable.push([ + truncateDisplay(grant.tool, 16), + truncateDisplay(grant.executable, 32), + truncateDisplay(grant.script ?? "", 40), + grant.until === null ? "always" : new Date(grant.until).toLocaleString("sv-SE").slice(0, 16), + grant.method, + ]); + } + + out.println(vaultTable.toString()); renderCliSection("Files"); out.println(` ${pc.dim(grantsPath())}`); + out.println(` ${pc.dim(vaultGrantsPath())}`); }); gate.command("revoke [client]") @@ -174,7 +194,10 @@ export function registerGateCommands(program: Command): void { } const removed = await revokeGrants(selector); - out.log.info(`Revoked ${removed} grant${removed === 1 ? "" : "s"}.`); + const vault = await revokeVaultGrants(selector); + out.log.info( + `Revoked ${removed} grant${removed === 1 ? "" : "s"} and ${vault} vault-key approval${vault === 1 ? "" : "s"}.` + ); }); gate.command("audit") diff --git a/src/ai/commands/usage/poll-daemon.ts b/src/ai/commands/usage/poll-daemon.ts index a871174a2a..eb171c7d49 100644 --- a/src/ai/commands/usage/poll-daemon.ts +++ b/src/ai/commands/usage/poll-daemon.ts @@ -17,6 +17,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; import { logger, out } from "@genesiscz/utils/logger"; import { isObject } from "@genesiscz/utils/object"; import { cpuMeta, profiler } from "@genesiscz/utils/profile"; +import { setVaultPurpose } from "@genesiscz/utils/security/vault-purpose"; const ANTHROPIC_SUB = "anthropic-sub"; /** The daemon runner kills this task at 60 s (`timeoutMs` in the task log); the tick keeps 10 s of headroom. */ @@ -161,6 +162,7 @@ async function main(): Promise { beforeMain: `${Math.round((beforeMain.user + beforeMain.system) / 1000)}ms`, })); logger.info("[ai-usage] daemon poll starting"); + setVaultPurpose("refresh the usage of every AI account (the usage daemon, once a minute in the background)"); const dashConfig = await loadDashboardConfig(); const db = new UsageLimitsDb(); diff --git a/src/ai/commands/warmup.ts b/src/ai/commands/warmup.ts index 79ea2242f5..beaa42759a 100644 --- a/src/ai/commands/warmup.ts +++ b/src/ai/commands/warmup.ts @@ -18,9 +18,15 @@ function viaHint(result: WarmupResult): string { } function renderResult(result: WarmupResult): string { - const icon = result.ok ? pc.green("βœ“") : pc.red("βœ—"); - const tail = result.ok ? pc.dim(viaHint(result)) : pc.red(` ${result.error ?? "failed"}`); - return ` ${icon} ${result.accountName} ${pc.dim(`(${providerAliasOf(result.provider)}, ${result.durationMs}ms)`)}${tail}`; + const icon = result.ok ? pc.green("βœ“") : result.skipped ? pc.yellow("–") : pc.red("βœ—"); + const reason = result.error ?? "failed"; + const tail = result.ok + ? pc.dim(viaHint(result)) + : result.skipped + ? pc.yellow(` skipped: ${reason}`) + : pc.red(` ${reason}`); + const line = ` ${icon} ${result.accountName} ${pc.dim(`(${providerAliasOf(result.provider)}, ${result.durationMs}ms)`)}${tail}`; + return result.hint ? `${line}\n ${pc.dim(result.hint)}` : line; } /** @@ -83,9 +89,13 @@ export function registerWarmupCommand(program: Command, opts: { provider?: strin const spinner = flags.json ? undefined : p.spinner(); spinner?.start(`Warming up ${names.length > 0 ? names.join(", ") : "every account"}...`); const results = await warmupAccounts({ store, ...selection }); - const failed = results.filter((r) => !r.ok).length; + const skipped = results.filter((r) => r.skipped).length; + const failed = results.filter((r) => !r.ok && !r.skipped).length; + const skippedNote = skipped > 0 ? `, ${skipped} skipped (no quota left)` : ""; spinner?.stop( - failed === 0 ? `${results.length} account(s) warmed up` : `${failed} of ${results.length} failed` + failed === 0 + ? `${results.length - skipped} account(s) warmed up${skippedNote}` + : `${failed} of ${results.length} failed${skippedNote}` ); if (flags.json) { diff --git a/src/browser-router/lib/launch.ts b/src/browser-router/lib/launch.ts index 5e00814222..5ba7c3bad6 100644 --- a/src/browser-router/lib/launch.ts +++ b/src/browser-router/lib/launch.ts @@ -1,3 +1,4 @@ +import { appendGateAudit } from "@genesiscz/utils/audit/gates"; import { type RouteDecision, RouteError, @@ -107,6 +108,15 @@ export async function openMintedLink( export async function perform(decision: RouteDecision): Promise { if (decision.kind === "run") { if (decision.needsApproval) { + appendGateAudit({ + gate: "link-command", + event: "denied", + check: "approval-card", + detail: "asked outside the app: only GenesisTools.app shows the card", + link: decision.original, + route: decision.routeIndex, + argv: decision.argv, + }); throw new Error(`route ${decision.routeIndex} asks before running ${decision.argv.join(" ")}`); } diff --git a/src/claude/commands/info.test.ts b/src/claude/commands/info.test.ts new file mode 100644 index 0000000000..ee6e755d36 --- /dev/null +++ b/src/claude/commands/info.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { resolveInfoAccount } from "./info"; + +const vaultRead = async (): Promise => { + throw new Error("the vault was opened: the keychain would ask the user for gt-claude"); +}; + +describe("tools claude info: which account", () => { + test("a named account reads names only and never opens the vault", async () => { + const account = await resolveInfoAccount({ + name: "work", + readAccounts: async () => [{ name: "personal" }, { name: "work", label: "max 20x" }], + loadSecrets: vaultRead, + readToken: async () => "tok-work", + }); + + expect(account).toEqual({ name: "work", label: "max 20x" }); + }); + + test("a token on stdin is matched against the stored tokens", async () => { + const account = await resolveInfoAccount({ + name: undefined, + readAccounts: vaultRead, + loadSecrets: async () => ({ + getAccountsByProvider: () => [ + { name: "personal", provider: "anthropic-sub", tokens: { longLivedToken: "tok-personal" } }, + { name: "work", provider: "anthropic-sub", tokens: { longLivedToken: "tok-work" } }, + ], + }), + readToken: async () => "tok-work", + }); + + expect(account?.name).toBe("work"); + }); + + test("no name and no token reads nothing", async () => { + const account = await resolveInfoAccount({ + name: undefined, + readAccounts: vaultRead, + loadSecrets: vaultRead, + readToken: async () => null, + }); + + expect(account).toBeNull(); + }); +}); diff --git a/src/claude/commands/info.ts b/src/claude/commands/info.ts index b5edadb906..3c4bd991d4 100644 --- a/src/claude/commands/info.ts +++ b/src/claude/commands/info.ts @@ -1,8 +1,10 @@ import { AIConfig } from "@genesiscz/utils/ai/AIConfig"; +import { AiConfigStore } from "@genesiscz/utils/ai/config/AiConfigStore"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import type { AIAccountEntry } from "@genesiscz/utils/config/ai.types"; import { SafeJSON } from "@genesiscz/utils/json"; import { out } from "@genesiscz/utils/logger"; +import { withVaultPurpose } from "@genesiscz/utils/security/vault-purpose"; import type { Command } from "commander"; import pc from "picocolors"; import { normalizeLimits } from "../lib/usage/limits"; @@ -34,22 +36,54 @@ async function readStdinToken(): Promise { return token.length > 0 ? token : null; } -async function resolveAccount(name: string | undefined, aiConfig: AIConfig): Promise { - const accounts = aiConfig.getAccountsByProvider("anthropic-sub"); +/** What the payload needs of an account: its name and label, no secret. */ +export interface InfoAccount { + name: string; + label?: string; +} + +interface SecretAccounts { + getAccountsByProvider(provider: "anthropic-sub"): AIAccountEntry[]; +} +/** + * The statusline runs this in every Claude session. With a name it reads the config's account list only: + * `AIConfig.load()` decrypts every account's secrets, which reads the vault's master key from the keychain, and + * macOS then asks the user whether `gt-claude` may use it. Only a token on stdin needs the stored tokens to compare. + */ +export async function resolveInfoAccount({ + name, + readAccounts = readAnthropicAccounts, + loadSecrets = () => AIConfig.load(), + readToken = readStdinToken, +}: { + name: string | undefined; + readAccounts?: () => Promise; + loadSecrets?: () => Promise; + readToken?: () => Promise; +}): Promise { if (name) { - return accounts.find((a) => a.name === name) ?? null; + return (await readAccounts()).find((a) => a.name === name) ?? null; } - const token = await readStdinToken(); + const token = await readToken(); if (!token) { return null; } - return accounts.find((a) => a.tokens.longLivedToken === token) ?? null; + const config = await withVaultPurpose( + "match this Claude session's token to a configured account, for the statusline", + () => loadSecrets() + ); + return config.getAccountsByProvider("anthropic-sub").find((a) => a.tokens.longLivedToken === token) ?? null; +} + +async function readAnthropicAccounts(): Promise { + const store = await AiConfigStore.readOnly(); + return store.data().accounts.filter((a) => a.provider === "anthropic-sub"); } -async function buildPayload(account: AIAccountEntry): Promise { +async function buildPayload(account: InfoAccount): Promise { // Passive cache read only β€” `tools claude info` must never hit the network // (it is called from the statusline on every cache-miss refresh). const cached = await peekSharedUsage(); @@ -105,8 +139,7 @@ export function registerInfoCommand(program: Command): void { process.exit(1); } - const aiConfig = await AIConfig.load(); - const account = await resolveAccount(name, aiConfig); + const account = await resolveInfoAccount({ name }); if (!account) { if (name) { diff --git a/src/codex/lib/tail-events.test.ts b/src/codex/lib/tail-events.test.ts index 3b5b5f1464..b83e783106 100644 --- a/src/codex/lib/tail-events.test.ts +++ b/src/codex/lib/tail-events.test.ts @@ -1,6 +1,21 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; -import { planFollowRead, takeCompleteLines } from "./tail-events"; +import { out } from "@genesiscz/utils/logger"; +import * as adapter from "./adapter"; +import { sessionEventsPath } from "./paths"; +import { type CodexSessionMeta, CodexSessionStore } from "./store"; +import { + countStartedTurns, + followSessionEvents, + numberTurnLine, + planFollowRead, + takeCompleteLines, +} from "./tail-events"; describe("takeCompleteLines", () => { // The regression: a size snapshot cut a record in half, the half failed to @@ -75,7 +90,129 @@ describe("planFollowRead", () => { expect(planFollowRead(40, 5_000)).toEqual({ from: 0, rewound: true }); }); + test("the real follower restarts rendered turn numbers on rewind and preserves them on append", async () => { + const home = mkdtempSync(join(tmpdir(), "gt-codex-tail-rewind-")); + await env.testing.withOverrides({ GENESIS_TOOLS_HOME: home }, async () => { + const started = `${SafeJSON.stringify({ source: "app-server", method: "turn/started", params: { threadId: "t" } })}\n`; + const done = `${SafeJSON.stringify({ source: "app-server", method: "turn/completed", params: { threadId: "t", turn: {} } })}\n`; + const path = sessionEventsPath("rewind"); + mkdirSync(dirname(path), { recursive: true }); + const meta: CodexSessionMeta = { + name: "rewind", + daemonPid: process.pid, + cwd: "/repo", + sandbox: "read-only", + approvalPolicy: "never", + writePolicy: "deny", + status: "running", + agentName: "fixture", + rendezvousSession: "parent", + agentsEnabled: false, + startedAt: "2026-01-01T00:00:00Z", + lastEventAt: "2026-01-01T00:00:00Z", + codexVersion: "fixture", + pendingApprovals: {}, + }; + for (const rewound of [true, false]) { + writeFileSync(path, started.repeat(3)); + let reads = 0; + const output: string[] = []; + const metadata = spyOn(CodexSessionStore.prototype, "readMeta").mockImplementation(() => { + if (++reads === 1) { + writeFileSync(path, (rewound ? "" : started.repeat(3)) + started + done); + return meta; + } + return null; + }); + const print = spyOn(out, "println").mockImplementation((line) => { + output.push(String(line)); + }); + const sleep = spyOn(Bun, "sleep").mockResolvedValue(undefined); + try { + await followSessionEvents("rewind", true); + expect(output).toHaveLength(2); + expect(output[0]).toContain(`turn ${rewound ? 1 : 4} started`); + expect(output[1]).toContain(`turn ${rewound ? 1 : 4} completed`); + } finally { + metadata.mockRestore(); + print.mockRestore(); + sleep.mockRestore(); + } + } + }); + }); + + test("seeds historical turns through bounded reads without formatting skipped output", async () => { + const home = mkdtempSync(join(tmpdir(), "gt-codex-tail-history-")); + await env.testing.withOverrides({ GENESIS_TOOLS_HOME: home }, async () => { + const path = sessionEventsPath("history"); + mkdirSync(dirname(path), { recursive: true }); + const started = SafeJSON.stringify({ method: "turn/started", source: "app-server" }); + const output = SafeJSON.stringify({ + source: "app-server", + method: "item/completed", + params: { item: { type: "commandExecution", aggregatedOutput: "x".repeat(128 * 1024) } }, + }); + const body = `${[started, ...Array.from({ length: 20 }, () => output), started].join("\n")}\n`; + writeFileSync(path, body); + const file = Bun.file(path); + const originalSlice = file.slice.bind(file); + const slices = spyOn(file, "slice").mockImplementation( + (start?: number | string, end?: number | string, type?: string) => { + const begin = typeof start === "number" ? start : 0; + const until = typeof end === "number" ? end : file.size; + const contentType = typeof start === "string" ? start : typeof end === "string" ? end : type; + expect(until - begin).toBeLessThanOrEqual(64 * 1024); + return originalSlice(begin, until, contentType); + } + ); + const files = spyOn(Bun, "file").mockReturnValue(file); + const reads = spyOn(fs, "createReadStream"); + const formatted = spyOn(adapter, "formatStoredEventLine"); + const metadata = spyOn(CodexSessionStore.prototype, "readMeta").mockReturnValue(null); + try { + await followSessionEvents("history", true); + expect(reads).toHaveBeenCalledTimes(1); + expect(reads.mock.calls[0]?.[1]).toMatchObject({ + highWaterMark: 64 * 1024, + end: Buffer.byteLength(body) - 1, + }); + expect(formatted).not.toHaveBeenCalled(); + expect(countStartedTurns(body)).toBe(2); + expect(formatted).not.toHaveBeenCalled(); + } finally { + metadata.mockRestore(); + formatted.mockRestore(); + reads.mockRestore(); + files.mockRestore(); + slices.mockRestore(); + } + }); + }); + test("an emptied file rewinds too", () => { expect(planFollowRead(0, 120)).toEqual({ from: 0, rewound: true }); }); }); + +describe("turn numbers", () => { + test("numbers a Codex turn's start and end lines, continuing after the turns already in the log", () => { + const turns = { n: 2 }; + + expect(numberTurnLine("β–Ά turn ? started", turns)).toBe("β–Ά turn 3 started"); + expect(numberTurnLine("βœ” turn ? completed ($0.0100)", turns)).toBe("βœ” turn 3 completed ($0.0100)"); + expect(numberTurnLine("πŸ”§ command ls", turns)).toBe("πŸ”§ command ls"); + expect(numberTurnLine("β–Ά turn 7 started", turns)).toBe("β–Ά turn 7 started"); + }); + + test("counts the turns a log already started", () => { + const started = SafeJSON.stringify({ source: "app-server", method: "turn/started", params: { threadId: "t" } }); + const done = SafeJSON.stringify({ + source: "app-server", + method: "turn/completed", + params: { threadId: "t", turn: {} }, + }); + + expect(countStartedTurns(`${started}\n${done}\n${started}\n`)).toBe(2); + }); +}); diff --git a/src/codex/lib/tail-events.ts b/src/codex/lib/tail-events.ts index aa938dc33c..5cb369a79e 100644 --- a/src/codex/lib/tail-events.ts +++ b/src/codex/lib/tail-events.ts @@ -1,4 +1,5 @@ -import { existsSync, statSync } from "node:fs"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createInterface } from "node:readline"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger, out } from "@genesiscz/utils/logger"; import { formatStoredEventLine, type StoredCodexEvent } from "./adapter"; @@ -20,7 +21,83 @@ export function takeCompleteLines(buffer: string): { complete: string; rest: str return { complete: buffer.slice(0, cut + 1), rest: buffer.slice(cut + 1) }; } -export function printFollowedChunk(text: string, asEvents: boolean): void { +/** + * Codex's app-server names a turn by id, not by number, so a rendered turn line read "β–Ά turn ? started". The + * follower counts the turns it renders (seeded from the log it skipped) and numbers the start and end lines. + */ +export interface TurnCounter { + n: number; +} + +export function numberTurnLine(line: string, turns: TurnCounter): string { + if (line.startsWith("β–Ά turn ? ")) { + turns.n += 1; + return line.replace("turn ?", `turn ${turns.n}`); + } + + if (line.startsWith("βœ” turn ? ") && turns.n > 0) { + return line.replace("turn ?", `turn ${turns.n}`); + } + + return line; +} + +function isTurnStartLine(rawLine: string): boolean { + if (!rawLine.trim()) { + return false; + } + + try { + const event = SafeJSON.parse(rawLine, { strict: true }) as StoredCodexEvent | null; + return event?.method === "turn/started"; + } catch (err) { + logger.debug({ err, rawLine: rawLine.slice(0, 200) }, "[codex:tail] skipping unparseable event line"); + return false; + } +} + +/** How many turns the skipped part of the log already started, without rendering or retaining output. */ +export function countStartedTurns(text: string): number { + let count = 0; + let from = 0; + while (from < text.length) { + const newline = text.indexOf("\n", from); + const end = newline === -1 ? text.length : newline; + if (isTurnStartLine(text.slice(from, end))) { + count++; + } + from = end + 1; + } + return count; +} + +/** Read only the snapshotted prefix, in 64 KiB blocks, retaining at most one JSONL record. */ +async function countHistoricalTurns(path: string, bytes: number): Promise { + const input = createReadStream(path, { encoding: "utf8", highWaterMark: 64 * 1024, start: 0, end: bytes - 1 }); + const lines = createInterface({ input, crlfDelay: Infinity }); + let failure: Error | undefined; + input.once("error", (error) => { + failure = error; + lines.close(); + }); + let count = 0; + try { + for await (const line of lines) { + if (isTurnStartLine(line)) { + count++; + } + } + if (failure) { + throw failure; + } + return count; + } finally { + lines.close(); + input.destroy(); + } +} + +export function printFollowedChunk(text: string, asEvents: boolean, turns: TurnCounter = { n: 0 }): void { if (!asEvents) { out.print(text); return; @@ -41,7 +118,7 @@ export function printFollowedChunk(text: string, asEvents: boolean): void { const line = formatStoredEventLine(stored); if (line) { - out.println(line); + out.println(numberTurnLine(line, turns)); } } } @@ -78,6 +155,9 @@ export async function followSessionEvents(name: string, asEvents: boolean): Prom const path = sessionEventsPath(name); let offset = existsSync(path) ? statSync(path).size : 0; let carry = ""; + const turns: TurnCounter = { + n: asEvents && offset > 0 ? await countHistoricalTurns(path, offset) : 0, + }; // A streaming decoder, not `.text()` per read: a size snapshot can cut // a multi-byte codepoint in half, and decoding each byte slice on its // own turned every such character into U+FFFD. The decoder holds the @@ -93,6 +173,7 @@ export async function followSessionEvents(name: string, asEvents: boolean): Prom if (plan?.rewound) { logger.debug({ path, size, offset }, "[codex:tail] events file shrank, following from 0"); + turns.n = 0; carry = ""; decoder = new TextDecoder("utf-8"); } @@ -104,7 +185,7 @@ export async function followSessionEvents(name: string, asEvents: boolean): Prom const { complete, rest } = takeCompleteLines(carry + chunk); carry = rest; if (complete) { - printFollowedChunk(complete, asEvents); + printFollowedChunk(complete, asEvents, turns); } offset = size; } @@ -117,7 +198,7 @@ export async function followSessionEvents(name: string, asEvents: boolean): Prom // first, in case the file ends mid-codepoint. const tail = carry + decoder.decode(); if (tail) { - printFollowedChunk(tail, asEvents); + printFollowedChunk(tail, asEvents, turns); } return; } diff --git a/src/grok/README.md b/src/grok/README.md index 871e5f1b1d..2a0e567b68 100644 --- a/src/grok/README.md +++ b/src/grok/README.md @@ -68,13 +68,14 @@ worker isolation remains controlled by the worker launcher below. - **Two session stores.** `tools grok history` / `run --resume` list TUI dirs under `~/.grok/sessions` (and the worker-home copy of that layout). `tools grok sessions` lists headless workers under `~/.genesis-tools/grok/sessions/.meta.json`. Those are not the same inventory. - **TUI resume is not the worker.** Bare `--resume` on `run` launches native `grok --resume` in your normal environment. A query selects an indexed session and launches `grok -r ` with that session's `GROK_HOME`, so a worker-home result is not looked up in a different personal home. Neither calls `runSession` or mutates the parent environment. Worker mode still needs `--name` and `--cwd` with `--resume` absent. - **Session bookkeeping.** The worker uuid and cwd are stored in `~/.genesis-tools/grok/sessions/.meta.json`; `steer` resumes with the identical `--cwd` automatically (grok keys sessions by cwd). -- **Sticky read-only.** The grok CLI forgets `--tools` on every `--resume`; the harness re-arms the read-only allowlist on each steer of a `--readonly` session. `steer --writable` switches the session back to the project jail deliberately. +- **Sticky permission flags.** The grok CLI forgets `--tools` and `--always-approve` on every `--resume`; the harness re-arms the read-only allowlist on each steer of a `--readonly` session, and `--always-approve` on each writable turn. `steer --writable` switches the session back to writable mode deliberately. - **Honest exit codes.** The grok CLI exits 0 even when a turn dies. `tools grok` parses the stream and exits 1 when no terminal `end` event is present. - **One claim per name, one transcript per turn.** `run` reserves the session name with `O_EXCL`, and each turn reserves its own log file the same way, so two concurrent invocations cannot both start turn 1 or truncate each other's transcript. -## Safety model (verified against grok CLI 1.0.3, 2026-08-26) +## Safety model (re-verified against grok CLI 1.0.52, 2026-10-15) -- Default headless mode is a **project jail**: edits and commands inside `--cwd` run without approval; writes outside are blocked with a denial the worker sees. +- A writable turn runs with `--always-approve`. Nobody can answer a permission prompt in a headless turn: without the flag, grok sent a nested `sh -c` or a `;` chain through a 1-6 s gate, and a verdict other than "allow" came back as `cancelled` and ended the whole turn before the command ran. `--allow Bash` does not stop that gate. +- There is **no project jail**. The jail verified on 1.0.3 is gone: on 1.0.52 a write to a path under $HOME was allowed in 0 ms with and without `--always-approve`. Give a writable worker a disposable worktree. - `--readonly` maps to the `--tools read_file,list_dir,grep` allowlist β€” the worker physically lacks edit and terminal tools. - Do not hand-roll bare `grok` flags for safety: `--permission-mode plan` does not restrict headless runs, and `--disallowed-tools` silently keeps `run_terminal_command`. diff --git a/src/grok/lib/worker.isolation.test.ts b/src/grok/lib/worker.isolation.test.ts index 53e44cf93b..eaf3272901 100644 --- a/src/grok/lib/worker.isolation.test.ts +++ b/src/grok/lib/worker.isolation.test.ts @@ -170,6 +170,35 @@ describe("read-only tool restriction", () => { }); }); +/** + * Regression test: 2026-10-15, grok 1.0.52. Without a permission mode a headless + * worker sends some shell commands (a nested `sh -c`, a `;` chain) through a + * gate that takes 1-6 s. Nobody can answer it, and a verdict that is not + * "allow" came back as `permission_resolved: cancelled`, which ended the whole + * turn (`stopReason: cancelled`) before the command ran. `--always-approve` + * resolves the same calls in 0 ms; `--allow Bash` does not (the nested-shell + * floor still applies). The CLI forgets flags on `--resume`, so every writable + * turn must carry it. + */ +describe("headless permission mode", () => { + test("a writable run approves tool calls, because no one can answer a headless prompt", () => { + expect(buildRunArgs({ sessionId: "s-1", model: undefined, readOnly: false }, ["-p", "hi"])).toContain( + "--always-approve" + ); + }); + + test("a resumed writable session is re-armed, because the CLI forgets the flag", () => { + expect(buildSteerArgs({ sessionId: "s-1" }, false, ["-p", "again"])).toContain("--always-approve"); + }); + + test("a read-only session never gets always-approve", () => { + expect(buildRunArgs({ sessionId: "s-1", model: undefined, readOnly: true }, ["-p", "hi"])).not.toContain( + "--always-approve" + ); + expect(buildSteerArgs({ sessionId: "s-1" }, true, ["-p", "again"])).not.toContain("--always-approve"); + }); +}); + describe("interrupted initial launch", () => { test("retries session creation before using resume", () => { const meta = { diff --git a/src/grok/lib/worker.ts b/src/grok/lib/worker.ts index f992a870f0..ec8bf31d68 100644 --- a/src/grok/lib/worker.ts +++ b/src/grok/lib/worker.ts @@ -42,6 +42,24 @@ const SIDE_EFFECT_ENV: Record = { const READ_ONLY_TOOLS = "read_file,list_dir,grep"; +/** + * The tool and permission flags of one turn, re-armed on EVERY turn because the + * grok CLI forgets them on `--resume`. + * + * Read-only: the `--tools` allowlist, so the worker has no edit or terminal tool. + * Writable: `--always-approve`. A headless turn has nobody to answer a permission + * prompt. Without a mode, grok 1.0.52 sends some shell commands (a nested `sh -c`, + * a `;` chain) through a 1-6 s gate, and a verdict other than "allow" resolves as + * `cancelled`, which ends the whole turn before the command runs (2026-10-15, + * bs-521 and bs-94). `--allow Bash` does not help: the nested-shell floor still + * applies. Deny rules still apply under always-approve, and the default mode no + * longer jails edits to `--cwd` anyway (a write to a path under $HOME was allowed + * in 0 ms in both modes). + */ +function permissionArgs(readOnly: boolean): string[] { + return readOnly ? ["--tools", READ_ONLY_TOOLS] : ["--always-approve"]; +} + export type GrokAuthMode = "subscription" | "api-key"; /** @@ -174,18 +192,15 @@ export function buildRunArgs( args.push("-m", session.model); } - if (session.readOnly) { - args.push("--tools", READ_ONLY_TOOLS); - } - - args.push(...contractArgs(session.readOnly, session.surfaces)); + args.push(...permissionArgs(session.readOnly), ...contractArgs(session.readOnly, session.surfaces)); return args; } /** - * Arguments for a resumed turn. `--tools` is re-armed every time because the - * grok CLI drops safety flags on `--resume`; without this a session started - * read-only silently gains write tools from turn 2 onward. + * Arguments for a resumed turn. `permissionArgs` is re-armed every time because + * the grok CLI drops safety flags on `--resume`; without this a session started + * read-only silently gains write tools from turn 2 onward, and a writable one + * loses always-approve. */ export function buildSteerArgs( session: { sessionId: string; surfaces?: WorkerSurfaces }, @@ -193,11 +208,7 @@ export function buildSteerArgs( promptArguments: string[] ): string[] { const args = [...promptArguments, "--resume", session.sessionId]; - if (readOnly) { - args.push("--tools", READ_ONLY_TOOLS); - } - - args.push(...contractArgs(readOnly, session.surfaces)); + args.push(...permissionArgs(readOnly), ...contractArgs(readOnly, session.surfaces)); return args; } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitState.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitState.swift index c815568f70..b83e79d324 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitState.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitState.swift @@ -177,31 +177,44 @@ public struct GitRepoStatus: Equatable, Sendable { let given = treeRoot.standardizedFileURL.path let givenReal = GitWorkingTreeSource.realPath(given) let rootReal = GitWorkingTreeSource.realPath(root.standardizedFileURL.path) + let inside = givenReal + "/" + // Lengths once, and the folder walks cut at the last "/" in UTF-8: `String.count` walks every character, and + // the walks asked it at every level of every entry (117 ms per call for a vault folder with 1,825 entries, + // debug build, 2026-10-14). + let realLength = givenReal.count + let givenBytes = given.utf8.count var files: [String: GitChangeKind] = [:] var folders = Set() var counts: [String: GitLineCount] = [:] for entry in entries { guard let kind = entry.shown else { continue } let real = rootReal + "/" + entry.path - guard real.hasPrefix(givenReal + "/") else { continue } - let spelled = given + real.dropFirst(givenReal.count) + guard real.hasPrefix(inside) else { continue } + let spelled = given + real.dropFirst(realLength) files[spelled] = kind - var folder = (spelled as NSString).deletingLastPathComponent - while folder.count > given.count, folders.insert(folder).inserted { - folder = (folder as NSString).deletingLastPathComponent + var folder = Self.parent(of: spelled[...]) + while let current = folder, current.utf8.count > givenBytes, folders.insert(String(current)).inserted { + folder = Self.parent(of: current) } guard let lines = numstat[entry.path] else { continue } counts[spelled] = lines - var parent = (spelled as NSString).deletingLastPathComponent - while parent.count > given.count { - let sum = counts[parent] ?? GitLineCount(additions: 0, deletions: 0) - counts[parent] = GitLineCount(additions: sum.additions + lines.additions, deletions: sum.deletions + lines.deletions) - parent = (parent as NSString).deletingLastPathComponent + var parent = Self.parent(of: spelled[...]) + while let current = parent, current.utf8.count > givenBytes { + let key = String(current) + let sum = counts[key] ?? GitLineCount(additions: 0, deletions: 0) + counts[key] = GitLineCount(additions: sum.additions + lines.additions, deletions: sum.deletions + lines.deletions) + parent = Self.parent(of: current) } } return GitTreeDecorations(files: files, folders: folders, counts: counts) } + /// Everything before the last "/" (an absolute path's folder), nil when there is none. + private static func parent(of path: Substring) -> Substring? { + guard let slash = path.utf8.lastIndex(of: UInt8(ascii: "/")) else { return nil } + return path[.. Void = { _ in } + public var onDeny: @MainActor () -> Void = {} + + public init(title: String, purpose: String, symbol: String, facts: [ApprovalFact], details: [ApprovalFact], + warning: String?, footnote: String, rememberChoices: [ApprovalRemember], rememberNote: String?, + allowTitle: String, deadline: Date?, identifier: String) { + self.title = title + self.purpose = purpose + self.symbol = symbol + self.facts = facts + self.details = details + self.warning = warning + self.footnote = footnote + self.rememberChoices = rememberChoices + self.rememberNote = rememberNote + self.allowTitle = allowTitle + self.deadline = deadline + self.identifier = identifier + } + + public func allow() { + guard !answered else { return } + answered = true + onAllow(rememberChoices.indices.contains(rememberIndex) ? rememberChoices[rememberIndex] : nil) + } + + public func deny() { + guard !answered else { return } + answered = true + onDeny() + } +} + +/// Laid out like a macOS alert: the app icon with the request's symbol, a short title, the reason as the first line a +/// reader sees, a column of facts, the rest folded, and the buttons on the trailing edge with the default last. +public struct ApprovalDialogView: View { + @ObservedObject var model: ApprovalDialogModel + + public init(model: ApprovalDialogModel) { + self.model = model + } + + public var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 14) { + icon + VStack(alignment: .leading, spacing: 4) { + Text(model.title) + .font(.system(size: 13, weight: .semibold)) + .accessibilityAddTraits(.isHeader) + Text(model.purpose) + .font(.system(size: 12)) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + facts(model.facts) + .padding(.leading, 66) + + GenDisclosure(isExpanded: $model.showsDetails, minHeight: 22, identifier: "\(model.identifier)-details") { + facts(model.details) + .padding(.leading, 18) + .padding(.top, 2) + } label: { + Text("Details") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .padding(.leading, 66) + + if let warning = model.warning { + Label(warning, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 11)) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 66) + .accessibilityIdentifier("\(model.identifier)-warning") + } + + Text(model.footnote) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 66) + + remember + buttons + } + .padding(20) + .frame(width: 480) + // The hosting controller insets its content by the hidden title bar; the strip stays draggable through + // `.titlebarZone()`, so the dialog uses the full height (Martin's screenshot showed an empty band on top). + .ignoresSafeArea() + .accessibilityElement(children: .contain) + .accessibilityIdentifier(model.identifier) + } + + private var icon: some View { + ZStack(alignment: .bottomTrailing) { + Image(nsImage: NSApp?.applicationIconImage ?? NSImage()) + .resizable() + .frame(width: 52, height: 52) + Image(systemName: model.symbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(Circle().fill(Color.orange)) + .overlay(Circle().stroke(Color(nsColor: .windowBackgroundColor), lineWidth: 2)) + .offset(x: 4, y: 4) + } + .frame(width: 56, height: 56, alignment: .topLeading) + .accessibilityHidden(true) + } + + private func facts(_ rows: [ApprovalFact]) -> some View { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 5) { + ForEach(rows) { fact in + GridRow { + Text(fact.label) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + Text(verbatim: fact.value) + .font(fact.monospaced ? .system(size: 11, design: .monospaced) : .system(size: 12)) + .lineLimit(3) + .truncationMode(.middle) + // Without it the grid measured one line and cut a path in the middle while the window had room. + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .help(fact.help ?? fact.value) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + /// Its own row, so the buttons keep their full titles (the first layout cut "Allow with Touch ID" short). + @ViewBuilder + private var remember: some View { + if model.rememberChoices.count > 1 { + HStack(spacing: 8) { + Text("Remember") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Picker("Remember", selection: $model.rememberIndex) { + ForEach(model.rememberChoices.indices, id: \.self) { index in + Text(model.rememberChoices[index].title).tag(index) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + .accessibilityIdentifier("\(model.identifier)-remember") + } + .padding(.leading, 66) + .disabled(model.answered) + .environment(\.controlActiveState, .key) + } else if let note = model.rememberNote { + Label(note, systemImage: "clock.badge.xmark") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(.leading, 66) + } + } + + private var buttons: some View { + HStack(spacing: 8) { + if let deadline = model.deadline { + (Text("Denies itself in ") + Text(timerInterval: Date()...max(Date(), deadline), countsDown: true)) + .font(.system(size: 11).monospacedDigit()) + .foregroundStyle(.tertiary) + .lineLimit(1) + .fixedSize() + } + Spacer(minLength: 8) + Button("Deny", action: model.deny) + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("\(model.identifier)-deny") + Button(model.allowTitle, action: model.allow) + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .fixedSize() + .accessibilityIdentifier("\(model.identifier)-allow") + } + .controlSize(.regular) + .disabled(model.answered) + // The panel is non-activating, so its app is never active while it shows; AppKit then greys the prominent + // button and the main action looks like the others (the permission dialog's live check, 2026-10-10). + .environment(\.controlActiveState, .key) + } +} + +/// One floating panel for one request. It never runs a modal session (a modal loop on the main queue stops every +/// timer queued there, so a deadline never fired), never activates its app, and takes the keyboard only after the +/// user clicks into it: a request that pops up while someone types must not catch a stray Return. +@MainActor +public final class ApprovalPanelPresenter: NSObject, NSWindowDelegate { + private var panel: NSPanel? + private var model: ApprovalDialogModel? + + public override init() {} + + public func present(_ model: ApprovalDialogModel) { + if NSApp == nil { _ = NSApplication.shared } + let controller = NSHostingController(rootView: ApprovalDialogView(model: model).titlebarZone()) + // The panel follows the view's size, so opening Details grows it and closing shrinks it back. + controller.sizingOptions = [.preferredContentSize] + let size = controller.view.fittingSize + let panel = ApprovalPanel( + contentRect: CGRect(origin: .zero, size: size), + styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, defer: false) + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + panel.standardWindowButton(button)?.isHidden = true + } + panel.title = model.title + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.isFloatingPanel = true + // After isFloatingPanel, whose setter resets the level: above the widget's panels, as the permission dialog. + panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 1) + panel.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] + panel.animationBehavior = .alertPanel + panel.contentViewController = controller + panel.delegate = self + panel.setAccessibilityIdentifier(model.identifier) + panel.setFrameOrigin(Self.origin(for: size)) + self.panel = panel + self.model = model + panel.orderFrontRegardless() + } + + public func close() { + panel?.delegate = nil + panel?.orderOut(nil) + panel = nil + } + + /// ⌘W answers the way Deny does. + public func windowShouldClose(_ sender: NSWindow) -> Bool { + model?.deny() + return false + } + + /// Horizontally centred in the upper fifth of the screen under the pointer, where macOS puts an alert. + private static func origin(for size: CGSize) -> CGPoint { + let mouse = NSEvent.mouseLocation + let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } ?? NSScreen.main + let visible = screen?.visibleFrame ?? CGRect(x: 0, y: 0, width: 1440, height: 900) + let top = visible.maxY - visible.height * 0.2 + return CGPoint(x: (visible.midX - size.width / 2).rounded(), y: (top - size.height).rounded()) + } +} + +/// A titled panel that can take the keyboard without activating its app. +private final class ApprovalPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Permissions/GateAudit.swift b/src/macos/GenesisKit/Sources/GenesisKit/Permissions/GateAudit.swift new file mode 100644 index 0000000000..7db064564a --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Permissions/GateAudit.swift @@ -0,0 +1,244 @@ +import Darwin +import Foundation + +/// One audit trail for every gate a person answers: the vault key, an AI account's token, a link that runs a saved +/// command, Touch ID, and the privacy-permission dialogs. `~/.genesis-tools/audit/gates.jsonl` (under +/// `GENESIS_TOOLS_HOME` when set), one JSON object per line, readable only by its owner. Each line names the gate, the +/// event (`prompted`, `allowed`, `denied`, `remembered`, `granted`, `not-now`, `recorded`, ...), the process that wrote +/// it, and everything the gate showed, warnings included, so a decision can be audited later from the file alone +/// (Martin, 2026-10-16: "I want all permission gates to be audited"). Secrets are never written. The TypeScript side +/// writes the same file (`src/utils/audit/gates.ts`). +public enum GateCredentialRedactor { + private static let flags: Set = ["--token", "--api-key", "--key", "--password", "--secret"] + + private static func shortToken(_ words: [String]) -> Bool { + words.contains { ["wakeup", "gt-wakeup"].contains(($0 as NSString).lastPathComponent) || $0.contains("/src/wakeup/") } + && words.contains("daemon") && words.contains("register") + } + + public static func arguments(_ words: [String]) -> [String] { + let short = shortToken(words) + var hideNext = false + return words.map { word in + if hideNext { hideNext = false; return "[REDACTED]" } + let parts = word.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + let flag = parts.first.map(String.init) ?? "" + guard flags.contains(flag) || (short && flag == "-t") else { return headers(word) } + hideNext = parts.count == 1 + return parts.count == 1 ? word : "\(flag)=[REDACTED]" + } + } + + private static let sensitiveQueryNames: Set = [ + "token", "accesstoken", "refreshtoken", "idtoken", "apikey", "key", "password", "passwd", "secret", + "clientsecret", "authorization", "auth", "credential", "credentials", "prompt", "command", "cmd", "argv", "args", + ] + + private static func url(_ raw: String, depth: Int) -> String { + guard var url = URLComponents(string: raw) else { return "[REDACTED URL]" } + if url.user != nil || url.password != nil { url.user = "[REDACTED]"; url.password = nil } + if let items = url.queryItems { + url.queryItems = params(items, depth: depth) + } + // An OAuth implicit-flow callback carries its tokens in the fragment (`#access_token=…`). + if let fragment = url.percentEncodedFragment, fragment.contains("=") { + var pairs = URLComponents() + pairs.percentEncodedQuery = fragment + pairs.queryItems = params(pairs.queryItems ?? [], depth: depth) + url.percentEncodedFragment = pairs.percentEncodedQuery + } + return url.string ?? "[REDACTED URL]" + } + + private static func params(_ items: [URLQueryItem], depth: Int) -> [URLQueryItem] { + items.map { item in + let name = item.name.lowercased().replacingOccurrences(of: "-", with: "").replacingOccurrences(of: "_", with: "") + let value: String? = sensitiveQueryNames.contains(name) ? "[REDACTED]" : item.value.map { text($0, depth: depth + 1) } + return URLQueryItem(name: item.name, value: value) + } + } + + /// Use UTF-16 units to match NSRegularExpression offsets and treat CRLF as two newline units. + private static func headerValueEnd(_ units: [UInt16], from start: Int, outer: UInt16?) -> Int { + func space(_ index: Int) -> Bool { index < units.count && (units[index] == 32 || units[index] == 9) } + func lineEnd(_ index: Int) -> Bool { index >= units.count || units[index] == 13 || units[index] == 10 } + func letter(_ unit: UInt16) -> Bool { (65...90).contains(unit) || (97...122).contains(unit) } + func name(_ unit: UInt16) -> Bool { letter(unit) || (48...57).contains(unit) || [95, 46, 45].contains(unit) } + func boundary(_ index: Int) -> (next: Int, found: Bool) { + var next = index + while space(next) { next += 1 } + if next == index { return (next, false) } + var token = next + while token < units.count && (units[token] == 34 || units[token] == 39) { token += 1 } + var flag = token + if flag < units.count && units[flag] == 45 { + flag += 1 + if flag < units.count && units[flag] == 45 { flag += 1 } + } + let isFlag = flag > token && flag < units.count && letter(units[flag]) + let nameStart = token + while token < units.count && name(units[token]) { token += 1 } + let named = token > nameStart + while space(token) { token += 1 } + return (next, isFlag || (named && token < units.count && units[token] == 58)) + } + var index = start + var previous: UInt16 = 0 + while !lineEnd(index) { + if space(index) { + let run = boundary(index) + if run.found { return index } + index = run.next + continue + } + let quote = units[index] + if quote != 34 && quote != 39 { previous = quote; index += 1; continue } + // A wrapper before a flag/header is complete; a value quote after '=' is still secret. + let next = index + 1 + if outer == quote && index > start && previous != 61 && (lineEnd(next) || boundary(next).found) { + return index + } + var close = next + while !lineEnd(close) && units[close] != quote { + close += units[close] == 92 && !lineEnd(close + 1) ? 2 : 1 + } + if lineEnd(close) { return close } + previous = quote + index = close + 1 + } + return index + } + + private static func redactedHeaderValue(_ name: String, value: String) -> String { + let pattern = try! NSRegularExpression(pattern: #"^([a-z][a-z0-9+.-]*)[ \t]+\S"#, options: .caseInsensitive) + var scheme = "" + if ["authorization", "proxy-authorization"].contains(name.lowercased()), + let match = pattern.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)) { + scheme = String(value[Range(match.range(at: 1), in: value)!]) + " " + } + return scheme + "[REDACTED]" + } + + private static func headers(_ value: String) -> String { + let names = #"(?:Proxy-)?Authorization|[a-z0-9_.-]*(?:key|token|secret|cookie)[a-z0-9_.-]*"# + let exact = try! NSRegularExpression(pattern: #"^([ \t]*)("# + names + #")([ \t]*:[ \t]*)([^\r\n]*)$"#, options: .caseInsensitive) + let range = NSRange(value.startIndex..., in: value) + if let match = exact.firstMatch(in: value, range: range) { + let payload = Range(match.range(at: 4), in: value)! + let name = String(value[Range(match.range(at: 2), in: value)!]) + return String(value[.. 0 ? units[match.range.location - 1] : nil + let wrapper = prefix == 34 || prefix == 39 ? prefix : beforeFlag + let outer = wrapper == 34 || wrapper == 39 ? wrapper : nil + let end = headerValueEnd(units, from: start, outer: outer) + safe += source.substring(with: NSRange(location: copied, length: start - copied)) + + redactedHeaderValue(name, value: source.substring(with: NSRange(location: start, length: end - start))) + copied = end + cursor = end + } + return safe + source.substring(from: copied) + } + + public static func text(_ value: String) -> String { text(value, depth: 0) } + + private static func text(_ value: String, depth: Int) -> String { + guard depth < 8 else { return "[REDACTED]" } + let links = try! NSRegularExpression(pattern: #"\b[a-z][a-z0-9+.-]*://\S+"#, options: .caseInsensitive) + let matches = links.matches(in: value, range: NSRange(value.startIndex..., in: value)) + var safe = value + for match in matches.reversed() { + guard let range = Range(match.range, in: safe) else { continue } + safe.replaceSubrange(range, with: url(String(safe[range]), depth: depth)) + } + let words = safe.components(separatedBy: .whitespacesAndNewlines) + let flags = shortToken(words) ? "--(?:token|api-key|key|password|secret)|-t" : "--(?:token|api-key|key|password|secret)" + let pattern = #"(^|[\s"'])("# + flags + #")(=|\s+)("(?:\\[^\r\n]|[^"\\\r\n])*(?:"|\\?(?=$|[\r\n]))|'(?:\\[^\r\n]|[^'\\\r\n])*(?:'|\\?(?=$|[\r\n]))|[^\s;]+)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return "[REDACTED]" } + let redacted = regex.stringByReplacingMatches(in: safe, range: NSRange(safe.startIndex..., in: safe), + withTemplate: "$1$2$3[REDACTED]") + return headers(redacted) + } + + public static func fields(_ value: Any) -> Any { + if let text = value as? String { return self.text(text) } + if let words = value as? [String] { return arguments(words).map(text) } + if let array = value as? [Any] { return array.map(fields) } + if let object = value as? [String: Any] { return object.mapValues(fields) } + return value + } +} + +public enum GateAudit { + public static var path: String { + let override = ProcessInfo.processInfo.environment["GENESIS_TOOLS_HOME"] + let home = override?.isEmpty == false ? override! : NSHomeDirectory() + return (home as NSString).appendingPathComponent(".genesis-tools/audit/gates.jsonl") + } + + /// O_APPEND makes the single write atomic with other Swift and TypeScript writers, including file creation. + public static func appendLine(_ data: Data, to file: String) { + do { + try FileManager.default.createDirectory(atPath: (file as NSString).deletingLastPathComponent, + withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let fd = open(file, O_WRONLY | O_CREAT | O_APPEND, 0o600) + guard fd >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + defer { close(fd) } + let line = data + Data("\n".utf8) + let written = line.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + guard written == line.count else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + } catch { + GenesisKit.log("gate audit: could not append to \(file): \(error)") + } + } + + /// What an approval window showed: its title, why, every fact and detail with its full value, the warning. + public static func shown(title: String, purpose: String, facts: [ApprovalFact], warning: String?, + onceOnly: String? = nil) -> [String: Any] { + var shown: [String: Any] = [ + "title": title, + "purpose": GateCredentialRedactor.text(purpose), + "facts": facts.map { ["label": $0.label, "value": GateCredentialRedactor.text($0.help ?? $0.value)] }, + ] + if let warning { shown["warning"] = warning } + if let onceOnly { shown["onceOnly"] = onceOnly } + return shown + } + + /// Appends one line. A value that is not JSON (an optional left in, a custom type) is dropped from the line and + /// logged, never thrown: an audit write must not stop a gate. + public static func write(gate: String, event: String, _ fields: [String: Any] = [:]) { + var entry = fields.filter { JSONSerialization.isValidJSONObject(["value": $0.value]) } + if entry.count != fields.count { + GenesisKit.log("gate audit \(gate) \(event): dropped non-JSON fields \(Set(fields.keys).subtracting(entry.keys).sorted())") + } + entry["at"] = ISO8601DateFormatter().string(from: Date()) + entry["gate"] = gate + entry["event"] = event + entry["writer"] = [ + "pid": Int(getpid()), + "app": Bundle.main.bundleIdentifier ?? "", + "executable": Bundle.main.executablePath ?? "", + ] + + guard let data = try? JSONSerialization.data(withJSONObject: GateCredentialRedactor.fields(entry), options: [.sortedKeys]) else { + GenesisKit.log("gate audit \(gate) \(event): the entry is not JSON") + return + } + + appendLine(data, to: path) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Permissions/PermissionCenter.swift b/src/macos/GenesisKit/Sources/GenesisKit/Permissions/PermissionCenter.swift index e3338da349..1199e68d30 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Permissions/PermissionCenter.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Permissions/PermissionCenter.swift @@ -256,6 +256,10 @@ public final class PermissionCenter { let dialog = PermissionDialogModel(need: need, status: status, center: self) dialogs[need.kind] = dialog + GateAudit.write(gate: "privacy-permission", event: "prompted", [ + "kind": need.kind.rawValue, "status": String(describing: status), "reason": need.reason, + "trigger": String(describing: need.trigger), + ]) if let waiting = waitingElsewhere.removeValue(forKey: need.kind) { waiting.poll.cancel() waiting.needs.forEach(dialog.merge) @@ -348,6 +352,10 @@ public final class PermissionCenter { presenter.close(open) let dialog = PermissionDialogModel(need: need, status: status, center: self) dialogs[need.kind] = dialog + GateAudit.write(gate: "privacy-permission", event: "prompted", [ + "kind": need.kind.rawValue, "status": String(describing: status), "reason": need.reason, + "trigger": String(describing: need.trigger), + ]) GenesisKit.log("permission \(need.kind.rawValue) still missing here after its dialog saw the grant; dialog shown again") presenter.present(dialog, takesFocus: need.trigger == .userAction) dialog.startWatching() @@ -360,6 +368,7 @@ public final class PermissionCenter { dialogs[dialog.kind] = nil dialog.didClose() if dismissedByUser { dismissed.insert(dialog.kind) } + GateAudit.write(gate: "privacy-permission", event: dismissedByUser ? "not-now" : "granted", ["kind": dialog.kind.rawValue]) lock?.release(dialog.kind) presenter.close(dialog) GenesisKit.log("permission \(dialog.kind.rawValue) dialog closed (\(dismissedByUser ? "not now" : "granted"))") diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift index 014f54ccf9..efa5e2f35e 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift @@ -426,7 +426,7 @@ final class ClickyTests: XCTestCase { func testUnreadablePreferencesDoNotEraseValidTypingHistory() throws { let suite = "dev.genesis.clicky.persistence.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } var saved = ClickyStatistics() saved.record(keyCode: 0, release: false, at: date(12)) defaults.set(Data("{invalid preferences".utf8), forKey: "clicky.preferences.v1") @@ -450,7 +450,7 @@ final class ClickyTests: XCTestCase { func testUndecodablePreferencesAreKeptBeforeTheFirstSaveReplacesThem() throws { let suite = "dev.genesis.clicky.persistence.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let stored = Data("{\"releaseSounds\": false, \"notifications\": tru".utf8) defaults.set(stored, forKey: "clicky.preferences.v1") let model = ClickyModel(defaults: defaults, previewOnly: true, observeSystemEvents: false) @@ -468,7 +468,7 @@ final class ClickyTests: XCTestCase { func testUndecodableLibraryListIsKeptBeforeAnAddedFolderReplacesIt() throws { let suite = "dev.genesis.clicky.persistence.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let stored = Data("[{\"id\": \"work\", \"displayName\": \"Work sou".utf8) defaults.set(stored, forKey: "clicky.libraries.v1") let library = ClickySoundLibrary(defaults: defaults, bundled: nil) @@ -478,7 +478,7 @@ final class ClickyTests: XCTestCase { XCTAssertEqual(recovery.count, 1) XCTAssertEqual(defaults.data(forKey: try XCTUnwrap(recovery.first)), stored) - defaults.removePersistentDomain(forName: suite) + disposeIntegrationDefaults(defaults, suite: suite) let reopened = ClickySoundLibrary(defaults: defaults, bundled: nil) defer { reopened.stop() } XCTAssertTrue(defaults.dictionaryRepresentation().keys.allSatisfy { !$0.contains(".undecodable.") }) @@ -488,7 +488,7 @@ final class ClickyTests: XCTestCase { func testDecodablePreferencesLeaveNoRecoveryCopy() throws { let suite = "dev.genesis.clicky.persistence.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } var saved = ClickyPreferences() saved.releaseSounds = false defaults.set(try JSONEncoder().encode(saved), forKey: "clicky.preferences.v1") @@ -503,7 +503,7 @@ final class ClickyTests: XCTestCase { func testUnreadableHistoryIsNeverOverwrittenByActivationOrShutdown() throws { let suite = "dev.genesis.clicky.persistence.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let original = Data("{\"presses\":12000,\"minutes\":\"unreadable\"}".utf8) defaults.set(original, forKey: "clicky.statistics.v1") let monitor = InputMonitorStub() @@ -535,7 +535,7 @@ final class ClickyTests: XCTestCase { func testRepeatedResetsKeepOnlyTheNewestRecoveryCopies() throws { let suite = "dev.genesis.clicky.persistence.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let prefix = "clicky.statistics.recovery." defaults.set(Data("from an older build".utf8), forKey: prefix + UUID().uuidString) for round in 1...5 { @@ -554,7 +554,7 @@ final class ClickyTests: XCTestCase { func testHistoryRetryReopensRestoredDataWithoutResettingOrWritingIt() throws { let suite = "dev.genesis.clicky.persistence.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let original = Data("unreadable".utf8) defaults.set(original, forKey: "clicky.statistics.v1") let model = ClickyModel(defaults: defaults, previewOnly: true, observeSystemEvents: false) @@ -583,7 +583,7 @@ final class ClickyTests: XCTestCase { let (model, defaults, suite) = monitorFixture(monitor) defer { model.shutdown() - defaults.removePersistentDomain(forName: suite) + disposeIntegrationDefaults(defaults, suite: suite) } XCTAssertFalse(model.enabled) model.activate() @@ -607,7 +607,7 @@ final class ClickyTests: XCTestCase { let (model, defaults, suite) = monitorFixture(monitor) defer { model.shutdown() - defaults.removePersistentDomain(forName: suite) + disposeIntegrationDefaults(defaults, suite: suite) } model.activate() XCTAssertFalse(model.enabled) @@ -632,7 +632,7 @@ final class ClickyTests: XCTestCase { let (model, defaults, suite) = monitorFixture(monitor) defer { model.shutdown() - defaults.removePersistentDomain(forName: suite) + disposeIntegrationDefaults(defaults, suite: suite) } // "Allow Input Monitoring…" in the privacy note, with Clicky off. model.openInputSettings() @@ -661,7 +661,7 @@ final class ClickyTests: XCTestCase { XCTAssertEqual(monitor.permissionDialogs, result == .permissionRequired ? 1 : 0) XCTAssertEqual(monitor.starts, 1) model.shutdown() - defaults.removePersistentDomain(forName: suite) + disposeIntegrationDefaults(defaults, suite: suite) } } @@ -672,7 +672,7 @@ final class ClickyTests: XCTestCase { let (model, defaults, suite) = monitorFixture(monitor, snapshot: true) defer { model.shutdown() - defaults.removePersistentDomain(forName: suite) + disposeIntegrationDefaults(defaults, suite: suite) } model.activate() XCTAssertFalse(model.enabled) @@ -688,7 +688,7 @@ final class ClickyTests: XCTestCase { let (model, defaults, suite) = monitorFixture(monitor) defer { model.shutdown() - defaults.removePersistentDomain(forName: suite) + disposeIntegrationDefaults(defaults, suite: suite) } model.activate() let event = try XCTUnwrap(CGEvent(source: nil)) @@ -706,7 +706,7 @@ final class ClickyTests: XCTestCase { let (model, defaults, suite) = monitorFixture(monitor) defer { model.shutdown() - defaults.removePersistentDomain(forName: suite) + disposeIntegrationDefaults(defaults, suite: suite) } model.activate() let event = try XCTUnwrap(CGEvent(source: nil)) @@ -730,7 +730,7 @@ final class ClickyTests: XCTestCase { func testSettingsRegistrationPreservesPendingRequestsAndPersistsSelection() { let suite = "dev.genesis.settings.test.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let appearance = NativeSettingsAppearance( defaults: defaults, notificationNamespace: suite, observeExternalChanges: false) let initial = settingsSection("general", pages: ["general"], order: 0) @@ -751,7 +751,7 @@ final class ClickyTests: XCTestCase { func testSettingsRejectDuplicatePageIDsWithoutChangingTheCatalog() { let suite = "dev.genesis.settings.test.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let appearance = NativeSettingsAppearance( defaults: defaults, notificationNamespace: suite, observeExternalChanges: false) let store = NativeSettingsStore( @@ -775,7 +775,7 @@ final class ClickyTests: XCTestCase { func testSettingsOrderPlacesLateRegisteredFeaturesBeforeAbout() { let suite = "dev.genesis.settings.test.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let appearance = NativeSettingsAppearance( defaults: defaults, notificationNamespace: suite, observeExternalChanges: false) let store = NativeSettingsStore( @@ -793,7 +793,7 @@ final class ClickyTests: XCTestCase { func testSharedAppearanceIsDurableIndependentAndMigrationDoesNotOverwrite() { let suite = "dev.genesis.settings.test.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let first = NativeSettingsAppearance( defaults: defaults, notificationNamespace: suite, observeExternalChanges: false) first.migrateIfNeeded(reduceMotion: true, reduceTransparency: false) @@ -819,7 +819,7 @@ final class ClickyTests: XCTestCase { func testThemeChoiceSurvivesIndependentTransparencyOverrides() { let suite = "dev.genesis.settings.test.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let appearance = NativeSettingsAppearance( defaults: defaults, notificationNamespace: suite, observeExternalChanges: false) appearance.theme = .gradient @@ -843,7 +843,7 @@ final class ClickyTests: XCTestCase { func testIndependentAppearanceWritesDoNotOverwriteAnotherInstancesChanges() { let suite = "dev.genesis.settings.test.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let first = NativeSettingsAppearance( defaults: defaults, notificationNamespace: suite, observeExternalChanges: false) let stale = NativeSettingsAppearance( @@ -863,7 +863,7 @@ final class ClickyTests: XCTestCase { func testSharedAppearanceReceivesDistributedChanges() async { let suite = "dev.genesis.settings.test.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let writer = NativeSettingsAppearance(defaults: defaults, notificationNamespace: suite) let reader = NativeSettingsAppearance(defaults: UserDefaults(suiteName: suite)!, notificationNamespace: suite) let changed = expectation(description: "Distributed appearance notification") @@ -880,7 +880,7 @@ final class ClickyTests: XCTestCase { func testClickyLegacyAppearanceAndSharedStoreRemainConnected() async throws { let suite = "dev.genesis.settings.test.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } var legacy = ClickyPreferences() legacy.reduceMotion = true defaults.set(try JSONEncoder().encode(legacy), forKey: "clicky.preferences.v1") @@ -905,7 +905,7 @@ final class ClickyTests: XCTestCase { func testSettingsTaxonomyRetainsOneClickyModelWithoutEnablingInput() { let suite = "dev.genesis.settings.test.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let model = ClickyModel(defaults: defaults, previewOnly: true) let sections = ClickySettingsPages.sections(model: model) XCTAssertEqual(sections.map(\.id), ["general", "clicky", "about"]) @@ -924,7 +924,7 @@ final class ClickyTests: XCTestCase { func testNotificationPermissionRoutesDeniedToSettingsAndRetainsActivationPreference() async { let suite = "dev.genesis.clicky.notifications.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } var authorization: UNAuthorizationStatus = .denied var requests = 0 var settingsOpens = 0 @@ -961,7 +961,7 @@ final class ClickyTests: XCTestCase { func testStaleNotificationReadCannotOverwriteCompletedPermissionRequest() async { let suite = "dev.genesis.clicky.notifications.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let started = AsyncStream.makeStream() var pending: CheckedContinuation? var first = true @@ -992,7 +992,7 @@ final class ClickyTests: XCTestCase { func testNotificationSettingsFailureIsActionable() async { let suite = "dev.genesis.clicky.notifications.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let model = ClickyModel(defaults: defaults, observeSystemEvents: false, notificationClient: NativeNotificationClient(status: { .denied }, request: { XCTFail("Denied authorization must not be requested again") @@ -1038,7 +1038,7 @@ final class ClickyTests: XCTestCase { func testPreviewCannotActivateInputAndStartsOff() { let suite = "dev.genesis.clicky.test.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let model = ClickyModel(defaults: defaults, previewOnly: true) XCTAssertFalse(model.enabled) model.activate() @@ -1544,6 +1544,7 @@ extension ClickyPackTests { return (UserDefaults(suiteName: suite)!, suite) } + @MainActor private func seedLibrary(_ defaults: UserDefaults, root: URL, count: Int = 1) throws -> [ClickyLibraryRecord] { let bookmark = try root.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) @@ -1560,7 +1561,7 @@ extension ClickyPackTests { let fixture = try Fixture() defer { fixture.dispose() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let library = ClickySoundLibrary(defaults: defaults, bundled: fixture.root) defer { library.stop() } XCTAssertEqual(library.records.map(\.id), [ClickySoundLibrary.bundledID]) @@ -1597,7 +1598,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [a, b], prepareStarted: { starts[$0 - 1].fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let record = try XCTUnwrap(seedLibrary(defaults, root: fixture.root).first) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -1635,7 +1636,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [base], prepareStarted: { _ in started.fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let record = try XCTUnwrap(seedLibrary(defaults, root: fixture.root).first) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -1664,7 +1665,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [a, b], prepareStarted: { starts[$0 - 1].fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let record = try XCTUnwrap(seedLibrary(defaults, root: fixture.root).first) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -1718,7 +1719,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [a, b], prepareStarted: { starts[$0 - 1].fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let record = try XCTUnwrap(seedLibrary(defaults, root: fixture.root).first) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -1751,7 +1752,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [pack], catalogueStarted: { _ in started.fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let record = try XCTUnwrap(seedLibrary(defaults, root: fixture.root).first) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -1779,7 +1780,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [older, newer], catalogueStarted: { starts[$0 - 1].fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } _ = try seedLibrary(defaults, root: fixture.root) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -1807,7 +1808,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [pack], catalogueStarted: { starts[$0 - 1].fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } _ = try seedLibrary(defaults, root: firstFixture.root) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -1834,7 +1835,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [pack], catalogueStarted: { _ in started.fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let library = ClickySoundLibrary(defaults: defaults, loader: loader) await loader.holdCatalogues() let importing = Task { @MainActor in await library.register(fixture.root) } @@ -1856,7 +1857,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [pack], catalogueStarted: { starts[$0 - 1].fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } await loader.holdCatalogues() @@ -1885,7 +1886,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [pack], catalogueStarted: { starts[$0 - 1].fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } _ = try seedLibrary(defaults, root: firstFixture.root, count: 31) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -1957,7 +1958,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [pack], prepareStarted: { _ in started.fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let record = try XCTUnwrap(seedLibrary(defaults, root: fixture.root).first) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -2018,7 +2019,7 @@ extension ClickyTests { let monitor = InputMonitorStub() monitor.granted = true let (model, defaults, suite) = monitorFixture(monitor) - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } model.activate() XCTAssertTrue(model.enabled) model.shutdown() @@ -2070,7 +2071,7 @@ extension ClickyTests { let (model, defaults, suite) = monitorFixture(monitor, snapshot: true) model.shutdown() defaults.set(data, forKey: "clicky.preferences.v1") - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let restored = ClickyModel(defaults: defaults, previewOnly: true, inputMonitor: monitor, observeSystemEvents: false) defer { restored.shutdown() } XCTAssertEqual(restored.preferences, original) @@ -2098,7 +2099,7 @@ extension ClickyTests { let monitor = InputMonitorStub() let suite = "dev.genesis.clicky.missing-library.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } var saved = ClickyPreferences() saved.selectedSwitch = .honey saved.selectedPack = ClickyPackReference(libraryID: "missing-library", packID: "fixture") @@ -2134,7 +2135,7 @@ extension ClickyPackTests { let loader = GatedIntegrationLoader(packs: [pack], prepareStarted: { _ in started.fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let record = try XCTUnwrap(seedLibrary(defaults, root: fixture.root).first) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -2163,7 +2164,7 @@ extension ClickyPackTests { prepareStarted: { _ in forbidden.fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } let record = try XCTUnwrap(seedLibrary(defaults, root: fixture.root).first) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -2191,7 +2192,7 @@ extension ClickyPackTests { prepareStarted: { _ in forbidden.fulfill() }) addTeardownBlock { await loader.cancelPending() } let (defaults, suite) = integrationDefaults() - defer { defaults.removePersistentDomain(forName: suite) } + defer { disposeIntegrationDefaults(defaults, suite: suite) } _ = try seedLibrary(defaults, root: fixture.root) let library = ClickySoundLibrary(defaults: defaults, loader: loader) defer { library.stop() } @@ -2385,3 +2386,13 @@ extension ClickyTests { XCTAssertEqual(stats.presses, 12002) } } + +/// `removePersistentDomain` empties a suite but leaves its plist in ~/Library/Preferences, so every run of these +/// tests used to leave one file per test behind (1,541 by 2026-10-14). Remove the file too. +@MainActor +func disposeIntegrationDefaults(_ defaults: UserDefaults, suite: String) { + defaults.removePersistentDomain(forName: suite) + let plist = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Preferences/\(suite).plist") + try? FileManager.default.removeItem(at: plist) +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift index 136f22e823..d7f429adf0 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift @@ -171,6 +171,29 @@ final class GitReposTests: XCTestCase { XCTAssertNil(marks.count(for: "/tmp/gk-counts-repo/notes/new.md"), "an untracked file has no numstat") } + /// The folder walks cut paths at "/" in UTF-8 (2026-10-14); accented names keep their marks and sums, and the + /// tree's own root is never marked. + func testTheTreeMarksWalkFoldersWithAccentedNames() { + let root = URL(fileURLWithPath: "/tmp/gk-accents-repo") + let status = GitRepoStatus(root: root, branch: "main", upstream: nil, ahead: 0, behind: 0, entries: [ + GitStatusEntry(path: "ČEZ/ΓΊkoly/ΕΎΓ‘dost/a.md", unstaged: .modified), + GitStatusEntry(path: "ČEZ/ΓΊkoly/b.md", unstaged: .modified), + GitStatusEntry(path: "ČEZ/c.md", unstaged: .untracked), + GitStatusEntry(path: "jinde/d.md", unstaged: .modified), + ]) + let numstat = [ + "ČEZ/ΓΊkoly/ΕΎΓ‘dost/a.md": GitLineCount(additions: 2, deletions: 1), + "ČEZ/ΓΊkoly/b.md": GitLineCount(additions: 1, deletions: 3), + ] + let marks = status.decorations(under: root.appendingPathComponent("ČEZ"), numstat: numstat) + let tree = "/tmp/gk-accents-repo/ČEZ" + XCTAssertEqual(Set(marks.files.keys), ["\(tree)/ΓΊkoly/ΕΎΓ‘dost/a.md", "\(tree)/ΓΊkoly/b.md", "\(tree)/c.md"]) + XCTAssertEqual(marks.folders, ["\(tree)/ΓΊkoly/ΕΎΓ‘dost", "\(tree)/ΓΊkoly"], "the tree's root is not marked") + XCTAssertEqual(marks.count(for: "\(tree)/ΓΊkoly/ΕΎΓ‘dost"), GitLineCount(additions: 2, deletions: 1)) + XCTAssertEqual(marks.count(for: "\(tree)/ΓΊkoly"), GitLineCount(additions: 3, deletions: 4)) + XCTAssertNil(marks.count(for: tree), "the tree's root sums nothing") + } + func testAFolderOutsideARepositoryHasNoModel() async throws { let folder = FileManager.default.temporaryDirectory.appendingPathComponent("norepo-\(UUID().uuidString)") try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift new file mode 100644 index 0000000000..4c81835f5c --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift @@ -0,0 +1,274 @@ +import XCTest +@testable import GenesisKit + +/// Every gate writes one line to the shared trail (Martin, 2026-10-16: "I want all permission gates to be audited"). +final class GateAuditTests: XCTestCase { + func testCredentialRedactionCoversArgumentsPurposesAndFullFactHelp() throws { + let token = "fixture-secret-never-real" + for flag in ["--token", "--api-key", "--key", "--password", "--secret"] { + for args in [[flag, token], ["\(flag)=\(token)"]] { + XCTAssertFalse(GateCredentialRedactor.arguments(args).joined(separator: " ").contains(token)) + XCTAssertFalse(GateCredentialRedactor.text("tools claude " + args.joined(separator: " ")).contains(token)) + } + } + XCTAssertEqual(GateCredentialRedactor.arguments(["tools", "wakeup", "daemon", "register", "-t", token]).last, "[REDACTED]") + XCTAssertEqual(GateCredentialRedactor.arguments(["bun", "/repo/src/wakeup/index.ts", "daemon", "register", "-t=\(token)"]).last, "-t=[REDACTED]") + XCTAssertEqual(GateCredentialRedactor.arguments(["tools", "claude", "tail", "-t", "5"]).last, "5") + let command = "tools claude --token=\(token)" + let shown = GateAudit.shown(title: "Approve", purpose: command, + facts: [ApprovalFact("Command", "short", help: command)], warning: nil) + let data = try JSONSerialization.data(withJSONObject: shown) + XCTAssertFalse(String(decoding: data, as: UTF8.self).contains(token)) + } + + func testAuditHeaderCredentialsAreRedactedWhileSafeHeadersRemain() throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("gate-headers-\(UUID().uuidString)").path + setenv("GENESIS_TOOLS_HOME", home, 1) + defer { unsetenv("GENESIS_TOOLS_HOME"); try? FileManager.default.removeItem(atPath: home) } + let token = "fixture-header-secret-never-real" + let headers = [ + ("Authorization: Bearer {token}", "Authorization: Bearer [REDACTED]"), + ("authorization:Basic {token}", "authorization:Basic [REDACTED]"), + ("Proxy-Authorization: Basic {token}", "Proxy-Authorization: Basic [REDACTED]"), + ("pRoXy-AuThOrIzAtIoN:Bearer {token}", "pRoXy-AuThOrIzAtIoN:Bearer [REDACTED]"), + ("Authorization:{token}", "Authorization:[REDACTED]"), + ("X-Api-Key:{token}", "X-Api-Key:[REDACTED]"), + ("x-aUtH-tOkEn: {token}", "x-aUtH-tOkEn: [REDACTED]"), + ("X-Client-Secret: {token}", "X-Client-Secret: [REDACTED]"), + ("Cookie:session={token}; other={token}", "Cookie:[REDACTED]"), + ("Set-Cookie: session={token}; Path=/", "Set-Cookie: [REDACTED]"), + ] + let argv = ["curl"] + headers.flatMap { ["--header", $0.0.replacingOccurrences(of: "{token}", with: token)] } + + ["--header", "Accept: application/json", "https://api.example.test/deploy"] + let command = argv.map { "\"\($0)\"" }.joined(separator: " ") + GateAudit.write(gate: "link-command", event: "prompted", + ["argv": argv, "shown": ["facts": [["label": "Command", "help": command], + ["label": "Unquoted command", "help": argv.joined(separator: " ")]]]]) + let text = try String(contentsOfFile: GateAudit.path, encoding: .utf8) + XCTAssertFalse(text.contains(token)) + // JSONSerialization writes "/" as "\/", so the safe header is checked in the parsed entry, not the raw line. + let entry = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + let written = try XCTUnwrap(entry["argv"] as? [String]) + XCTAssertTrue(written.contains("Accept: application/json")) + for (_, expected) in headers { XCTAssertTrue(written.contains(expected), expected) } + let shown = try XCTUnwrap(entry["shown"] as? [String: Any]) + let facts = try XCTUnwrap(shown["facts"] as? [[String: String]]) + XCTAssertEqual(facts.count, 2) + for fact in facts { + let help = try XCTUnwrap(fact["help"]) + XCTAssertTrue(help.contains("Accept: application/json")) + XCTAssertTrue(help.contains("Authorization: Bearer [REDACTED]")) + } + } + + func testQuotedHeaderValuesAreRedactedInAccountCommandsAndShownFacts() throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("gate-quoted-headers-\(UUID().uuidString)").path + setenv("GENESIS_TOOLS_HOME", home, 1) + defer { unsetenv("GENESIS_TOOLS_HOME"); try? FileManager.default.removeItem(atPath: home) } + let cookie = "fixture-cookie" + let bearer = "fixture-bearer" + for header in ["Cookie: session=\"\(cookie)\"", "Cookie: session='\(cookie)'", "X-Api-Key: \"\(cookie)\"", + "Cookie: session=\"prefix --header \(cookie)\"", "Cookie: session=\"prefix Accept: \(cookie)\"", + "Cookie: session= \" --header \(cookie)\""] { + let argv = ["curl", "--header", header, "--header", "Authorization: Bearer \(bearer)", + "--header", "Accept: application/json", "https://api.example.test/deploy"] + let original = argv + let command = String((["bun", "/fixture/client.ts"] + Array(argv.dropFirst())).joined(separator: " ").prefix(200)) + let shown = GateAudit.shown(title: "Account approval", purpose: "Read account", facts: [ + ApprovalFact("Quoted command", argv.map { "\"\($0)\"" }.joined(separator: " ")), + ApprovalFact("Unquoted command", argv.joined(separator: " ")), + ApprovalFact("Mixed quoted command", "curl -H \"Cookie: session='\(cookie)'\" --header Authorization: Bearer \(bearer) --header Accept: application/json"), + ], warning: nil) + GateAudit.write(gate: "ai-account", event: "prompted", ["argv": argv, "client": ["command": command], + "command": command, "shown": shown, + "ambiguous": "bun --header Cookie: session=\"\(cookie) --header Accept: application/json"]) + let text = try String(contentsOfFile: GateAudit.path, encoding: .utf8) + XCTAssertFalse(text.contains(cookie)) + XCTAssertFalse(text.contains(bearer)) + let line = try XCTUnwrap(text.split(separator: "\n").last) + let entry = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + let written = try XCTUnwrap(entry["argv"] as? [String]) + XCTAssertTrue(written.contains("Accept: application/json")) + let client = try XCTUnwrap(entry["client"] as? [String: String]) + let savedShown = try XCTUnwrap(entry["shown"] as? [String: Any]) + let facts = try XCTUnwrap(savedShown["facts"] as? [[String: String]]) + XCTAssertEqual(facts.count, 3) + let commands = [try XCTUnwrap(client["command"]), try XCTUnwrap(entry["command"] as? String)] + + facts.compactMap { $0["value"] } + for saved in commands { + XCTAssertFalse(saved.contains(cookie)) + XCTAssertFalse(saved.contains(bearer)) + XCTAssertTrue(saved.contains("Accept: application/json")) + XCTAssertTrue(saved.contains("Authorization: Bearer [REDACTED]")) + } + XCTAssertTrue((entry["ambiguous"] as? String)?.contains("[REDACTED]") == true) + XCTAssertEqual(argv, original) + } + } + + func testQuotedShellHeaderBoundariesAndEqualsFlags() throws { + let secret = "fixture-cookie-r5" + let safe = "Accept: application/json" + var commands = [ + "curl -H \"Cookie: session=\(secret)\" -H \"\(safe)\"", + "curl -H \"Cookie: session=\(secret)\" -H \"Authorization: Bearer \(secret)\" -H \"\(safe)\"", + "curl -H \"Cookie: session='\(secret)'\" -H \"\(safe)\"", + "curl --header Cookie: session=\"prefix --header \(secret)\" --header \(safe)", + ] + for flag in ["--header", "-H"] { + for header in ["Authorization: Bearer \(secret)", "Proxy-Authorization: Basic \(secret)", + "Cookie: session='\(secret)'", "X-Api-Key:\(secret)"] { + let argv = ["wget", "\(flag)=\(header)", "\(flag)=\(safe)"] + let original = argv + let sanitized = GateCredentialRedactor.arguments(argv).joined(separator: " ") + XCTAssertFalse(sanitized.contains(secret)) + XCTAssertTrue(sanitized.contains(safe)) + XCTAssertEqual(argv, original) + commands += [argv.joined(separator: " "), argv.map { "\"\($0)\"" }.joined(separator: " ")] + } + } + let home = FileManager.default.temporaryDirectory.appendingPathComponent("gate-r5-\(UUID().uuidString)").path + setenv("GENESIS_TOOLS_HOME", home, 1) + defer { unsetenv("GENESIS_TOOLS_HOME"); try? FileManager.default.removeItem(atPath: home) } + for command in commands { + GateAudit.write(gate: "ai-account", event: "prompted", ["command": command]) + let lines = try String(contentsOfFile: GateAudit.path, encoding: .utf8).split(separator: "\n") + let line = try XCTUnwrap(lines.last) + let entry = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + let saved = try XCTUnwrap(entry["command"] as? String) + XCTAssertFalse(saved.contains(secret), command) + XCTAssertTrue(saved.contains(safe), command) + if command.contains("Authorization: Bearer") { + XCTAssertTrue(saved.contains("Authorization: Bearer [REDACTED]"), command) + } + } + } + + func testCredentialFlagValuesConsumeEscapedQuotesAndMalformedRemainders() throws { + let secret = "fixture-secret-suffix-r6" + let home = FileManager.default.temporaryDirectory.appendingPathComponent("gate-flags-r6-\(UUID().uuidString)").path + setenv("GENESIS_TOOLS_HOME", home, 1) + defer { unsetenv("GENESIS_TOOLS_HOME"); try? FileManager.default.removeItem(atPath: home) } + for flag in ["--token", "--api-key", "--key", "--password", "--secret"] { + for separator in [" ", "="] { + for quote in ["\"", "'"] { + let value = [quote, "prefix\\", quote, secret, quote].joined() + let input = ["tools claude", flag + separator + value, "--format json"].joined(separator: " ") + let expected = ["tools claude", flag + separator + "[REDACTED]", "--format json"].joined(separator: " ") + XCTAssertEqual(GateCredentialRedactor.text(input), expected) + let unfinished = ["tools claude", flag + separator + String(value.dropLast()), "--format json\\"].joined(separator: " ") + XCTAssertEqual(GateCredentialRedactor.text(unfinished), + ["tools claude", flag + separator + "[REDACTED]"].joined(separator: " ")) + GateAudit.write(gate: "vault", event: "prompted", ["command": input, "shown": [["help": input]]]) + let text = try String(contentsOfFile: GateAudit.path, encoding: .utf8) + XCTAssertFalse(text.contains(secret)) + let line = try XCTUnwrap(text.split(separator: "\n").last) + let entry = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + XCTAssertEqual(entry["command"] as? String, expected) + let shown = try XCTUnwrap(entry["shown"] as? [[String: String]]) + XCTAssertEqual(shown.first?["help"], expected) + } + } + } + let relay = ["tools wakeup daemon register -t \"prefix\\\"", secret, "\" --format json"].joined() + XCTAssertEqual(GateCredentialRedactor.text(relay), "tools wakeup daemon register -t [REDACTED] --format json") + XCTAssertEqual(GateCredentialRedactor.text("tools claude tail -t \"safe\\\"value\""), + "tools claude tail -t \"safe\\\"value\"") + } + + func testHeaderNewlinesUseCodeUnitsAndLongRunsAreLinear() { + let safe = "Accept: application/json" + for newline in ["\r", "\n", "\r\n"] { + XCTAssertEqual(GateCredentialRedactor.text("Cookie: session=πŸ”’fixture-cookie-r5\(newline)\(safe)"), + "Cookie: [REDACTED]\(newline)\(safe)") + } + let started = Date() + for value in ["x" + String(repeating: " ", count: 131_072) + "!", String(repeating: "\"x\"", count: 43_691)] { + XCTAssertEqual(GateCredentialRedactor.text("curl --header Cookie: \(value)"), "curl --header Cookie: [REDACTED]") + } + XCTAssertLessThan(Date().timeIntervalSince(started), 2) + } + + func testAuditURLsRedactUserinfoQueryCredentialsAndEncodedPrompts() throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("gate-url-\(UUID().uuidString)").path + setenv("GENESIS_TOOLS_HOME", home, 1) + defer { unsetenv("GENESIS_TOOLS_HOME"); try? FileManager.default.removeItem(atPath: home) } + let token = "fixture'url-secret" + var link = URLComponents(string: "https://example.com/run")! + link.user = "fixture-user" + link.password = token + link.queryItems = [URLQueryItem(name: "token", value: token), URLQueryItem(name: "api_key", value: token), + URLQueryItem(name: "prompt", value: "tools claude --token \(token)"), URLQueryItem(name: "safe", value: "kept")] + let url = try XCTUnwrap(link.string) + var nested = URLComponents(string: "https://example.com/open")! + nested.queryItems = [URLQueryItem(name: "next", value: url)] + var callback = URLComponents(string: "genesis://oauth/callback")! + var fragment = URLComponents() + fragment.queryItems = [URLQueryItem(name: "access_token", value: token), URLQueryItem(name: "state", value: "kept-state")] + callback.percentEncodedFragment = fragment.percentEncodedQuery + GateAudit.write(gate: "link-command", event: "denied", + ["link": url, "url": try XCTUnwrap(nested.string), "open": [url, try XCTUnwrap(callback.string)]]) + let text = try String(contentsOfFile: GateAudit.path, encoding: .utf8) + XCTAssertFalse(text.contains(token)) + XCTAssertFalse(text.contains("fixture-user")) + XCTAssertFalse(text.contains("url-secret")) + XCTAssertTrue(text.contains("safe=kept")) + XCTAssertTrue(text.contains("state=kept-state")) + XCTAssertTrue(text.contains("example.com")) + XCTAssertTrue(text.contains("REDACTED")) + } + + func testConcurrentProcessesAppendWithoutOverwritingDecisions() throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("gate-append-\(UUID().uuidString)").path + setenv("GENESIS_TOOLS_HOME", home, 1) + defer { unsetenv("GENESIS_TOOLS_HOME"); try? FileManager.default.removeItem(atPath: home) } + let path = GateAudit.path + try FileManager.default.createDirectory(atPath: (path as NSString).deletingLastPathComponent, withIntermediateDirectories: true) + let writers = (0..<4).map { index -> Process in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", #"i=0; while [ "$i" -lt 200 ]; do printf '{"external":%s,"i":%s}\n' "$1" "$i" >> "$2"; i=$((i+1)); done"#, "audit-writer", "\(index)", path] + return process + } + let completed = expectation(description: "external audit writers finished") + completed.expectedFulfillmentCount = writers.count + for writer in writers { + writer.terminationHandler = { _ in completed.fulfill() } + try writer.run() + } + for index in 0..<200 { GateAudit.write(gate: "test", event: "allowed", ["i": index]) } + wait(for: [completed], timeout: 10) + for writer in writers { XCTAssertEqual(writer.terminationStatus, 0) } + let lines = try String(contentsOfFile: path, encoding: .utf8).split(separator: "\n") + XCTAssertEqual(lines.count, 1_000) + for line in lines { XCTAssertNoThrow(try JSONSerialization.jsonObject(with: Data(line.utf8))) } + } + + func testALineCarriesTheGateTheEventTheWriterAndWhatTheWindowShowed() throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("gate-audit-\(UUID().uuidString)").path + setenv("GENESIS_TOOLS_HOME", home, 1) + defer { + unsetenv("GENESIS_TOOLS_HOME") + try? FileManager.default.removeItem(atPath: home) + } + + let shown = GateAudit.shown(title: "β€œx” wants the vault key", purpose: "Why.", + facts: [ApprovalFact("Folder", "~/work", help: "/Users/x/work")], + warning: "Extra code may be loaded.", onceOnly: "extra code may be loaded") + GateAudit.write(gate: "vault", event: "prompted", ["shown": shown, "optional": Optional.none as Any]) + + let path = GateAudit.path + XCTAssertTrue(path.hasPrefix(home)) + let text = try String(contentsOfFile: path, encoding: .utf8) + let line = try XCTUnwrap(text.split(separator: "\n").last) + let entry = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + XCTAssertEqual(entry["gate"] as? String, "vault") + XCTAssertEqual(entry["event"] as? String, "prompted") + XCTAssertNotNil(entry["writer"]) + let written = try XCTUnwrap(entry["shown"] as? [String: Any]) + XCTAssertEqual(written["warning"] as? String, "Extra code may be loaded.") + XCTAssertEqual((written["facts"] as? [[String: String]])?.first?["value"], "/Users/x/work") + let mode = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: path)[.posixPermissions] as? Int) + XCTAssertEqual(mode & 0o777, 0o600) + } +} diff --git a/src/macos/GenesisTools/Sources/BrowserURL.swift b/src/macos/GenesisTools/Sources/BrowserURL.swift index b0ccb49959..d9b911a6c4 100644 --- a/src/macos/GenesisTools/Sources/BrowserURL.swift +++ b/src/macos/GenesisTools/Sources/BrowserURL.swift @@ -544,8 +544,19 @@ private enum BrowserOutcome { private func performBrowserDecision(_ decision: RouteDecision, toastEnabled: Bool) async throws -> BrowserOutcome { if decision.kind == "run" { let argv = decision.argv ?? [] - if decision.touchId && !requireBrowserTouchID() { return .denied } - if decision.needsApproval == true && !confirmBrowser(argv, open: decision.open) { return .denied } + if decision.touchId { + let ok = requireBrowserTouchID() + auditRoute(decision, gate: "touch-id", event: ok ? "allowed" : "denied") + if !ok { return .denied } + } + if decision.needsApproval == true { + auditRoute(decision, gate: "approval-card", event: "prompted") + let ok = confirmBrowser(argv, open: decision.open) + auditRoute(decision, gate: "approval-card", event: ok ? "allowed" : "denied") + if !ok { return .denied } + } else { + auditRoute(decision, gate: "no-approval", event: "allowed") + } let output = try await Task.detached(priority: .userInitiated) { try runBrowserArgv(argv) }.value if !toastEnabled { notifyBrowser(decision.notify?.isEmpty == false ? decision.notify! : (output.isEmpty ? "Done" : output)) @@ -559,6 +570,7 @@ private func performBrowserDecision(_ decision: RouteDecision, toastEnabled: Boo // The shared contract (src/utils/browser-router/route.ts): a `tool` action "is recorded and not // executed", and the CLI's perform() refuses it. The app keeps the same boundary. let message = "Tool routes are recorded, not run: " + (["tools", decision.tool ?? ""] + (decision.args ?? [])).joined(separator: " ") + auditRoute(decision, gate: "tool-route", event: "recorded") if !toastEnabled { notifyBrowser(message) } @@ -570,6 +582,18 @@ private func performBrowserDecision(_ decision: RouteDecision, toastEnabled: Boo return .ran } +/// A link that runs a saved command is a gate (Touch ID, the Allow / Deny card, or a route set to run without asking): +/// each decision goes to the shared gate audit with the link, the route and the command (GenesisKit `GateAudit`). +private func auditRoute(_ decision: RouteDecision, gate: String, event: String) { + var fields: [String: Any] = ["check": gate, "link": decision.original, "url": decision.url, "via": decision.via] + fields["route"] = decision.routeIndex + fields["argv"] = decision.argv ?? (["tools", decision.tool ?? ""] + (decision.args ?? [])) + fields["open"] = decision.open + fields["approval"] = decision.approval + fields["touchId"] = decision.touchId + GateAudit.write(gate: "link-command", event: event, fields) +} + private func requireBrowserTouchID() -> Bool { let context = LAContext() var error: NSError? diff --git a/src/macos/GenesisTools/Sources/Gate.swift b/src/macos/GenesisTools/Sources/Gate.swift index 100b3a6ddf..8641fc4b9f 100644 --- a/src/macos/GenesisTools/Sources/Gate.swift +++ b/src/macos/GenesisTools/Sources/Gate.swift @@ -68,72 +68,6 @@ private func describeToken(_ kind: String?, provider: String) -> String { /// a little earlier and says why. private let gateDeadlineSeconds: Double = 170 -private func rememberLabel(_ seconds: Int) -> String { - if seconds <= 0 { - return "Allow once" - } - - if seconds % 86_400 == 0 { - let days = seconds / 86_400 - return "Allow for \(days) day\(days == 1 ? "" : "s")" - } - - if seconds % 3_600 == 0 { - let hours = seconds / 3_600 - return "Allow for \(hours) hour\(hours == 1 ? "" : "s")" - } - - return "Allow for \(seconds / 60) min" -} - -private func describeClient(_ client: GateClientInfo) -> String { - var lines: [String] = [] - - if let pid = client.pid { - lines.append("Process: pid \(pid)") - } else { - lines.append("Process: not identified (no pid)") - } - - if let executable = client.executable, !executable.isEmpty { - lines.append("Executable: \(executable)") - } else if client.pid != nil { - lines.append("Executable: not running under that pid") - } - - if let script = client.script, !script.isEmpty { - lines.append("Script: \(script)") - } - - if let command = client.command, !command.isEmpty, command != client.executable { - lines.append("Command: \(command)") - } - - if let cwd = client.cwd, !cwd.isEmpty { - lines.append("Working dir: \(cwd)") - } - - if let ancestors = client.ancestors, !ancestors.isEmpty { - let chain = ancestors.map { "\($0.command) (\($0.pid))" }.joined(separator: " ← ") - lines.append("Started by: \(chain)") - } - - if let injected = client.injected, !injected.isEmpty { - lines.append("⚠️ Extra code may be loaded: \(injected.joined(separator: ", "))") - } - - // A running pid that did not start the request is refused before this window opens, so an - // unverified client here is either a real ancestor whose identity is incomplete, or no pid. - if client.verified != true { - let why = client.isAncestor == true - ? "its binary or script could not be established" - : "no running pid was given" - lines.append("⚠️ Unverified: \(why). Allow once at most; it will not be remembered.") - } - - return lines.joined(separator: "\n") -} - private func gateEmit(decision: String, rememberSeconds: Int, method: String, reason: String) -> Never { logClick("gate.approve decision=\(decision) remember=\(rememberSeconds) method=\(method) reason=\(reason)") emitResult([ @@ -145,8 +79,8 @@ private func gateEmit(decision: String, rememberSeconds: Int, method: String, re } /// Touch ID first, then the account password when biometrics are unavailable or the user picks -/// the fallback. A cancel at either step is a deny. -private func authenticate(reason: String, completion: @escaping (_ method: String?, _ failure: String) -> Void) { +/// the fallback. A cancel at either step is a deny. The vault window (VaultUnlock.swift) confirms with it too. +func authenticate(reason: String, completion: @escaping (_ method: String?, _ failure: String) -> Void) { let biometric = LAContext() biometric.localizedFallbackTitle = "Use Password" var unavailable: NSError? @@ -187,51 +121,147 @@ private func authenticate(reason: String, completion: @escaping (_ method: Strin } } -func approveGate(_ params: GateApproveParams) { - let choices = (params.rememberChoicesSeconds?.isEmpty == false ? params.rememberChoicesSeconds : nil) ?? [0, 8 * 3_600, 7 * 86_400] - let accountLabel = params.account.label.map { "\(params.account.name) (\($0))" } ?? params.account.name - - DispatchQueue.main.asyncAfter(deadline: .now() + gateDeadlineSeconds) { - gateEmit(decision: "deny", rememberSeconds: 0, method: "none", reason: "no answer within \(Int(gateDeadlineSeconds))s") +/// What the gate window shows, built from the CLI's request. Pure, so tests read it. +struct GateWindowContent { + var title: String + var purpose: String + var facts: [ApprovalFact] + var details: [ApprovalFact] + var warning: String? + var rememberChoices: [ApprovalRemember] + + static func rememberTitle(_ seconds: Int) -> String { + if seconds <= 0 { return "Just this once" } + if seconds % 86_400 == 0 { return "For \(Plural.count(seconds / 86_400, "day"))" } + if seconds % 3_600 == 0 { return "For \(Plural.count(seconds / 3_600, "hour"))" } + return "For \(seconds / 60) min" } - DispatchQueue.main.async { - NSApplication.shared.activate(ignoringOtherApps: true) + static func build(_ params: GateApproveParams, home: String = NSHomeDirectory()) -> GateWindowContent { + let client = params.client + let accountLabel = params.account.label.map { "\(params.account.name) (\($0))" } ?? params.account.name + let choices = (params.rememberChoicesSeconds?.isEmpty == false ? params.rememberChoicesSeconds : nil) ?? [0, 8 * 3_600, 7 * 86_400] + let program = client.script ?? client.executable + + var facts = [ApprovalFact("Account", "\(accountLabel) Β· \(params.provider)")] + facts.append(ApprovalFact("Program", program.map { "\(client.name) Β· \(VaultWindowContent.programPath($0, home: home))" } ?? client.name, + help: [client.executable, client.script].compactMap { $0 }.joined(separator: " "))) + if let cwd = client.cwd, !cwd.isEmpty { + facts.append(ApprovalFact("Folder", VaultWindowContent.shortPath(cwd, home: home), help: cwd)) + } - let isApiKey = params.tokenKind == "api-key" - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = isApiKey - ? "β€œ\(params.client.name)” asks for the API KEY of the \(params.provider) account \(accountLabel)" - : "β€œ\(params.client.name)” asks for the \(params.provider) token of \(accountLabel)" - alert.informativeText = describeClient(params.client) - + "\n\n" + describeToken(params.tokenKind, provider: params.provider) + " Touch ID confirms an allow." - - for seconds in choices { - alert.addButton(withTitle: rememberLabel(seconds)) + var details = [ApprovalFact("Process", client.pid.map { "pid \($0)" } ?? "not identified (no pid)")] + if let executable = client.executable, !executable.isEmpty { + details.append(ApprovalFact("Executable", executable, monospaced: true)) + } + + if let command = client.command, !command.isEmpty, command != client.executable { + details.append(ApprovalFact("Command", command, monospaced: true)) + } + + if let ancestors = client.ancestors, !ancestors.isEmpty { + details.append(ApprovalFact("Started by", ancestors.map { "\($0.command) (\($0.pid))" }.joined(separator: " ← "))) + } + + var warnings: [String] = [] + if let injected = client.injected, !injected.isEmpty { + warnings.append("Extra code may be loaded (\(injected.joined(separator: ", "))).") + } + + // A running pid that did not start the request is refused before this window opens, so an unverified client + // here is either a real ancestor whose identity is incomplete, or no pid. + if client.verified != true { + warnings.append(client.isAncestor == true + ? "Unverified: its binary or script could not be established. It will not be remembered." + : "Unverified: no running pid was given. It will not be remembered.") } - let deny = alert.addButton(withTitle: "Deny") - deny.keyEquivalent = "\u{1b}" + let isApiKey = params.tokenKind == "api-key" + return GateWindowContent( + title: isApiKey + ? "β€œ\(client.name)” wants the \(params.provider) API key of \(params.account.name)" + : "β€œ\(client.name)” wants the \(params.provider) token of \(params.account.name)", + purpose: describeToken(params.tokenKind, provider: params.provider), + facts: facts, + details: details, + warning: warnings.isEmpty ? nil : warnings.joined(separator: " "), + rememberChoices: choices.map { ApprovalRemember(seconds: $0, title: rememberTitle($0)) }) + } +} - let response = alert.runModal() - let index = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue +@MainActor private let gatePresenter = ApprovalPanelPresenter() + +/// What the shared gate audit (GenesisKit `GateAudit`) records for an AI-account request: who asked, how it runs, and +/// which account and token kind. The token itself is never in it. +private func gateAuditFields(_ params: GateApproveParams) -> [String: Any] { + let client = params.client + var fields: [String: Any] = ["name": client.name] + fields["pid"] = client.pid + fields["executable"] = client.executable + fields["script"] = client.script + fields["command"] = client.command + fields["cwd"] = client.cwd + fields["parents"] = client.ancestors?.map { ["pid": $0.pid, "command": $0.command] } + fields["injected"] = client.injected + fields["verified"] = client.verified + return ["client": fields, "provider": params.provider, "account": params.account.name, + "tokenKind": params.tokenKind ?? "token"] +} + +func approveGate(_ params: GateApproveParams) { + let content = GateWindowContent.build(params) + let audit = gateAuditFields(params) + func record(_ event: String, _ extra: [String: Any] = [:]) { + GateAudit.write(gate: "ai-account", event: event, audit.merging(extra) { _, new in new }) + } + let biometric = LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) - guard index >= 0, index < choices.count else { + DispatchQueue.main.async { + let model = ApprovalDialogModel( + title: content.title, purpose: content.purpose, symbol: "person.badge.key.fill", facts: content.facts, + details: content.details, warning: content.warning, + footnote: "Every decision is written to the gate audit log (tools ai gate audit).", + rememberChoices: content.rememberChoices.count > 1 ? content.rememberChoices : [], + rememberNote: content.rememberChoices.count > 1 ? nil : "Only once", + allowTitle: biometric ? "Allow with Touch ID" : "Allow…", + deadline: Date().addingTimeInterval(gateDeadlineSeconds), identifier: "gate-approve") + + // The NSAlert this replaces ran a modal loop on the main queue, so this timer never fired there. + let deadline = DispatchWorkItem { + record("denied", ["detail": "no answer within \(Int(gateDeadlineSeconds))s"]) + gateEmit(decision: "deny", rememberSeconds: 0, method: "none", reason: "no answer within \(Int(gateDeadlineSeconds))s") + } + DispatchQueue.main.asyncAfter(deadline: .now() + gateDeadlineSeconds, execute: deadline) + + model.onDeny = { + deadline.cancel() + gatePresenter.close() + record("denied", ["detail": "denied in the approval window"]) gateEmit(decision: "deny", rememberSeconds: 0, method: "none", reason: "denied in the approval window") } - let remember = choices[index] - let reason = isApiKey - ? "give β€œ\(params.client.name)” the API key of the \(params.provider) account \(params.account.name)" - : "allow β€œ\(params.client.name)” to use the \(params.provider) account \(params.account.name)" + model.onAllow = { choice in + deadline.cancel() + gatePresenter.close() + let remember = choice?.seconds ?? 0 + let reason = params.tokenKind == "api-key" + ? "give β€œ\(params.client.name)” the API key of the \(params.provider) account \(params.account.name)" + : "allow β€œ\(params.client.name)” to use the \(params.provider) account \(params.account.name)" + + authenticate(reason: reason) { method, failure in + guard let method else { + record("denied", ["detail": failure]) + gateEmit(decision: "deny", rememberSeconds: 0, method: "none", reason: failure) + } - authenticate(reason: reason) { method, failure in - guard let method else { - gateEmit(decision: "deny", rememberSeconds: 0, method: "none", reason: failure) + record("allowed", ["method": method, "rememberSeconds": remember]) + gateEmit(decision: "allow", rememberSeconds: remember, method: method, reason: "") } - - gateEmit(decision: "allow", rememberSeconds: remember, method: method, reason: "") } + + record("prompted", ["shown": GateAudit.shown(title: content.title, purpose: content.purpose, + facts: content.facts + content.details, warning: content.warning, + onceOnly: content.rememberChoices.count > 1 ? nil : "only once")]) + gatePresenter.present(model) } } diff --git a/src/macos/GenesisTools/Sources/Notify.swift b/src/macos/GenesisTools/Sources/Notify.swift index 70df81205e..a2f2a9a22f 100644 --- a/src/macos/GenesisTools/Sources/Notify.swift +++ b/src/macos/GenesisTools/Sources/Notify.swift @@ -815,7 +815,7 @@ private func list() { /// bump it: a client discovers those from `rpc.hello`'s method list instead. let rpcProtocolVersion = 1 -let rpcMethods = ["rpc.hello", "notify.post", "notify.remove", "notify.list", "notify.status", "notify.authorize", "notify.settings", "notify.reply", "gate.approve"] +let rpcMethods = ["rpc.hello", "notify.post", "notify.remove", "notify.list", "notify.status", "notify.authorize", "notify.settings", "notify.reply", "gate.approve", "vault.unlock"] /// `GenesisTools --rpc ''`, or `--rpc -` to read the request from stdin: run one method and /// exit with one JSON line on stdout. @@ -905,6 +905,13 @@ func runRpc(_ arguments: [String]) -> Never { approveGate(request.params) + case "vault.unlock": + guard let request = try? decoder.decode(ParamsEnvelope.self, from: data) else { + emitError(code: "params_invalid", message: "vault.unlock needs params.tool and params.purpose", exitCode: 64) + } + + unlockVault(request.params) + default: emitError(code: "method_unknown", message: "unknown method \(envelope.method)", exitCode: 69) } diff --git a/src/macos/GenesisTools/Sources/VaultRecipient.swift b/src/macos/GenesisTools/Sources/VaultRecipient.swift new file mode 100644 index 0000000000..133aa6ec6c --- /dev/null +++ b/src/macos/GenesisTools/Sources/VaultRecipient.swift @@ -0,0 +1,233 @@ +import Darwin +import Foundation + +/// A release-time snapshot, not an atomic transport authentication: libproc cannot prevent a holder +/// from forking or passing an fd after inspection. Unknown or incomplete observations deny release, +/// except confirmed exits and EPERM on the fd tables of protected system executables. A holder that +/// execs into such a binary while keeping the read end is not seen. That exception trusts the system +/// binary's code not to relay the bytes; it does not prove the read end was closed. +enum VaultRecipient { + struct Identity: Equatable { + var pid: pid_t + var startSeconds: UInt64 + var startMicroseconds: UInt64 + var executable: String + } + + enum Endpoint: Equatable { + case pipe(handle: UInt64, peer: UInt64) + case socket(pcb: UInt64, peer: UInt64) + case other + + var connected: Bool { + switch self { + case let .pipe(handle, peer), let .socket(handle, peer): + return handle != 0 && peer != 0 && handle != peer + case .other: + return false + } + } + + func isPeer(of output: Endpoint) -> Bool { + switch (self, output) { + case let (.pipe(handle, peer), .pipe(outputHandle, outputPeer)), + let (.socket(handle, peer), .socket(outputHandle, outputPeer)): + return handle == outputPeer && peer == outputHandle + default: + return false + } + } + } + + enum ReadFailure { + case exited + case unavailable(code: Int32, executable: String?) + + var maySkip: Bool { + switch self { + case .exited: + return true + case let .unavailable(code, executable): + // /System/Volumes/Data is the user's writable data volume, not the sealed system volume. + guard code == EPERM, let executable, + executable == (executable as NSString).standardizingPath, + !executable.hasPrefix("/System/Volumes/") else { return false } + return ["/usr/bin/", "/usr/sbin/", "/usr/libexec/", "/bin/", "/sbin/", "/System/"] + .contains { executable.hasPrefix($0) } + } + } + } + + struct ProcessFDs { + var pid: pid_t + /// nil means the table, or any relevant descriptor in it, could not be read completely. + var endpoints: [Endpoint]? + var failure: ReadFailure? = nil + + var maySkip: Bool { endpoints == nil && failure?.maySkip == true } + } + + static func rejection(stdout: Endpoint, caller: Identity?, current: Identity?, processes: [ProcessFDs]) -> String? { + guard stdout.connected else { return "vault stdout must be a connected pipe or Unix-domain socket" } + guard let caller, let current else { return "the caller's process identity could not be read" } + guard current == caller else { return "the caller's process instance or executable changed before key release" } + guard processes.allSatisfy({ $0.endpoints != nil || $0.maySkip }) else { + return "the vault recipient could not be verified because a process fd table was unreadable" + } + let holders = Set(processes.filter { process in + process.endpoints?.contains(where: { $0.isPeer(of: stdout) }) == true + }.map(\.pid)) + guard holders.subtracting([caller.pid]).isEmpty else { return "another process holds the output" } + guard holders == Set([caller.pid]) else { + return "the verified caller must be the sole holder of vault stdout's other end" + } + return nil + } + + static func identity(_ pid: pid_t) -> Identity? { + var info = proc_bsdinfo() + let size = Int32(MemoryLayout.stride) + guard proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, size) == size, + info.pbi_pid == UInt32(pid), info.pbi_start_tvsec != 0 else { return nil } + var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) + guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil } + return Identity(pid: pid, startSeconds: info.pbi_start_tvsec, startMicroseconds: info.pbi_start_tvusec, + executable: String(cString: buffer)) + } + + private struct InspectionFailure: Error { + var reason: String + var code: Int32 = 0 + var fdTable: Bool = false + } + + private static func hasExited(_ pid: pid_t) -> Bool { + kill(pid, 0) == -1 && errno == ESRCH + } + + private static func executablePath(_ pid: pid_t) -> String? { + var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) + guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil } + return String(cString: buffer) + } + + private static func pids(kind: UInt32, uid: uid_t) throws -> Set { + let needed = proc_listpids(kind, uid, nil, 0) + guard needed > 0 else { throw InspectionFailure(reason: "the user's process list could not be read") } + var buffer = [pid_t](repeating: 0, count: Int(needed) / MemoryLayout.stride + 128) + let capacity = Int32(buffer.count * MemoryLayout.stride) + let bytes = buffer.withUnsafeMutableBytes { proc_listpids(kind, uid, $0.baseAddress, capacity) } + guard bytes > 0, bytes < capacity, Int(bytes) % MemoryLayout.stride == 0 else { + throw InspectionFailure(reason: "the user's process list was incomplete") + } + return Set(buffer.prefix(Int(bytes) / MemoryLayout.stride).filter { $0 > 0 }) + } + + private static func userPids() throws -> Set { + // Include processes with this real uid even if their effective uid has changed. + try pids(kind: UInt32(PROC_UID_ONLY), uid: getuid()) + .union(pids(kind: UInt32(PROC_RUID_ONLY), uid: getuid())) + } + + private static func descriptors(_ pid: pid_t) throws -> [proc_fdinfo] { + errno = 0 + let needed = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0) + let sizeError = errno + guard needed >= 0, sizeError == 0 else { + throw InspectionFailure(reason: "the fd table for process \(pid) could not be read", code: sizeError, fdTable: true) + } + let stride = MemoryLayout.stride + var buffer = [proc_fdinfo](repeating: proc_fdinfo(), count: Int(needed) / stride + 32) + let capacity = Int32(buffer.count * stride) + errno = 0 + let bytes = buffer.withUnsafeMutableBytes { proc_pidinfo(pid, PROC_PIDLISTFDS, 0, $0.baseAddress, capacity) } + let readError = errno + guard bytes >= 0, readError == 0, bytes < capacity, Int(bytes) % stride == 0 else { + throw InspectionFailure(reason: "the fd table for process \(pid) was incomplete", code: readError, fdTable: true) + } + return Array(buffer.prefix(Int(bytes) / stride)) + } + + private static func endpoint(_ pid: pid_t, fd: Int32, type: UInt32) throws -> Endpoint { + if type == UInt32(PROX_FDTYPE_PIPE) { + var info = pipe_fdinfo() + let size = Int32(MemoryLayout.stride) + guard proc_pidfdinfo(pid, fd, PROC_PIDFDPIPEINFO, &info, size) == size else { + throw InspectionFailure(reason: "pipe \(fd) of process \(pid) could not be inspected", code: errno) + } + return .pipe(handle: info.pipeinfo.pipe_handle, peer: info.pipeinfo.pipe_peerhandle) + } + if type == UInt32(PROX_FDTYPE_SOCKET) { + var info = socket_fdinfo() + let size = Int32(MemoryLayout.stride) + guard proc_pidfdinfo(pid, fd, PROC_PIDFDSOCKETINFO, &info, size) == size else { + throw InspectionFailure(reason: "socket \(fd) of process \(pid) could not be inspected", code: errno) + } + guard info.psi.soi_family == AF_UNIX, info.psi.soi_kind == SOCKINFO_UN else { return .other } + return .socket(pcb: info.psi.soi_pcb, peer: info.psi.soi_proto.pri_un.unsi_conn_pcb) + } + return .other + } + + private static func stdoutEndpoint() throws -> Endpoint { + var status = stat() + guard fstat(STDOUT_FILENO, &status) == 0 else { + throw InspectionFailure(reason: "vault stdout could not be inspected") + } + switch status.st_mode & mode_t(S_IFMT) { + case mode_t(S_IFIFO): + return try endpoint(getpid(), fd: STDOUT_FILENO, type: UInt32(PROX_FDTYPE_PIPE)) + case mode_t(S_IFSOCK): + return try endpoint(getpid(), fd: STDOUT_FILENO, type: UInt32(PROX_FDTYPE_SOCKET)) + default: + return .other + } + } + + private static func processFDs(_ pid: pid_t, output: Endpoint) -> ProcessFDs { + do { + let fds = try descriptors(pid) + var endpoints: [Endpoint] = [] + for fd in fds { + switch (output, fd.proc_fdtype) { + case (.pipe, UInt32(PROX_FDTYPE_PIPE)), (.socket, UInt32(PROX_FDTYPE_SOCKET)): + endpoints.append(try endpoint(pid, fd: fd.proc_fd, type: fd.proc_fdtype)) + default: + break + } + } + return ProcessFDs(pid: pid, endpoints: endpoints) + } catch let error as InspectionFailure { + if error.code == ESRCH || hasExited(pid) { + return ProcessFDs(pid: pid, endpoints: nil, failure: .exited) + } + // Only a refused fd TABLE qualifies for the protected-system exception. A descriptor + // changing or becoming unreadable inside an otherwise readable table still denies release. + let failure: ReadFailure? = error.fdTable + ? .unavailable(code: error.code, executable: executablePath(pid)) : nil + return ProcessFDs(pid: pid, endpoints: nil, failure: failure) + } catch { + return ProcessFDs(pid: pid, endpoints: nil, failure: hasExited(pid) ? .exited : nil) + } + } + + static func releaseRejection(caller: Identity?) -> String? { + do { + let output = try stdoutEndpoint() + guard output.connected else { return "vault stdout must be a connected pipe or Unix-domain socket" } + let pids = try userPids() + let tables = pids.sorted().map { processFDs($0, output: output) } + let currentPids = try userPids() + // Exits cannot introduce a new reader; new processes or uid changes still require a fresh request. + guard currentPids.isSubset(of: pids), pids.subtracting(currentPids).allSatisfy(hasExited) else { + return "the user's process list changed during vault recipient inspection" + } + guard try stdoutEndpoint() == output else { return "vault stdout changed during recipient inspection" } + return rejection(stdout: output, caller: caller, current: caller.flatMap { identity($0.pid) }, processes: tables) + } catch let error as InspectionFailure { + return error.reason + } catch { + return "the vault recipient could not be inspected" + } + } +} diff --git a/src/macos/GenesisTools/Sources/VaultUnlock.swift b/src/macos/GenesisTools/Sources/VaultUnlock.swift new file mode 100644 index 0000000000..aec54cdc97 --- /dev/null +++ b/src/macos/GenesisTools/Sources/VaultUnlock.swift @@ -0,0 +1,833 @@ +// The vault key's approval window (`--rpc {"method":"vault.unlock"}`), Martin's DECISION 131 b (2026-10-15): +// "App approval window, but it probably should have enough context so i know exactly what / who / which session / +// triggered that and why (each caller should give that context possibly)". +// +// A `gt-` process needs the GenesisTools vault master key. Before this, each tool's own bun link read the +// keychain item and macOS asked "gt-claude wants to use your confidential information" with nothing to judge. Now the +// tool starts this process, and the app hands the key on after its own window. +// +// WHO comes from the kernel, never from the request: this process is the caller's child, so `getppid()` is the caller, +// and its executable, script, arguments, environment (the session ids), working folder and parent chain are read from +// there. WHY and WHAT come from the request, because only the caller knows them. A remembered approval for the same +// executable and script skips the window; otherwise Touch ID confirms an allow. Every outcome goes to the gate's audit +// log. A deny, a cancel, a failed authentication and a timeout all reply "deny" with a reason. +// +// Second version, after Martin's first look (2026-10-15 00:28, "this can be probably nicer", and "it wanted touchid +// and when i gave it it wanted password 2 times always"): a SwiftUI panel instead of an NSAlert (whose modal loop +// also kept the 170 s deadline from ever firing), `bun run