From 609462f2a3db35aa6148e898facf4d53c76a66f4 Mon Sep 17 00:00:00 2001 From: JoshuaVSherman Date: Fri, 28 Aug 2026 12:45:23 -0400 Subject: [PATCH] feat(hooks): refuse the Gate 2 approval-token write unless a filing skill authorized it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/write_issue_approval_token.ts now refuses to write unless the most recent own-session, non-sidechain user turn invoked /design-issue or /file-issue, and a dispatched subagent's own turn is refused regardless of what an authorizing turn elsewhere in the transcript says (web-jam-tools#808, decision 21 of design-issue-enhancements-design-2026-08-23.md). - hooks/lib/check_token_write_authorization.ts: the pure, testable decision — scans backward through own-session user turns (hooks/lib/select_transcript_entry.ts's isOwnSessionUserTurnBoundary) for the most recent /design-issue or /file-issue invocation, mirroring decision 17's opus-delegation-gate.sh scan-for-most-recent approach rather than checking only the literal last turn (which would break /design-issue's multi-turn Gate 2 flow). - scripts/write_issue_approval_token.ts: resolveClaudeCodeWriteContext() locates the invoking session's own transcript by globbing every ~/.claude/projects/*/ directory for a file named .jsonl, since a bare CLI script has no hook-delivered transcript_path and the project-slug cannot be reconstructed from Deno.cwd() once a prior `cd` (e.g. into a /work-issue worktree) has moved it. resolveAntigravityWriteContext() is a documented best-effort fallback via hooks/lib/agy_hook_shim.ts's existing /tmp/agy-hook-invocations.jsonl record, with an acknowledged limitation: Antigravity's transcript shape carries no in-band subagent marker (web-jam-tools#841 non-goals), so isSubagentInvocation is only computed mechanically on Claude Code (tailIsCurrentlySidechain). - New --transcript-path/--conversation-id CLI flags let a caller (or a test) supply the authorization context explicitly instead of relying on auto-discovery. - buildApprovalToken/writeApprovalToken/writeApprovalTokenSync stay unauthorized on purpose — the CLI's import.meta.main block is the only caller in this repo and is now the sole gated entry point. Bumps deno.json to 1.32.33. --- deno.json | 2 +- hooks/lib/check_token_write_authorization.ts | 142 ++++++++ scripts/write_issue_approval_token.ts | 194 +++++++++- test/write_issue_approval_token.test.ts | 355 ++++++++++++++++++- 4 files changed, 681 insertions(+), 12 deletions(-) create mode 100644 hooks/lib/check_token_write_authorization.ts diff --git a/deno.json b/deno.json index 7c5207e8..4be4c452 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/denoland/deno/main/cli/schemas/config-file.v1.json", "name": "@webjam/tools", - "version": "1.32.32", + "version": "1.32.33", "exports": "./src/uptime/cron.ts", "tasks": { "sheet-music:generate": "deno run --allow-read --allow-write src/sheet-music/generate_docx.ts", diff --git a/hooks/lib/check_token_write_authorization.ts b/hooks/lib/check_token_write_authorization.ts new file mode 100644 index 00000000..46e2d4b3 --- /dev/null +++ b/hooks/lib/check_token_write_authorization.ts @@ -0,0 +1,142 @@ +/** + * Authorization check for scripts/write_issue_approval_token.ts (web-jam-tools#808). + * + * Decision 21 of ~/Dropbox/web-jam-llms/Token_Savings/design-issue-enhancements-design-2026-08-23.md: + * invoking a skill is authorization for what that skill does, so the token writer refuses unless + * the most recent non-sidechain, own-session user turn invoked one of the two filing skills — + * design-issue or file-issue — and a dispatched subagent never writes a token at all. This is the + * same mechanism decision 17 establishes for the work-issue grant (hooks/opus-delegation-gate.sh), + * applied a second time: it scans the transcript for the most recent authorizing invocation rather + * than requiring it to be the literal last message, because a `/design-issue` run's Gate 2 approval + * routinely arrives many turns after the `/design-issue` invocation itself, and requiring the + * literal last turn would break that legitimate flow (see file header note on scan-vs-last-turn + * below). It is built on hooks/lib/select_transcript_entry.ts's surface-aware reader (web-jam-tools#841). + * + * Kept separate from scripts/write_issue_approval_token.ts (rather than inlined) so the pure + * decision logic is testable via injected entries, matching the pattern + * hooks/lib/check_issue_approval_token.ts's decide() and getOpusGateInfo() already use — the CLI + * script's `import.meta.main` block is the only place that does real file I/O. + */ + +import { + extractEntryText, + isOwnSessionUserTurnBoundary, + type TranscriptEntry, +} from "./select_transcript_entry.ts"; + +/** The two skills decision 21 recognizes as authorizing a token write. Both are filing paths Josh + * invokes directly — design-issue reaches filing through its own plan gate, file-issue is the + * standalone path — so recognizing only one would leave the other permanently unable to file. */ +export const AUTHORIZING_FILING_SKILLS = ["design-issue", "file-issue"] as const; +export type FilingSkill = typeof AUTHORIZING_FILING_SKILLS[number]; + +/** + * Returns the filing skill a piece of user-turn text invokes as a slash command, or null. + * + * Anchored to the START of the (trimmed) text, not a substring match anywhere in it: a slash + * command is only recognized by either surface when it opens the message, so prose that merely + * mentions "/file-issue" mid-sentence (discussing the skill, not invoking it) must not count — + * the same mention-vs-use distinction this repo's other banned-phrase/invocation checks apply. + */ +export function filingSkillInvoked(text: string): FilingSkill | null { + const trimmed = text.trim().toLowerCase(); + for (const skill of AUTHORIZING_FILING_SKILLS) { + const token = `/${skill}`; + if (trimmed === token || trimmed.startsWith(`${token} `) || trimmed.startsWith(`${token}\n`)) { + return skill; + } + } + return null; +} + +export interface TokenWriteAuthorizationContext { + /** The transcript entries to scan — the invoking session's own transcript. */ + entries: readonly TranscriptEntry[]; + /** Claude Code: the session id. Antigravity: the conversationId. Empty/null means undetermined + * — the check fails closed rather than guessing. */ + ownConversationId: string | null; + /** + * True when THIS invocation is itself happening inside a dispatched subagent's turn, so the + * write must be refused regardless of what an authorizing turn elsewhere in the transcript says + * (acceptance criterion: "A dispatched subagent is refused a token write even when an + * authorizing skill invocation is present on the most recent user turn"). Claude Code computes + * this mechanically (see tailIsCurrentlySidechain below); Antigravity has no reliable direct + * signal for it (see resolveAntigravityWriteContext's doc comment) and passes false, relying on + * the scan below instead — a dispatched subagent's own composed prompt is virtually never a + * literal "/file-issue"/"/design-issue" invocation, so the scan denies it in practice even + * without this flag, though not as an adversarial-proof guarantee. This is a real, acknowledged + * asymmetry between surfaces, not a gap silently assumed closed. + */ + isSubagentInvocation: boolean; +} + +export interface TokenWriteAuthorizationResult { + ok: boolean; + reason?: string; + /** Which skill's invocation satisfied the check, when ok is true. */ + skill?: FilingSkill; +} + +/** + * Decides whether scripts/write_issue_approval_token.ts may write a token for this invocation. + * + * Scans the transcript BACKWARD (most recent first) for the first own-session user turn + * (isOwnSessionUserTurnBoundary — already excludes another conversation's/subagent's entries) that + * invokes /design-issue or /file-issue, mirroring decision 17's opus-delegation-gate.sh mechanism: + * that gate explicitly rejected "read the literal last user turn alone" as an alternative, because + * the grant would die on the user's very next message — the exact failure a single-turn check has + * here too, since /design-issue's Gate 2 approval routinely lands many turns after the + * /design-issue invocation itself. Scanning for the most recent occurrence (not just the latest + * turn) is what keeps that legitimate multi-turn flow working; the resulting token's own bounded + * expiry (4h TTL, unchanged by this fix) is what keeps a scan-based grant from being unboundedly + * stale, the same way decision 17's branch-scoping bounds its own grant. + */ +export function checkTokenWriteAuthorization( + ctx: TokenWriteAuthorizationContext, +): TokenWriteAuthorizationResult { + if (ctx.isSubagentInvocation) { + return { + ok: false, + reason: + "Refused: this invocation is a dispatched subagent's own turn. A subagent never writes an approval token — the orchestrating session asks Josh and writes it.", + }; + } + + if (!ctx.ownConversationId) { + return { + ok: false, + reason: + "Refused: could not determine this invocation's own session/conversation identity, so the authorizing invocation cannot be verified. Failing closed rather than guessing.", + }; + } + + for (let i = ctx.entries.length - 1; i >= 0; i--) { + const entry = ctx.entries[i]; + if (!isOwnSessionUserTurnBoundary(entry, ctx.ownConversationId)) continue; + const skill = filingSkillInvoked(extractEntryText(entry)); + if (skill) return { ok: true, skill }; + } + + return { + ok: false, + reason: + "Refused: no /design-issue or /file-issue invocation found in this session's own transcript. Get Josh's explicit approval for this plan first, or ask him directly.", + }; +} + +/** + * Claude Code only: true when the transcript's own tail — the entry most recently written, + * immediately preceding the tool call now running this script — sits inside a sidechain. Claude + * Code interleaves a subagent's entries into the SAME transcript file flagged isSidechain: true, + * written before the tool it describes executes (the same guarantee PreToolUse hooks already rely + * on to see the current call in their own transcript_path read), so the last entry reliably + * reflects whether THIS invocation belongs to a subagent's turn rather than the main thread. + * + * Always false for an Antigravity transcript (no isSidechain field exists there at all) — that + * surface's subagent detection is handled by the caller passing isSubagentInvocation itself; this + * function only ever answers the Claude Code half of that question. + */ +export function tailIsCurrentlySidechain(entries: readonly TranscriptEntry[]): boolean { + const last = entries[entries.length - 1]; + return Boolean(last && typeof last === "object" && last.isSidechain === true); +} diff --git a/scripts/write_issue_approval_token.ts b/scripts/write_issue_approval_token.ts index c04dc235..924c424b 100644 --- a/scripts/write_issue_approval_token.ts +++ b/scripts/write_issue_approval_token.ts @@ -12,6 +12,15 @@ * Default token path: $HOME/.claude/state/issue-approval-token.json * Supports path override via ISSUE_APPROVAL_TOKEN_PATH env var or --token-path flag. * + * web-jam-tools#808: the CLI invocation below refuses to write at all unless + * hooks/lib/check_token_write_authorization.ts's decision 21 check passes — the most recent + * own-session, non-sidechain user turn must have invoked /design-issue or /file-issue, and the + * invocation must not itself be a dispatched subagent's own turn. See resolveWriteContext() below + * for how the transcript to check is located on each surface. The exported buildApprovalToken/ + * writeApprovalToken/writeApprovalTokenSync functions themselves stay unchanged and unauthorized — + * they are the mechanical "write this already-authorized token" primitives the CLI block calls + * only after authorizeWrite() passes; nothing else in this repo imports them directly. + * * CLI usage: * deno run --allow-env --allow-read --allow-write scripts/write_issue_approval_token.ts \ * --session-id "" \ @@ -22,10 +31,13 @@ import { dirname } from "@std/path"; import { parseArgs } from "@std/cli/parse-args"; +import { type ApprovalToken, defaultTokenPath } from "../hooks/lib/check_issue_approval_token.ts"; +import { loadTranscript, type TranscriptEntry } from "../hooks/lib/select_transcript_entry.ts"; import { - type ApprovalToken, - defaultTokenPath, -} from "../hooks/lib/check_issue_approval_token.ts"; + checkTokenWriteAuthorization, + tailIsCurrentlySidechain, + type TokenWriteAuthorizationResult, +} from "../hooks/lib/check_token_write_authorization.ts"; export interface WriteApprovalTokenOptions { sessionId: string; @@ -112,6 +124,152 @@ export function writeApprovalTokenSync( return { token, path }; } +/** + * Resolved authorization-check inputs for one CLI invocation: the transcript entries to scan, + * whose conversation counts as "own", and whether this call is itself a subagent's turn. + */ +export interface ResolvedWriteContext { + entries: TranscriptEntry[]; + ownConversationId: string | null; + isSubagentInvocation: boolean; +} + +/** + * Claude Code discovery: finds THIS session's own transcript file purely from its session id, with + * no hook-delivered payload to read it from (this script is a plain CLI, not a hook). Transcripts + * live at ~/.claude/projects//.jsonl, but the slug is derived from + * wherever the Claude Code process was originally launched — NOT the script's current working + * directory, which a prior `cd` (e.g. into a /work-issue worktree) may have moved — so the slug + * cannot be reconstructed from Deno.cwd(). Session ids are UUIDs, so searching every project + * directory for a file named exactly ".jsonl" is unambiguous and avoids needing the + * slug algorithm at all. + */ +export async function resolveClaudeCodeWriteContext( + sessionId: string, +): Promise { + if (!sessionId) return null; + const home = Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || "/home/joshua"; + const projectsDir = `${home}/.claude/projects`; + let matchPath: string | null = null; + try { + for await (const projectEntry of Deno.readDir(projectsDir)) { + if (!projectEntry.isDirectory) continue; + const candidate = `${projectsDir}/${projectEntry.name}/${sessionId}.jsonl`; + try { + await Deno.stat(candidate); + matchPath = candidate; + break; + } catch { + continue; + } + } + } catch { + return null; + } + if (!matchPath) return null; + const entries = await loadTranscript(matchPath); + return { + entries, + ownConversationId: sessionId, + isSubagentInvocation: tailIsCurrentlySidechain(entries), + }; +} + +/** + * Antigravity discovery: agy has no hook-delivered payload here either, and unlike Claude Code its + * transcript path cannot be located from a session id (Antigravity keys transcripts by + * conversationId, which this script is never told directly). The one existing, real record of that + * identity is hooks/lib/agy_hook_shim.ts's recordInvocation() — already writing every agy tool + * call's full payload (conversationId, transcriptPath) to AGY_HOOK_RECORD_PATH (default + * /tmp/agy-hook-invocations.jsonl) for an unrelated purpose (web-jam-tools#816). Because at least + * one hook is registered against every command agy runs (".*::agy-model-guard.sh"), and recording + * happens before the matched hook decides, THIS invocation's own run_command call is normally the + * most recently recorded line by the time this script starts — so the last parseable line's + * conversationId/transcriptPath is normally this invocation's own. + * + * Known limitation, stated rather than silently assumed away: this is a shared, cross-session log. + * Under genuine concurrent agy activity the last line could belong to a different session's call + * instead, and Antigravity's own transcript shape carries no in-band signal distinguishing a + * subagent's turn from a person's (web-jam-tools#841 non-goals) — so unlike + * resolveClaudeCodeWriteContext, this cannot compute isSubagentInvocation directly and always + * returns false for it, leaning on checkTokenWriteAuthorization's text scan instead (see that + * function's isSubagentInvocation doc comment for why that still denies a dispatched subagent's + * own composed prompt in practice, short of an adversarial one). + */ +export async function resolveAntigravityWriteContext(): Promise { + const recordPath = Deno.env.get("AGY_HOOK_RECORD_PATH") || "/tmp/agy-hook-invocations.jsonl"; + let text: string; + try { + text = await Deno.readTextFile(recordPath); + } catch { + return null; + } + const lines = text.split("\n").filter((l) => l.trim().length > 0); + for (let i = lines.length - 1; i >= 0; i--) { + let parsed: unknown; + try { + parsed = JSON.parse(lines[i]); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object") continue; + const rec = parsed as Record; + const conversationId = typeof rec.conversationId === "string" ? rec.conversationId : ""; + const transcriptPath = typeof rec.transcriptPath === "string" ? rec.transcriptPath : ""; + if (!conversationId || !transcriptPath) continue; + let entries: TranscriptEntry[]; + try { + entries = await loadTranscript(transcriptPath); + } catch { + return null; + } + return { entries, ownConversationId: conversationId, isSubagentInvocation: false }; + } + return null; +} + +/** + * Resolves the authorization context for one real CLI invocation: explicit flags first (also how + * tests exercise the full path without needing real Claude Code/agy state on disk), then Claude + * Code discovery, then Antigravity discovery. Undetermined (no session id, no explicit transcript, + * neither surface's discovery finds anything) resolves to null conversation identity, which + * checkTokenWriteAuthorization refuses rather than guesses at. + */ +export async function resolveWriteContext( + options: { sessionId: string; transcriptPath?: string; conversationId?: string }, +): Promise { + if (options.transcriptPath) { + let entries: TranscriptEntry[]; + try { + entries = await loadTranscript(options.transcriptPath); + } catch { + entries = []; + } + const ownConversationId = options.conversationId || options.sessionId || null; + return { + entries, + ownConversationId, + isSubagentInvocation: tailIsCurrentlySidechain(entries), + }; + } + + const claudeCode = await resolveClaudeCodeWriteContext(options.sessionId); + if (claudeCode) return claudeCode; + + const antigravity = await resolveAntigravityWriteContext(); + if (antigravity) return antigravity; + + return { entries: [], ownConversationId: null, isSubagentInvocation: false }; +} + +/** Runs the decision 21 authorization check for one CLI invocation. Exported for testing. */ +export async function authorizeWrite( + options: { sessionId: string; transcriptPath?: string; conversationId?: string }, +): Promise { + const context = await resolveWriteContext(options); + return checkTokenWriteAuthorization(context); +} + if (import.meta.main) { try { const args = parseArgs(Deno.args, { @@ -124,6 +282,8 @@ if (import.meta.main) { "expires-at", "ttl-hours", "token-path", + "transcript-path", + "conversation-id", ], boolean: ["json", "help"], collect: ["title"], @@ -137,10 +297,12 @@ if (import.meta.main) { }); if (args.help) { - console.log(`Usage: deno run --allow-env --allow-read --allow-write scripts/write_issue_approval_token.ts [options] + console.log( + `Usage: deno run --allow-env --allow-read --allow-write scripts/write_issue_approval_token.ts [options] Options: - -s, --session-id Session ID that received plan-gate approval (defaults to $CLAUDE_SESSION_ID or $SESSION_ID) + -s, --session-id Session ID that received plan-gate approval (defaults to + $CLAUDE_CODE_SESSION_ID, $CLAUDE_SESSION_ID, or $SESSION_ID) -r, --repo Target repository (e.g. WebJamApps/web-jam-tools) -t, --title Approved issue title (can be repeated) --titles <list|json> Approved titles as JSON array or comma-separated list @@ -148,13 +310,19 @@ Options: --ttl-hours <hours> Token TTL in hours (default: 4) --expires-at <iso> Explicit expiration ISO 8601 timestamp -p, --token-path <path> Override token output path (defaults to $ISSUE_APPROVAL_TOKEN_PATH or ~/.claude/state/issue-approval-token.json) + --transcript-path <path> Override transcript auto-discovery (web-jam-tools#808) with an explicit + transcript file to scan for the authorizing skill invocation + --conversation-id <id> Own conversation identity to check the transcript against (web-jam-tools#808); + defaults to --session-id when a transcript is given without it --json Output written token as JSON to stdout -h, --help Show this help message -`); +`, + ); Deno.exit(0); } const sessionId = args["session-id"] || + Deno.env.get("CLAUDE_CODE_SESSION_ID") || Deno.env.get("CLAUDE_SESSION_ID") || Deno.env.get("SESSION_ID") || ""; @@ -202,6 +370,20 @@ Options: const expiresAt = args["expires-at"]; const tokenPath = args["token-path"]; + // web-jam-tools#808: refuse unless the most recent own-session, non-sidechain user turn + // invoked /design-issue or /file-issue, and never for a dispatched subagent's own turn — see + // hooks/lib/check_token_write_authorization.ts for the decision and this file's + // resolveWriteContext() for how each surface's transcript is located. + const authorization = await authorizeWrite({ + sessionId, + transcriptPath: args["transcript-path"], + conversationId: args["conversation-id"], + }); + if (!authorization.ok) { + console.error(`Refused to write approval token: ${authorization.reason}`); + Deno.exit(1); + } + const { token, path } = await writeApprovalToken({ sessionId, repo, diff --git a/test/write_issue_approval_token.test.ts b/test/write_issue_approval_token.test.ts index 54f030b9..f519d12d 100644 --- a/test/write_issue_approval_token.test.ts +++ b/test/write_issue_approval_token.test.ts @@ -3,14 +3,32 @@ // Unit tests and round-trip integration tests for scripts/write_issue_approval_token.ts // validating that the written approval token is accepted by // hooks/lib/check_issue_approval_token.ts and hooks/require-approval-token-on-issue-write.sh. +// +// web-jam-tools#808: the CLI block now refuses to write at all unless +// hooks/lib/check_token_write_authorization.ts's decision 21 check passes, so every CLI-level test +// below that expects a successful write supplies --transcript-path/--conversation-id pointing at a +// fixture transcript carrying an authorizing /file-issue or /design-issue turn (see +// writeAuthorizingTranscriptFixture below) — a plain CLI invocation with nothing authorizing it is +// exactly what "CLI: refuses ..." further down exercises. buildApprovalToken/writeApprovalToken/ +// writeApprovalTokenSync stay unauthorized on purpose (see file header on scripts/ +// write_issue_approval_token.ts) so the pre-existing unit tests of those three functions are +// unchanged below. import { assert, assertEquals, assertThrows } from "@std/assert"; import { + authorizeWrite, buildApprovalToken, + resolveClaudeCodeWriteContext, writeApprovalToken, writeApprovalTokenSync, } from "../scripts/write_issue_approval_token.ts"; import { checkIssueApprovalToken, loadToken } from "../hooks/lib/check_issue_approval_token.ts"; +import { + checkTokenWriteAuthorization, + filingSkillInvoked, + tailIsCurrentlySidechain, +} from "../hooks/lib/check_token_write_authorization.ts"; +import type { TranscriptEntry } from "../hooks/lib/select_transcript_entry.ts"; const SCRIPT_PATH = new URL( "../scripts/write_issue_approval_token.ts", @@ -22,7 +40,10 @@ const HOOK_PATH = new URL( import.meta.url, ).pathname; -async function runCli(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { +async function runCli( + args: string[], + env?: Record<string, string>, +): Promise<{ code: number; stdout: string; stderr: string }> { const cmd = new Deno.Command("deno", { args: [ "run", @@ -34,6 +55,10 @@ async function runCli(args: string[]): Promise<{ code: number; stdout: string; s ], stdout: "piped", stderr: "piped", + // clearEnv is required for `env` to actually override an inherited var — otherwise + // Deno.Command MERGES `env` with the parent's environment rather than replacing it, so a + // deleted key here would still show up in the child from ambient inheritance. + ...(env ? { env, clearEnv: true } : {}), }); const { code, stdout, stderr } = await cmd.output(); return { @@ -43,6 +68,44 @@ async function runCli(args: string[]): Promise<{ code: number; stdout: string; s }; } +/** Env for a CLI test that must NOT inherit this real Claude Code session's own ambient + * CLAUDE_CODE_SESSION_ID/CLAUDE_SESSION_ID/SESSION_ID — used only by tests that deliberately omit + * --session-id and would otherwise silently pick up this test run's own real session id. */ +function envWithoutAmbientSessionId(): Record<string, string> { + const env = Deno.env.toObject(); + delete env.CLAUDE_CODE_SESSION_ID; + delete env.CLAUDE_SESSION_ID; + delete env.SESSION_ID; + return env; +} + +/** + * Writes a fixture transcript (JSONL, Claude Code shape) to `dir`, and returns + * ["--transcript-path", path, "--conversation-id", "test-conv"] ready to splice into a runCli() + * call. Claude Code-shaped entries ignore --conversation-id entirely (isOwnSessionUserTurnBoundary + * delegates straight to isUserTurnBoundary for them), so the value only matters for the + * Antigravity-shaped fixtures used further down. + */ +async function writeAuthorizingTranscriptFixture( + dir: string, + text: string, + opts?: { sidechainTail?: boolean }, +): Promise<string[]> { + const path = `${dir}/transcript.jsonl`; + const lines: unknown[] = [ + { type: "user", message: { role: "user", content: text } }, + ]; + if (opts?.sidechainTail) { + lines.push({ + type: "assistant", + isSidechain: true, + message: { role: "assistant", content: "working on the delegated task" }, + }); + } + await Deno.writeTextFile(path, lines.map((l) => JSON.stringify(l)).join("\n") + "\n"); + return ["--transcript-path", path, "--conversation-id", "test-conv"]; +} + async function runHook( payload: Record<string, unknown>, tokenPath: string, @@ -195,6 +258,7 @@ Deno.test("CLI: writes token via repeated --title arguments", async () => { const dir = await Deno.makeTempDir(); const tokenPath = `${dir}/cli-token.json`; try { + const authArgs = await writeAuthorizingTranscriptFixture(dir, "/file-issue do the thing"); const res = await runCli([ "--session-id", "cli-session", @@ -206,6 +270,7 @@ Deno.test("CLI: writes token via repeated --title arguments", async () => { "Title Two", "--token-path", tokenPath, + ...authArgs, ]); assertEquals(res.code, 0, res.stderr); @@ -223,6 +288,7 @@ Deno.test("CLI: writes token via --titles JSON array and --json stdout", async ( const dir = await Deno.makeTempDir(); const tokenPath = `${dir}/cli-token-json.json`; try { + const authArgs = await writeAuthorizingTranscriptFixture(dir, "/design-issue plan the thing"); const res = await runCli([ "--session-id", "json-session", @@ -233,6 +299,7 @@ Deno.test("CLI: writes token via --titles JSON array and --json stdout", async ( "--token-path", tokenPath, "--json", + ...authArgs, ]); assertEquals(res.code, 0, res.stderr); @@ -254,6 +321,7 @@ Deno.test("CLI: writes token via --titles-file argument", async () => { const tokenPath = `${dir}/file-token.json`; try { await Deno.writeTextFile(titlesFilePath, "File Title 1\nFile Title 2\n"); + const authArgs = await writeAuthorizingTranscriptFixture(dir, "/file-issue do the thing"); const res = await runCli([ "--session-id", "file-session", @@ -263,6 +331,7 @@ Deno.test("CLI: writes token via --titles-file argument", async () => { titlesFilePath, "--token-path", tokenPath, + ...authArgs, ]); assertEquals(res.code, 0, res.stderr); @@ -273,10 +342,286 @@ Deno.test("CLI: writes token via --titles-file argument", async () => { } }); -Deno.test("CLI: fails with exit code 1 when required arguments are missing", async () => { - const res = await runCli(["--repo", "web-jam-tools"]); - assertEquals(res.code, 1); - assert(res.stderr.includes("sessionId is required")); +Deno.test("CLI: fails with exit code 1 when required arguments are missing (authorized invocation)", async () => { + const dir = await Deno.makeTempDir(); + try { + // Authorized (an authorizing transcript + explicit --conversation-id, deliberately with no + // --session-id) so this exercises buildApprovalToken's OWN validation, not web-jam-tools#808's + // authorization gate — see the next test for the unauthorized-invocation case. + const authArgs = await writeAuthorizingTranscriptFixture(dir, "/file-issue do the thing"); + const res = await runCli( + ["--repo", "web-jam-tools", ...authArgs], + envWithoutAmbientSessionId(), + ); + assertEquals(res.code, 1); + assert(res.stderr.includes("sessionId is required")); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +// --- web-jam-tools#808: authorization-gate regression tests --- +// +// Each of these fails against the pre-#808 code: without checkTokenWriteAuthorization gating the +// CLI block, EVERY invocation below would exit 0 and write a token regardless of transcript +// content, since no such gate existed at all. + +Deno.test("CLI: refuses when no authorizing skill invocation is found anywhere in the transcript", async () => { + const dir = await Deno.makeTempDir(); + const tokenPath = `${dir}/should-not-exist.json`; + try { + const authArgs = await writeAuthorizingTranscriptFixture( + dir, + "please go file this issue for me", + ); + const res = await runCli([ + "--session-id", + "unauthorized-session", + "--repo", + "web-jam-tools", + "--title", + "Some title", + "--token-path", + tokenPath, + ...authArgs, + ]); + assertEquals(res.code, 1); + assert(res.stderr.includes("Refused to write approval token")); + assert(res.stderr.includes("no /design-issue or /file-issue invocation found")); + const exists = await Deno.stat(tokenPath).then(() => true).catch(() => false); + assertEquals(exists, false); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("CLI: refuses a token write when the current invocation is a dispatched subagent's own turn, even though an authorizing invocation is present", async () => { + const dir = await Deno.makeTempDir(); + const tokenPath = `${dir}/should-not-exist.json`; + try { + // The orchestrator's own turn WAS an authorizing /file-issue invocation, but the transcript's + // tail — the entry immediately preceding THIS tool call — is flagged isSidechain: true, i.e. + // this call belongs to a dispatched subagent's turn, not the main thread's. + const authArgs = await writeAuthorizingTranscriptFixture( + dir, + "/file-issue do the thing", + { sidechainTail: true }, + ); + const res = await runCli([ + "--session-id", + "subagent-session", + "--repo", + "web-jam-tools", + "--title", + "Some title", + "--token-path", + tokenPath, + ...authArgs, + ]); + assertEquals(res.code, 1); + assert(res.stderr.includes("dispatched subagent")); + const exists = await Deno.stat(tokenPath).then(() => true).catch(() => false); + assertEquals(exists, false); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("CLI: succeeds when the most recent authorizing turn invoked /design-issue, several turns before the write (Gate 2 flow)", async () => { + const dir = await Deno.makeTempDir(); + const tokenPath = `${dir}/design-issue-authorized.json`; + try { + const path = `${dir}/transcript.jsonl`; + const lines = [ + { type: "user", message: { role: "user", content: "/design-issue token savings" } }, + { type: "assistant", message: { role: "assistant", content: "Here's the design doc..." } }, + { type: "user", message: { role: "user", content: "looks good, approved" } }, + { type: "assistant", message: { role: "assistant", content: "Writing the approval token." } }, + ]; + await Deno.writeTextFile(path, lines.map((l) => JSON.stringify(l)).join("\n") + "\n"); + + const res = await runCli([ + "--session-id", + "design-issue-session", + "--repo", + "web-jam-tools", + "--title", + "Some title", + "--token-path", + tokenPath, + "--transcript-path", + path, + "--conversation-id", + "test-conv", + ]); + assertEquals(res.code, 0, res.stderr); + const loaded = loadToken(tokenPath); + assert(loaded !== null); + assertEquals(loaded?.session_id, "design-issue-session"); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("CLI: succeeds when the most recent authorizing turn invoked /file-issue", async () => { + const dir = await Deno.makeTempDir(); + const tokenPath = `${dir}/file-issue-authorized.json`; + try { + const authArgs = await writeAuthorizingTranscriptFixture( + dir, + "/file-issue add zipCode to the Venue model", + ); + const res = await runCli([ + "--session-id", + "file-issue-session", + "--repo", + "web-jam-tools", + "--title", + "Some title", + "--token-path", + tokenPath, + ...authArgs, + ]); + assertEquals(res.code, 0, res.stderr); + const loaded = loadToken(tokenPath); + assert(loaded !== null); + assertEquals(loaded?.session_id, "file-issue-session"); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +// --- checkTokenWriteAuthorization / filingSkillInvoked / tailIsCurrentlySidechain unit tests --- + +Deno.test("filingSkillInvoked: recognizes /file-issue and /design-issue at the start of the text", () => { + assertEquals(filingSkillInvoked("/file-issue add a thing"), "file-issue"); + assertEquals(filingSkillInvoked("/design-issue plan a thing"), "design-issue"); + assertEquals(filingSkillInvoked("/file-issue"), "file-issue"); + assertEquals(filingSkillInvoked(" /design-issue \nmore text"), "design-issue"); +}); + +Deno.test("filingSkillInvoked: does not match a mid-sentence mention, only an invocation", () => { + assertEquals(filingSkillInvoked("let's talk about /file-issue later"), null); + assertEquals( + filingSkillInvoked("the /design-issue skill handles this, but first..."), + null, + ); + assertEquals(filingSkillInvoked("/file-issueX add a thing"), null); +}); + +Deno.test("checkTokenWriteAuthorization: refuses when isSubagentInvocation is true regardless of transcript content", () => { + const entries: TranscriptEntry[] = [ + { type: "user", message: { role: "user", content: "/file-issue do the thing" } }, + ]; + const result = checkTokenWriteAuthorization({ + entries, + ownConversationId: "sess-1", + isSubagentInvocation: true, + }); + assertEquals(result.ok, false); + assert(result.reason?.includes("dispatched subagent")); +}); + +Deno.test("checkTokenWriteAuthorization: refuses when ownConversationId is undetermined", () => { + const result = checkTokenWriteAuthorization({ + entries: [], + ownConversationId: null, + isSubagentInvocation: false, + }); + assertEquals(result.ok, false); + assert(result.reason?.includes("could not determine")); +}); + +Deno.test("checkTokenWriteAuthorization: finds the most recent authorizing turn, skipping a subagent's interleaved sidechain turns", () => { + const entries: TranscriptEntry[] = [ + { type: "user", message: { role: "user", content: "/file-issue add a thing" } }, + { type: "user", isSidechain: true, message: { role: "user", content: "/file-issue a decoy" } }, + { type: "assistant", isSidechain: true, message: { role: "assistant", content: "working" } }, + ]; + const result = checkTokenWriteAuthorization({ + entries, + ownConversationId: "sess-1", + isSubagentInvocation: false, + }); + assertEquals(result.ok, true); + assertEquals(result.skill, "file-issue"); +}); + +Deno.test("checkTokenWriteAuthorization: Antigravity entries only count when conversationId matches", () => { + const entries: TranscriptEntry[] = [ + { + type: "USER_INPUT", + source: "USER_EXPLICIT", + step_index: 0, + content: "/design-issue plan a thing", + conversationId: "parent-conv", + }, + { + type: "USER_INPUT", + source: "USER_EXPLICIT", + step_index: 0, + content: "/design-issue a subagent decoy", + conversationId: "subagent-conv", + }, + ]; + const result = checkTokenWriteAuthorization({ + entries, + ownConversationId: "parent-conv", + isSubagentInvocation: false, + }); + assertEquals(result.ok, true); + assertEquals(result.skill, "design-issue"); +}); + +Deno.test("tailIsCurrentlySidechain: true only when the LAST entry is flagged isSidechain", () => { + assertEquals( + tailIsCurrentlySidechain([ + { type: "user", message: { role: "user", content: "hi" } }, + { type: "assistant", isSidechain: true, message: { role: "assistant", content: "hi" } }, + ]), + true, + ); + assertEquals( + tailIsCurrentlySidechain([ + { type: "assistant", isSidechain: true, message: { role: "assistant", content: "hi" } }, + { type: "user", message: { role: "user", content: "hi" } }, + ]), + false, + ); + assertEquals(tailIsCurrentlySidechain([]), false); +}); + +Deno.test("resolveClaudeCodeWriteContext: returns null when no session id is given", async () => { + const result = await resolveClaudeCodeWriteContext(""); + assertEquals(result, null); +}); + +Deno.test("resolveClaudeCodeWriteContext: returns null when no matching transcript file exists", async () => { + const result = await resolveClaudeCodeWriteContext( + "definitely-not-a-real-session-id-web-jam-tools-808", + ); + assertEquals(result, null); +}); + +Deno.test("authorizeWrite: end-to-end via an explicit --transcript-path-equivalent options object", async () => { + const dir = await Deno.makeTempDir(); + try { + const path = `${dir}/transcript.jsonl`; + await Deno.writeTextFile( + path, + JSON.stringify({ type: "user", message: { role: "user", content: "/file-issue thing" } }) + + "\n", + ); + const result = await authorizeWrite({ + sessionId: "irrelevant-since-transcript-path-given", + transcriptPath: path, + conversationId: "test-conv", + }); + assertEquals(result.ok, true); + assertEquals(result.skill, "file-issue"); + } finally { + await Deno.remove(dir, { recursive: true }); + } }); // --- Round-trip integration tests with hook and reader (web-jam-tools#595) ---