diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75c945f..17861a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,7 +57,7 @@ jobs: run: npx tsc --noEmit - name: Security audit - run: npm audit --audit-level=moderate + run: npx audit-ci --config audit-ci.jsonc # Unit suite (unit tests + snapshot tests + property-based tests) - name: Unit tests diff --git a/.gitignore b/.gitignore index ebc8ac7..50f8fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,12 @@ web_modules/ # TypeScript cache *.tsbuildinfo +# TypeScript compilation output +*.js +*.d.ts +*.d.cts +*.d.mts + # Optional npm cache directory .npm @@ -148,3 +154,10 @@ vite.config.ts.timestamp-* .chunkhound.json .chunkhound/ .mcp.json + +# macOS +.DS_Store + +# PR review artifacts +AGENT_REVIEW.md +HUMAN_REVIEW.md diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..8160734 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run typecheck diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..231fa84 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# TUI Safety + +**Never use `console.debug/warn/error/log`** — writes to stdout/stderr corrupt pi's TUI ANSI rendering. Extension host runs in the same process. + +Use `ctx.ui.notify()` / `setStatus()` / `setWidget()` instead. For diagnostics, remove entirely. diff --git a/audit-ci.jsonc b/audit-ci.jsonc new file mode 100644 index 0000000..fdb63dd --- /dev/null +++ b/audit-ci.jsonc @@ -0,0 +1,19 @@ +{ + "$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json", + "moderate": true, + "allowlist": [ + // Upstream: pi-coding-agent ships a shrinkwrap that pins vulnerable transitive + // deps (ws, protobufjs, undici). The fix must happen in pi-coding-agent itself + // — nothing to do from this extension. + { "GHSA-96hv-2xvq-fx4p": { "active": true, "expiry": "2026-09-01", "notes": "ws <8.21.0 via pi-coding-agent shrinkwrap" } }, + { "GHSA-f38q-mgvj-vph7": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs <=7.6.2 via pi-coding-agent shrinkwrap" } }, + { "GHSA-wcpc-wj8m-hjx6": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs <=7.6.0 via pi-coding-agent shrinkwrap" } }, + { "GHSA-vmh5-mc38-953g|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, + { "GHSA-pr7r-676h-xcf6|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, + { "GHSA-38rv-x7px-6hhq|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, + { "GHSA-p88m-4jfj-68fv|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }, + { "GHSA-vxpw-j846-p89q|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } } + // Low-severity undici advisories (GHSA-35p6-xmwp-9g52, GHSA-g8m3-5g58-fq7m) are + // not allowlisted — the config gates moderate+ only, so they don't block CI. + ] +} diff --git a/handoff/command.ts b/handoff/command.ts index 314a389..053edec 100644 --- a/handoff/command.ts +++ b/handoff/command.ts @@ -23,11 +23,19 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat } state.pendingRequestedHandoff = { - direction, - enforcementAttempts: 0, toolCalled: false, + readonlyBypassActive: state.readonlyEnabled, + resumeReadonlyAfterHandoff: state.readonlyEnabled, + enforcementAttempts: 0, }; + if (ctx.hasUI && state.readonlyEnabled) { + ctx.ui.notify( + "Readonly is active. A temporary handoff-only exception is now enabled for this required handoff. The fresh context after handoff will resume in readonly mode.", + "info", + ); + } + // Show live progress indicator in footer if (ctx.hasUI && ctx.ui.theme) { ctx.ui.setStatus( @@ -36,8 +44,12 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat ); } + const readonlyNotice = state.readonlyEnabled + ? "\n\nReadonly remains active for normal mutations: write, edit, and non-temp bash writes stay blocked. A temporary exception allows the handoff tool for this request only. You must perform a real handoff now rather than continue normal work. The fresh context after compaction will resume in readonly mode with this handoff exception cleared, so draft the brief for a readonly continuation." + : "\n\nYou must perform a real handoff now rather than continue normal work."; + pi.sendUserMessage( - `Handoff direction: ${direction}\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.`, + `Handoff direction: ${direction}\n\nThe user explicitly requested /handoff. Prepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.${readonlyNotice}`, ctx.isIdle() ? undefined : { deliverAs: "followUp" }, ); }, diff --git a/handoff/compact.ts b/handoff/compact.ts index fb014f2..f2e0150 100644 --- a/handoff/compact.ts +++ b/handoff/compact.ts @@ -7,8 +7,6 @@ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "../state.js"; -import { clearActiveNotebookTopic } from "../notebook/topic.js"; -import { STATUS_KEY_HANDOFF } from "../tui.js"; function getImpossibleKeptId(branchEntries: SessionEntry[]): string { const leaf = branchEntries[branchEntries.length - 1]; @@ -16,20 +14,15 @@ function getImpossibleKeptId(branchEntries: SessionEntry[]): string { } export function registerHandoffCompaction(pi: ExtensionAPI, state: AgenticodingState): void { - pi.on("session_before_compact", async (event, ctx: ExtensionContext) => { + pi.on("session_before_compact", async (event, _ctx: ExtensionContext) => { const pending = state.pendingHandoff; if (!pending) { return; } state.pendingHandoff = null; - state.pendingRequestedHandoff = null; - clearActiveNotebookTopic(state); - - // Clear the handoff progress indicator now that compaction is consuming it - if (ctx.hasUI) { - ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); - } + // Keep pendingRequestedHandoff — compaction must complete successfully first. + // onComplete in handoff/tool.ts clears it after success; onError preserves it for retry. return { compaction: { diff --git a/handoff/tool.ts b/handoff/tool.ts index 790fa11..2e38d21 100644 --- a/handoff/tool.ts +++ b/handoff/tool.ts @@ -11,6 +11,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; +import { clearActiveNotebookTopic } from "../notebook/topic.js"; import type { AgenticodingState } from "../state.js"; import { STATUS_KEY_HANDOFF } from "../tui.js"; @@ -19,7 +20,7 @@ import { STATUS_KEY_HANDOFF } from "../tui.js"; * * Shape: handoff primer + original task. */ -function buildEnrichedTask(task: string): string { +export function buildEnrichedTask(task: string, options?: { resumeReadonlyAfterHandoff?: boolean }): string { const parts: string[] = [ "## Handoff — Continue Previous Work", "", @@ -29,12 +30,20 @@ function buildEnrichedTask(task: string): string { "- Use `notebook_index` to scan available pages when needed", "- Use `spawn` to delegate isolated subtasks to child agents", "- Build on notebook grounding and this brief rather than reconstructing old context", - "", - "## Task", - "", - task, ]; + if (options?.resumeReadonlyAfterHandoff) { + parts.push( + "", + "## Execution Constraints", + "", + "- Fresh context resumes in readonly mode.", + "- The temporary handoff-only exception used to reach this context is no longer active.", + "- Write, edit, and non-temp bash filesystem mutations remain blocked unless the user changes readonly mode.", + ); + } + + parts.push("", "## Task", "", task); return parts.join("\n"); } @@ -79,18 +88,25 @@ export function registerHandoffTool( }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const enrichedTask = buildEnrichedTask(params.task); + const requestedHandoff = state.pendingRequestedHandoff; + const enrichedTask = buildEnrichedTask(params.task, { + resumeReadonlyAfterHandoff: requestedHandoff?.resumeReadonlyAfterHandoff === true, + }); state.pendingHandoff = { task: enrichedTask, source: "tool" }; - if (state.pendingRequestedHandoff) { - state.pendingRequestedHandoff.toolCalled = true; + if (requestedHandoff) { + requestedHandoff.toolCalled = true; } ctx.compact({ onComplete: () => { + clearActiveNotebookTopic(state); + state.pendingRequestedHandoff = null; + if (ctx.hasUI) { + ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); + } pi.sendUserMessage("Proceed."); }, onError: () => { state.pendingHandoff = null; - // Safe: pendingRequestedHandoff may already be cleaned up by watchdog if (state.pendingRequestedHandoff) { state.pendingRequestedHandoff.toolCalled = false; } diff --git a/index.ts b/index.ts index f6506f0..facb912 100644 --- a/index.ts +++ b/index.ts @@ -12,8 +12,8 @@ * - state reset on /new */ -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { DynamicBorder } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, ExtensionContext, Skill, SlashCommandInfo } from "@earendil-works/pi-coding-agent"; +import { DynamicBorder, isToolCallEventType } from "@earendil-works/pi-coding-agent"; import { Container, type SelectItem, @@ -28,17 +28,141 @@ import { registerNotebookRehydration } from "./notebook/rehydration.js"; import { registerNotebookTopicTool } from "./notebook/topic-tool.js"; import { setActiveNotebookTopic } from "./notebook/topic.js"; import { registerHandoffTool } from "./handoff/tool.js"; +import { getReadonlyFromBranch } from "./readonly-rehydration.js"; import { registerHandoffCommand } from "./handoff/command.js"; import { registerHandoffCompaction } from "./handoff/compact.js"; import { registerSpawnTool } from "./spawn/index.js"; +import { + cacheLookupCommand, + cacheLookupCommandIssue, + cacheLookupSkill, + cacheLookupSkillIssue, + formatReadonlyFrontmatterIssue, + populateFromSkills, + populatePromptCacheFromResolvedCommandsAndDirs, + type ReadonlyCacheIssue, +} from "./readonly-cache.js"; import { STATUS_KEY_HANDOFF, + STATUS_KEY_READONLY, STATUS_KEY_TOPIC, WIDGET_KEY_WARNING, updateIndicators, } from "./tui.js"; +import { applyReadonlyBashGuard } from "./readonly-bash.js"; import { formatPagePreview } from "./notebook/store.js"; +// ── Helpers ──────────────────────────────────────────────────────────── + +/** + * Populate the readonly frontmatter cache from loaded skills and prompt + * commands/directories. Always called before toggle resolution so the cache + * is fresh for the current input. + */ +function populateReadonlyCache( + state: AgenticodingState, + event: { systemPromptOptions?: { skills?: Skill[] } }, + ctx: ExtensionContext, + pi: ExtensionAPI, +): void { + populateFromSkills(state, event.systemPromptOptions?.skills ?? []); + populatePromptCacheFromResolvedCommandsAndDirs(state, pi.getCommands(), ctx.cwd, ctx.isProjectTrusted()); +} + +function isPromptCommand(commands: SlashCommandInfo[], name: string): boolean { + return commands.some((command) => command.name === name && command.source === "prompt"); +} + +const READONLY_BYPASS_COMMANDS = new Set(["readonly", "notebook", "handoff"]); + +function isBuiltinReadonlyBypassCommand(name: string): boolean { + return READONLY_BYPASS_COMMANDS.has(name); +} + +function alignPendingReadonlyHandoff(state: AgenticodingState, readonly: boolean): void { + if (!state.pendingRequestedHandoff) return; + state.pendingRequestedHandoff.resumeReadonlyAfterHandoff = readonly; + state.pendingRequestedHandoff.readonlyBypassActive = readonly; +} + +function formatReadonlyCommandRef(command: { type: "skill" | "command"; name: string }): string { + return command.type === "skill" ? `/skill:${command.name}` : `/${command.name}`; +} + +function recordReadonlyFrontmatterIssue( + ctx: ExtensionContext, + pi: ExtensionAPI, + command: { type: "skill" | "command"; name: string }, + issue: ReadonlyCacheIssue, +): void { + pi.appendEntry("agenticoding-readonly-frontmatter-issue", { name: command.name, type: command.type, issue }); + if (ctx.hasUI) { + ctx.ui.notify(formatReadonlyFrontmatterIssue(formatReadonlyCommandRef(command), issue), "warning"); + } +} + +/** + * Consume any deferred readonly toggle recorded by the `input` handler. + * Must be called after `populateReadonlyCache` so the cache is populated. + */ +function consumePendingReadonlyToggle( + state: AgenticodingState, + ctx: ExtensionContext, + pi: ExtensionAPI, +): void { + const commands = pi.getCommands(); + // Readonly is a TUI-only feature. Headless/RPC sessions must not inherit a + // queued slash-command toggle, so drop any deferred intents here. + if (!ctx.hasUI) { + state.pendingReadonlyCommands.length = 0; + return; + } + + // Consume unknown/malformed queue entries until the first real readonly + // decision so a stale no-op slash command cannot delay the next valid toggle. + // Prompt commands may exist only on disk in Pi's standard prompt dirs, so + // command lookups trust the populated prompt cache instead of re-gating on + // the live registry here. Known non-prompt commands stay blocked because the + // cache builder marks their names as shadowed and never loads fallback files. + while (state.pendingReadonlyCommands.length > 0) { + const pendingCommand = state.pendingReadonlyCommands.shift(); + if (!pendingCommand) return; + + const readonly = pendingCommand.type === "skill" + ? cacheLookupSkill(state, pendingCommand.name) + : cacheLookupCommand(state, pendingCommand.name); + if (readonly === null) { + const issue = pendingCommand.type === "skill" + ? cacheLookupSkillIssue(state, pendingCommand.name) + : cacheLookupCommandIssue(state, pendingCommand.name); + if (issue) recordReadonlyFrontmatterIssue(ctx, pi, pendingCommand, issue); + continue; + } + + // Keep pending handoff aligned with the latest user intent even when the + // resolved frontmatter matches the current mode and produces no UI/log noise. + alignPendingReadonlyHandoff(state, readonly); + if (state.readonlyEnabled === readonly) { + return; + } + + state.readonlyEnabled = readonly; + state.readonlyNudgePending = true; + pi.appendEntry("agenticoding-readonly", { enabled: readonly }); + + if (ctx.hasUI) { + const commandRef = formatReadonlyCommandRef(pendingCommand); + ctx.ui.notify( + readonly + ? `Readonly mode enabled via \`${commandRef}\` frontmatter` + : `Readonly mode disabled via \`${commandRef}\` frontmatter`, + "info", + ); + } + return; + } +} + export default function (pi: ExtensionAPI): void { const state: AgenticodingState = createState(); @@ -56,6 +180,155 @@ export default function (pi: ExtensionAPI): void { // ── Register commands ─────────────────────────────────────────── registerHandoffCommand(pi, state); + // ── Readonly mode ─────────────────────────────────────────────── + + pi.registerFlag("readonly", { + description: "Start in readonly mode", + type: "boolean", + default: false, + }); + + function toggleReadonly(ctx: ExtensionContext): void { + if (!ctx.hasUI) return; // Toggle is a UI-only command, no-op in headless. + state.readonlyEnabled = !state.readonlyEnabled; + // Keep a pending readonly handoff aligned with the current mode so the + // compacted task reflects the latest user intent. + if (state.pendingRequestedHandoff) { + alignPendingReadonlyHandoff(state, state.readonlyEnabled); + if (state.readonlyEnabled) { + ctx.ui.notify( + "Pending handoff updated — the fresh context will resume in readonly mode.", + "info", + ); + } else { + ctx.ui.notify( + "Pending handoff readonly-continuation cleared — the fresh context will not resume in readonly mode.", + "info", + ); + } + } + state.readonlyNudgePending = true; + pi.appendEntry("agenticoding-readonly", { enabled: state.readonlyEnabled }); + updateIndicators(ctx, state); + ctx.ui.notify( + state.readonlyEnabled + ? "Readonly mode enabled \u2014 write/edit and non-temp bash writes blocked; handoff stays blocked unless the user explicitly requests /handoff" + : "Readonly mode disabled \u2014 write/edit/handoff and non-temp bash writes unblocked", + "info", + ); + } + + pi.registerCommand("readonly", { + description: "Toggle readonly mode (blocks write/edit and bash writes outside the OS temp dir; handoff requires explicit /handoff)", + handler: async (_args, ctx) => toggleReadonly(ctx), + }); + + pi.registerShortcut("ctrl+shift+r", { + description: "Toggle readonly mode", + handler: async (ctx) => { + if (ctx.isIdle()) toggleReadonly(ctx); + }, + }); + + function rehydrateReadonlyState(ctx: ExtensionContext): void { + const wasEnabled = state.readonlyEnabled; + const branch = ctx.sessionManager?.getBranch?.() ?? []; + state.readonlyEnabled = getReadonlyFromBranch(branch, pi); + // Nudge on any rehydrated readonly authority change. + if (state.readonlyEnabled !== wasEnabled) { + state.readonlyNudgePending = true; + } + } + + // ── Readonly: tool_call blocking ──────────────────────────────── + pi.on("tool_call", async (event, ctx) => { + // ── Readonly mode ─────────────────────────────────────────── + // Guardrail for a coding agent (not a security boundary): + // write/edit stay in the tool list but are blocked at call time. + // handoff is also blocked unless the user explicitly requested + // /handoff, which activates a narrow temporary exception for the + // handoff tool only. Keeping tools advertised avoids context-cache + // invalidation from tools disappearing mid-session. Children use + // the opposite approach (remove from tool list entirely) because + // they start with a fresh context — see spawn/index.ts. + if (!state.readonlyEnabled) return; + + if (event.toolName === "write" || event.toolName === "edit") { + return { + block: true as const, + reason: + "Readonly mode: write/edit disabled. " + + "Toggle with /readonly. Use spawn for same-topic delegation.", + }; + } + + if (event.toolName === "handoff" && !state.pendingRequestedHandoff?.readonlyBypassActive) { + return { + block: true as const, + reason: + "Readonly mode: handoff is disabled unless the user explicitly requests /handoff. " + + "Use spawn for same-topic delegation or ask for /handoff when a real context pivot is required.", + }; + } + + if (isToolCallEventType("bash", event)) { + const result = applyReadonlyBashGuard(event.input.command, ctx.cwd); + if (result.action === "block") { + return { block: true as const, reason: result.reason }; + } + if (result.action === "sandbox") { + // Mutate input.command in-place — SDK has no transform return type. + // Other tool_call hooks will see the sandbox-wrapped command. + event.input.command = result.sandboxedCommand; + } + } + }); + + // ── Readonly: record slash-command intent for deferred toggle ─ + // The readonly frontmatter cache is built in before_agent_start, after + // Pi resolves skills and exposes the current slash-command registry. + // Record intent here, then resolve it there. + pi.on("input", async (event, ctx) => { + // Only user-initiated slash-command input in TUI sessions should enqueue a + // readonly toggle. Headless/RPC runs preserve the existing contract: + // readonly is a UI-only feature. + // Extension-sourced inputs (e.g. pi.sendUserMessage) and plain-text user + // messages must NOT mutate the pending queue built from earlier queued + // commands (steer/followUp multi-message delivery). + if (!ctx.hasUI || event.source === "extension") return { action: "continue" }; + + const text = event.text; + // Capture the full first slash-command token and defer authority to the + // resolved registry in before_agent_start. This avoids drifting from Pi's + // naming rules when prompt/skill names include dots or suffixed segments. + const skillName = text.match(/^\/skill:([^\s/]+)/)?.[1]; + if (skillName) { + state.pendingReadonlyCommands.push({ type: "skill", name: skillName }); + return { action: "continue" }; + } + + const commandName = text.match(/^\/([^\s/]+)/)?.[1]; + if (!commandName) return { action: "continue" }; + + // Prefer the live command registry when it's already available, but don't + // rely on it as the sole authority at input time: some runtimes surface + // prompt commands only later in before_agent_start. If the command is + // unknown here, defer it optimistically unless it's one of the builtin + // commands that must never create a stale no-op queue entry. + const commands = pi.getCommands(); + if (isBuiltinReadonlyBypassCommand(commandName)) return { action: "continue" }; + if (isPromptCommand(commands, commandName)) { + state.pendingReadonlyCommands.push({ type: "command", name: commandName }); + return { action: "continue" }; + } + if (commands.some((command) => command.name === commandName)) { + return { action: "continue" }; + } + + state.pendingReadonlyCommands.push({ type: "command", name: commandName }); + return { action: "continue" }; + }); + // ── /notebook command — interactive page selector ──────────────── pi.registerCommand("notebook", { description: "Select a notebook page to preview, or set the active notebook topic with /notebook ", @@ -65,7 +338,9 @@ export default function (pi: ExtensionAPI): void { const result = setActiveNotebookTopic(state, topicArg, "human"); if (ctx.hasUI) { const message = result.boundaryHint - ? `Active notebook topic changed: ${result.boundaryHint.from} → ${result.boundaryHint.to}. This is a likely task boundary; handoff is recommended before continuing.` + ? state.readonlyEnabled + ? `Active notebook topic changed: ${result.boundaryHint.from} → ${result.boundaryHint.to}. This is a likely task boundary; use spawn only for same-topic delegation. If the user explicitly requests /handoff, complete it and make clear the fresh context resumes in readonly mode.` + : `Active notebook topic changed: ${result.boundaryHint.from} → ${result.boundaryHint.to}. This is a likely task boundary; handoff is recommended before continuing.` : `Active notebook topic: ${result.current}`; ctx.ui.notify(message, result.boundaryHint ? "warning" : "info"); } @@ -158,8 +433,15 @@ export default function (pi: ExtensionAPI): void { }, }); - // ── before_agent_start: inject context primer + notebook ─────── + // ── before_agent_start: populate readonly cache, consume the deferred + // queue until the first real readonly decision, then inject context + // primer + notebook ───────────────────────────────────────────── pi.on("before_agent_start", async (event, ctx: ExtensionContext) => { + if (state.pendingReadonlyCommands.length > 0) { + populateReadonlyCache(state, event, ctx, pi); + } + consumePendingReadonlyToggle(state, ctx, pi); + // Update TUI indicators before each user-prompt agent run updateIndicators(ctx, state); @@ -201,22 +483,72 @@ export default function (pi: ExtensionAPI): void { return { systemPrompt: parts.join("\n\n") }; }); - // ── context: inject primacy-zone nudge before each LLM call ──── + // ── context: inject primacy-zone nudge + readonly ON/OFF nudges ────── + // ON: nudge once on toggle. OFF: checks --readonly CLI flag and prior + // branch entries to detect session-level un-toggle before nudging. pi.on("context", async (event, ctx: ExtensionContext) => { const usage = ctx.getContextUsage(); const percent = usage?.percent ?? null; if (usage && usage.percent !== null) { state.lastContextPercent = usage.percent; } - if (!state.pendingTopicBoundaryHint && (percent === null || percent < 30)) { + + // Build the readonly nudge message (if pending) — don't early-return so + // it can merge with the watchdog nudge when both are needed in the same turn. + let readonlyNudgeMsg: { role: string; customType: string; content: string; display: boolean; timestamp: number } | null = null; + if (state.readonlyNudgePending) { + state.readonlyNudgePending = false; + readonlyNudgeMsg = { + role: "custom" as const, + customType: "agenticoding-readonly-nudge", + content: state.readonlyEnabled + ? state.pendingRequestedHandoff?.readonlyBypassActive + ? "Readonly mode is active. write, edit, and bash filesystem writes/deletions outside the OS temp dir are blocked. A temporary exception allows the handoff tool for the current user-requested handoff only. After compaction, the fresh context will resume in readonly mode and this exception will be cleared." + : "Readonly mode is active. write, edit, handoff, and bash filesystem writes/deletions outside the OS temp dir are blocked unless the user explicitly requests /handoff. Allowed: read, notebook, env inheritance, and non-mutating bash." + : "Readonly mode has been turned off. You may now use write, edit, handoff, and bash freely." + + (percent !== null && percent >= 30 + ? " Context was at " + Math.round(percent) + "% — if the work changed topics, you can handoff now." + : ""), + display: false, + timestamp: Date.now(), + }; + } + + const mustEnforceRequestedHandoff = state.pendingRequestedHandoff !== null; + + // Below primacy-zone threshold (~30%), skip watchdog unless a boundary + // hint or a sticky user-requested handoff is pending — context is still + // fresh enough that ordinary nudges add noise. + // HACK: `as any` required because readonlyNudgeMsg has customType field not in AgentMessage. + // Proper fix: augment CustomAgentMessages via module augmentation on @earendil-works/pi-agent-core. + if (!mustEnforceRequestedHandoff && !state.pendingTopicBoundaryHint && (percent === null || percent < 30)) { + state.lastWatchdogBand = null; + if (readonlyNudgeMsg) { + return { messages: [...event.messages, readonlyNudgeMsg as any] }; + } return; } + // Throttle: only nudge when crossing into a higher context-percentage band. + // Bands: null (<30), 0 (30-49), 1 (50-69), 2 (70+). This prevents nudging + // every turn once past 30%. + if (!mustEnforceRequestedHandoff && !state.pendingTopicBoundaryHint) { + const band = percent! < 50 ? 0 : percent! < 70 ? 1 : 2; + if (state.lastWatchdogBand !== null && band <= state.lastWatchdogBand) { + if (readonlyNudgeMsg) { + return { messages: [...event.messages, readonlyNudgeMsg as any] }; + } + return; + } + state.lastWatchdogBand = band; + } + const nudge = buildNudge(state, percent); state.pendingTopicBoundaryHint = null; return { messages: [ ...event.messages, + ...(readonlyNudgeMsg ? [readonlyNudgeMsg as any] : []), { role: "custom", customType: "agenticoding-watchdog", @@ -228,7 +560,7 @@ export default function (pi: ExtensionAPI): void { }; }); - // ── session_start: reset state + update indicators ───────────── + // ── session_start: reset state + readonly rehydration + indicators ── pi.on("session_start", async (event, ctx: ExtensionContext) => { if (event.reason === "new") { resetState(state); @@ -236,22 +568,22 @@ export default function (pi: ExtensionAPI): void { if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); ctx.ui.setStatus(STATUS_KEY_TOPIC, undefined); + ctx.ui.setStatus(STATUS_KEY_READONLY, undefined); ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined); } } + rehydrateReadonlyState(ctx); + updateIndicators(ctx, state); + }); + + // ── session_tree: rehydrate readonly state on tree changes ───── + pi.on("session_tree", async (_event, ctx: ExtensionContext) => { + rehydrateReadonlyState(ctx); updateIndicators(ctx, state); }); // ── update TUI indicators after each turn ─────────────────────── pi.on("turn_end", async (_event, ctx: ExtensionContext) => { - // Fallback: clear handoff indicator if the LLM completed a turn - // without calling the handoff tool (ignored the direction) - if (state.pendingRequestedHandoff && !state.pendingRequestedHandoff.toolCalled) { - state.pendingRequestedHandoff = null; - if (ctx.hasUI) { - ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); - } - } updateIndicators(ctx, state); }); } diff --git a/notebook/rehydration.ts b/notebook/rehydration.ts index 91e3cf7..1b2289e 100644 --- a/notebook/rehydration.ts +++ b/notebook/rehydration.ts @@ -42,6 +42,7 @@ export function registerNotebookRehydration( for (let i = branch.length - 1; i >= 0; i--) { const entry = branch[i]; + if (!entry || typeof entry !== "object") continue; if ( entry.type !== "custom" || diff --git a/os-sandbox.ts b/os-sandbox.ts new file mode 100644 index 0000000..709d43b --- /dev/null +++ b/os-sandbox.ts @@ -0,0 +1,272 @@ +/** + * OS-level sandboxing for readonly-mode bash commands. + * + * Wraps bash commands to run inside an OS sandbox that denies filesystem + * writes outside the OS temp dir. Uses platform-native sandbox mechanisms: + * macOS → sandbox-exec with Seatbelt profile + * Linux → bubblewrap (bwrap) if available + * Windows → not supported (returns command unchanged, classifyBashCommand applies) + * + * This replaces the best-effort command-pattern matching in classifyBashCommand + * with actual kernel-enforced file-write blocking. + */ + +import { execSync } from "node:child_process"; +import crypto from "node:crypto"; +import os from "node:os"; +import path from "node:path"; + +import { TEMP_DIR } from "./temp-dir.js"; +import { resolveRealPath } from "./resolve-path.js"; + +// ── Temp dir canonicalization ──────────────────────────────────── + +let _canonicalTempDir: string | undefined; + +/** Get the canonical (symlink-resolved) temp dir path. */ +function getCanonicalTempDir(): string { + if (_canonicalTempDir === undefined) { + _canonicalTempDir = resolveRealPath(TEMP_DIR); + } + return _canonicalTempDir; +} + +// ── Platform detection ─────────────────────────────────────────── + +/** + * Check whether we can use OS-level sandboxing on the current platform. + * Returns true when sandbox-exec is available (macOS) or bwrap is installed (Linux). + */ +export function canUseOsSandbox(): boolean { + const platform = process.platform; + if (platform === "darwin") { + return _hasSandboxExec(); + } + if (platform === "linux") { + return _hasBwrap(); + } + + return false; +} + +let _bwrapResult: boolean | undefined; +let _sandboxExecResult: boolean | undefined; + +function hasCommand(command: string): boolean { + try { + execSync(`command -v ${command}`, { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function _hasBwrap(): boolean { + if (_bwrapResult === undefined) { + if (hasCommand("bwrap")) { + // Quick functional test: can bwrap actually create a namespace? + try { + execSync("bwrap --ro-bind / / true 2>/dev/null", { stdio: "ignore", timeout: 2000 }); + _bwrapResult = true; + } catch (e) { + _bwrapResult = false; + } + } else { + _bwrapResult = false; + } + } + return _bwrapResult; +} + +function _hasSandboxExec(): boolean { + if (_sandboxExecResult === undefined) { + if (hasCommand("sandbox-exec")) { + // Quick functional test: can sandbox-exec actually enforce a profile? + try { + execSync("echo true | sandbox-exec -p '(version 1)(allow default)' /bin/bash 2>/dev/null", + { stdio: "ignore", timeout: 2000 }); + _sandboxExecResult = true; + } catch (e) { + _sandboxExecResult = false; + } + } else { + _sandboxExecResult = false; + } + } + return _sandboxExecResult; +} + +// ── macOS: sandbox-exec ────────────────────────────────────────── + +/** + * Build a Seatbelt sandbox profile string for readonly mode. + * + * Pattern: allow everything by default, but deny all file writes except + * to the canonical temp dir and /dev/null. + * + * Using (allow default) + write denies (permissive pattern) because + * (deny default) + explicit read allows is fragile — system library + * reads, dyld, and process execution are complex to enumerate and + * vary across macOS versions. The permissive pattern keeps standard + * tooling (node, npm, git, python, etc.) working while correctly + * blocking all file writes outside the temp dir. + */ +export function buildMacProfile(tempDir: string): string { + const canon = resolveRealPath(tempDir); + const original = path.resolve(os.tmpdir()); // may have symlinks (e.g., /var -> /private/var) + + // Collect unique paths — both canonical and unresolved (symlink) forms. + // Seatbelt subpath does NOT resolve symlinks, so we must include both. + // Also include /tmp and /private/tmp because bash (on macOS) creates + // heredoc temp files in /tmp regardless of $TMPDIR. + const writePaths = new Set(); + writePaths.add(canon); + if (original !== canon) writePaths.add(original); + writePaths.add("/private/tmp"); + writePaths.add("/tmp"); + + // Two distinct injection risks in the profile string: + // - Single quotes (') break out of the outer shell wrapper: sandbox-exec -p '${profile}' + // - Double quotes (") break Seatbelt (subpath "...") literal syntax + for (const p of writePaths) { + if (p.includes("'") || p.includes('"')) { + throw new Error(`[readonly] Sandbox profile path contains quote — cannot safely escape: ${p}`); + } + } + + const parts = [ + "(version 1)", + "(allow default)", + "(deny file-write*)", + '(allow file-write* (literal "/dev/null"))', + ]; + for (const p of writePaths) { + parts.push(`(allow file-write* (subpath "${p}"))`); + } + return parts.join(""); +} + +/** + * Generate a unique heredoc delimiter for wrapping commands. + * Using a random suffix avoids accidental collision with command content. + */ +function generateDelimiter(): string { + const suffix = crypto.randomBytes(4).toString("hex"); + return `PI_SANDBOX_INNER_${suffix}`; +} + +/** + * Wrap a bash command with sandbox-exec on macOS. + * + * Uses a heredoc to pipe the original command verbatim (with all newlines + * and special characters preserved) to an inner bash running under + * sandbox-exec: + * + * sandbox-exec -p '' /bin/bash << 'DELIM' + * + * DELIM + * + * The outer bash tool calls spawn(shell, ['-c', modifiedCommand]), so: + * /bin/bash -c "sandbox-exec -p '...' /bin/bash << 'DELIM'\n\nDELIM" + * + * The heredoc preserves all original characters (multiline, quotes, pipes, + * redirects) so the inner bash receives the exact original command. + * All descendants inherit the sandbox restrictions. + */ +export function wrapWithSandboxExec(command: string): string { + const profile = buildMacProfile(getCanonicalTempDir()); + const delim = generateDelimiter(); + return `sandbox-exec -p '${profile}' /bin/bash << '${delim}' +output=\$({ +: +${command} +} 2>&1) +rc=\$? +if [ \$rc -ne 0 ]; then + case "\$output" in + *"Operation not permitted"*|*"Permission denied"*|*"denying file-write"*) + echo "" + echo "[readonly mode] The OS sandbox blocked a filesystem write outside the OS temp dir." + echo "Use /readonly to disable, or write within the OS temp dir." + echo "" + ;; + esac +fi +[ -n "\$output" ] && echo "\$output" +exit \$rc +${delim}`; +} + +// ── Linux: bubblewrap ──────────────────────────────────────────── + +/** + * Wrap a bash command with bubblewrap on Linux. + * + * Uses the same heredoc approach as sandbox-exec for consistent behavior. + * + * --ro-bind / / makes entire root read-only + * --tmpfs /tmp then mounts writable tmpfs at /tmp (overrides ro-bind) + * --bind binds the real temp dir writable into /tmp + * --proc /proc, --dev /dev for proper /proc and /dev + * --unshare-all --share-net for isolation while allowing network + * --die-with-parent --new-session for clean termination + */ +export function wrapWithBwrap(command: string): string { + const canon = getCanonicalTempDir(); + const delim = generateDelimiter(); + const flags = [ + "--ro-bind / /", + "--tmpfs /tmp", + `--bind "${canon}" "${canon}"`, + "--proc /proc", + "--dev /dev", + "--unshare-all", + "--share-net", + "--die-with-parent", + "--new-session", + ]; + return `bwrap ${flags.join(" ")} /bin/bash << '${delim}' +output=\$({ +: +${command} +} 2>&1) +rc=\$? +if [ \$rc -ne 0 ]; then + case "\$output" in + *"Operation not permitted"*|*"Permission denied"*|*"denying file-write"*) + echo "" + echo "[readonly mode] The OS sandbox blocked a filesystem write outside the OS temp dir." + echo "Use /readonly to disable, or write within the OS temp dir." + echo "" + ;; + esac +fi +[ -n "\$output" ] && echo "\$output" +exit \$rc +${delim}`; +} + +// ── Unified dispatch ───────────────────────────────────────────── + +/** + * Wrap a bash command string to run inside an OS-level filesystem sandbox. + * + * On macOS: wraps with sandbox-exec (native, no deps). + * On Linux: wraps with bubblewrap if available. + * On other platforms / when unavailable: returns command unchanged. + * + * The returned command must be passed to /bin/bash -c (or equivalent) for + * execution — the shell tool handles this automatically. + */ +export function wrapCommandWithOsSandbox(command: string): string { + const platform = process.platform; + if (platform === "darwin") { + return wrapWithSandboxExec(command); + } + if (platform === "linux" && _hasBwrap()) { + return wrapWithBwrap(command); + } + // No OS sandbox available — command unchanged, classifyBashCommand + // fallback will handle it at the call site. + return command; +} diff --git a/package-lock.json b/package-lock.json index 944ec5f..62348ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,13 @@ "version": "0.3.0", "license": "MIT", "devDependencies": { - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-coding-agent": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-coding-agent": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", + "@types/node": "^25.9.3", + "audit-ci": "^7.1.0", "fast-check": "^4.8.0", + "husky": "^9.1.7", "typebox": "^1.2.2", "typescript": "^6.0.3" }, @@ -20,10 +23,10 @@ "node": ">=22" }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-coding-agent": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", + "typebox": "^1.2.2" } }, "node_modules/@anthropic-ai/sdk": { @@ -535,9 +538,9 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", - "integrity": "sha512-CM2pkTs1iupG/maw381lC9Q/Y/aQaMGK7GILc28ttImD0ci3LDwKroDsGkWbly5JIy3iqxdRxB9JlG7vvzCzTg==", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.6.tgz", + "integrity": "sha512-KGepEdgEeWDs7Imwlp96tBsO8TjSIpcDBvazsCDtHRa81+uwJI/YGetTegI52pMlKhVpJFLIGajRi4PCGC5MUg==", "dev": true, "license": "MIT", "dependencies": { @@ -567,16 +570,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.78.1.tgz", - "integrity": "sha512-Syjf6Ib8UoY5t9ZdKjp0BRrQZuFkFBc8j2KEU9zG/ZnmYPcAxYeioofdv2Q3MEXnHEX2U8sKQptkSnJIdMsd0g==", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.6.tgz", + "integrity": "sha512-xPQDoA3+4Q8UsInST8OGFHPHyi7JQIbx51ku5kf2VuhXrrTv/npjVTXYD3A3D5xs6QRebV0B2mQqHfXaxQAplw==", "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.1", - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-agent-core": "^0.79.6", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -588,6 +591,7 @@ "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", + "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" @@ -1065,12 +1069,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.78.1.tgz", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.6.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.6", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -1080,8 +1084,8 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.6.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1097,20 +1101,20 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.6.tgz", "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -2066,16 +2070,16 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { @@ -2322,6 +2326,19 @@ ], "license": "MIT" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2504,14 +2521,14 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", - "integrity": "sha512-07GVQo/38a0yvIPlWDr3RJn1B8gk3ZuIX9h2oIQ+Biyu3JN0KppWmgWHfaWRydQgse5JtC++KDw5MWaIRnV0mw==", + "version": "0.79.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.6.tgz", + "integrity": "sha512-6JCq780X0UuqvJsUDSJmi4V54ObB8qSwFQiBOI1jhPpC+Ydusd8SEXn2HtyIqve/utMgwcZT9aOyZM72m26A0w==", "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -2770,9 +2787,9 @@ } }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2796,6 +2813,56 @@ "node": ">= 14" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/audit-ci": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/audit-ci/-/audit-ci-7.1.0.tgz", + "integrity": "sha512-PjjEejlST57S/aDbeWLic0glJ8CNl/ekY3kfGFPMrPkmuaYaDKcMH0F9x9yS9Vp6URhuefSCubl/G0Y2r6oP0g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.3", + "escape-string-regexp": "^4.0.0", + "event-stream": "4.0.1", + "jju": "^1.4.0", + "jsonstream-next": "^3.0.0", + "readline-transform": "1.0.0", + "semver": "^7.0.0", + "tslib": "^2.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "audit-ci": "dist/bin.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2841,6 +2908,56 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -2869,6 +2986,13 @@ } } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2879,6 +3003,52 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/event-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz", + "integrity": "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.1", + "from": "^0.1.7", + "map-stream": "0.0.7", + "pause-stream": "^0.0.11", + "split": "^1.0.1", + "stream-combiner": "^0.2.2", + "through": "^2.3.8" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -2985,6 +3155,13 @@ "node": ">=12.20.0" } }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "dev": true, + "license": "MIT" + }, "node_modules/gaxios": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", @@ -3015,6 +3192,16 @@ "node": ">=18" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -3084,6 +3271,53 @@ "node": ">= 14" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true, + "license": "MIT" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -3108,6 +3342,33 @@ "node": ">=16" } }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/jsonstream-next": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/jsonstream-next/-/jsonstream-next-3.0.0.tgz", + "integrity": "sha512-aAi6oPhdt7BKyQn1SrIIGZBt0ukKuOUE1qV6kJ3GgioSOYzsRc8z9Hfr1BVmacA/jLe9nARfmgMGgn68BqIAgg==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through2": "^4.0.2" + }, + "bin": { + "jsonstream-next": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -3138,17 +3399,24 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/map-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz", + "integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/ms": { @@ -3257,6 +3525,29 @@ "node": ">=14.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dev": true, + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, "node_modules/protobufjs": { "version": "7.6.1", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", @@ -3299,6 +3590,41 @@ ], "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readline-transform": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/readline-transform/-/readline-transform-1.0.0.tgz", + "integrity": "sha512-7KA6+N9IGat52d83dvxnApAWN+MtVb1MiVuMR/cf1O4kYsJG+g/Aav0AHcHKsb6StinayfPLne0+fMX2sOzAKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -3330,6 +3656,104 @@ ], "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/stream-combiner": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz", + "integrity": "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "through": "~2.3.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", @@ -3343,6 +3767,23 @@ ], "license": "MIT" }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", @@ -3385,6 +3826,13 @@ "dev": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -3395,6 +3843,40 @@ "node": ">= 8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -3433,6 +3915,45 @@ "node": ">=16.0.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 86336d7..298dc53 100644 --- a/package.json +++ b/package.json @@ -15,23 +15,28 @@ "node": ">=22" }, "peerDependencies": { - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-coding-agent": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-coding-agent": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", "typebox": "^1.2.2" }, "scripts": { + "prepare": "husky", "test": "node ./scripts/run-node-test.mjs tests/unit/**/*.test.ts", "test:e2e": "node ./scripts/run-node-test.mjs tests/e2e/**/*.test.ts", "test:all": "npm run test && npm run test:e2e", "test:snapshots:check": "node ./scripts/run-node-test.mjs tests/unit/render-snapshots.test.ts", - "test:snapshots:update": "node ./scripts/run-node-test.mjs --update-snapshots tests/unit/render-snapshots.test.ts" + "test:snapshots:update": "node ./scripts/run-node-test.mjs --update-snapshots tests/unit/render-snapshots.test.ts", + "typecheck": "tsc --noEmit" }, "devDependencies": { - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-coding-agent": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.6", + "@earendil-works/pi-coding-agent": "^0.79.6", + "@earendil-works/pi-tui": "^0.79.6", + "@types/node": "^25.9.3", + "audit-ci": "^7.1.0", "fast-check": "^4.8.0", + "husky": "^9.1.7", "typebox": "^1.2.2", "typescript": "^6.0.3" }, diff --git a/readonly-bash.ts b/readonly-bash.ts new file mode 100644 index 0000000..bd8a998 --- /dev/null +++ b/readonly-bash.ts @@ -0,0 +1,1103 @@ +import path from "node:path"; +import os from "node:os"; +import { globSync } from "node:fs"; +import { canUseOsSandbox, wrapCommandWithOsSandbox } from "./os-sandbox.js"; +import { resolveRealPath } from "./resolve-path.js"; +import { TEMP_DIR } from "./temp-dir.js"; + +/** + * Readonly bash guard. + * + * Contract: block filesystem writes/deletions outside the OS temp dir. + * Non-mutating commands, unknown commands, and environment inheritance are + * allowed. Process-level commands (kill, reboot, shutdown, systemctl, su) + * are not filesystem mutations and are intentionally allowed. + * + * Package-manager mutations (npm install, pip install, etc.) are blocked + * unconditionally regardless of target path — they write outside any single + * directory (node_modules, site-packages, etc.) making temp-dir checking + * meaningless. + * + * This is a best-effort command inspection layer, not a security sandbox. + * + * ## Known L2 limitations (no OS sandbox available) + * + * These bypasses are mitigated by L1 (OS sandbox) on macOS and Linux but + * are effective on Windows or when sandbox tools are missing: + * + * - **Interpreters with programmatic code** — `node -e`, `python3 -c`, etc. + * running code like `require('fs').writeFileSync(...)` are not checked. + * The classifier only parses shell command tokens, not JS/Python/Perl code. + * - **xargs with stdin-fed package managers** — `printf install | xargs npm` + * bypasses because `xargs npm` alone has no verb args. The pipe feeds + * `install` at runtime via stdin; only the OS sandbox blocks the writes. + */ + +type Verdict = + | { ok: true } + | { ok: false; reason: string }; + +// TEMP_DIR is resolved in temp-dir.ts — imported above so both +// readonly-bash and os-sandbox use the same canonical temp dir. + +const GIT_IMMUTABLE = new Set([ + "diff", "log", "show", "status", "blame", "grep", + "ls-files", "ls-tree", "merge-tree", "format-patch", + "rev-parse", "rev-list", "cat-file", "for-each-ref", + "merge-base", "fsck", "range-diff", "shortlog", "name-rev", + "describe", "var", "version", +]); + +const GIT_MUTABLE = new Set([ + "add", "am", "apply", "checkout", "cherry-pick", "clean", + "clone", "commit", "fetch", "init", "merge", "mv", "pull", "push", + "rebase", "reset", "restore", "revert", "rm", "switch", +]); + +const GIT_MIXED: Record boolean> = { + reflog: (sub) => sub === "" || sub === "show" || sub.startsWith("show "), + branch: (sub) => + sub === "" || sub === "-l" || sub === "--show-current" || + /^--?[a-zA-Z-]*list(?:[=\s]|$)/.test(sub), + tag: (sub) => sub === "" || sub === "-l" || /^--?[a-zA-Z-]*list(?:[=\s]|$)/.test(sub), + remote: (sub) => sub === "" || sub === "-v" || sub === "show" || sub === "get-url", + config: (sub) => + sub === "" || sub === "-l" || sub === "--list" || + sub === "--get" || sub.startsWith("--get ") || sub.startsWith("--get="), + notes: (sub) => sub === "list" || sub === "show", + stash: (sub) => sub === "list" || sub === "show", + bisect: (sub) => sub === "log" || sub === "view" || sub === "", + worktree: (sub) => sub === "list", + submodule: (sub) => sub === "status", +}; + +// Interpreters whose inline-execution flag is recursively classified. +// node -c = syntax check only (non-executing); node -e executes code. +const INTERPRETER_EXEC_FLAGS: Record = { + node: ["-e"], + bash: ["-c"], sh: ["-c"], zsh: ["-c"], dash: ["-c"], ksh: ["-c"], + python3: ["-c"], python: ["-c"], + perl: ["-e"], + ruby: ["-e"], +}; + +const INTERPRETERS = new Set(Object.keys(INTERPRETER_EXEC_FLAGS)); + +// Package managers are blocked unconditionally — they mutate system state +// outside any single directory (npm install writes to node_modules, pip +// installs to site-packages, etc.). Temp-dir path checking is not meaningful. +const PACKAGE_MANAGERS = new Set(["npm", "yarn", "pnpm", "pip", "pip3", "pipx", "apt", "apt-get", "brew", "cargo", "gem", "yum", "dnf", "pacman", "choco"]); + + +/** + * Classify a bash command string for readonly mode. + * + * Splits the command into shell-operator-separated segments (&&, ||, ;, |, &, \n), + * checks each segment for command substitutions ($(...), backticks), write redirects (>), + * and filesystem mutations. Blocks if any target path resolves outside the OS temp dir. + * + * When OS-level sandboxing (canUseOsSandbox()) is available, this serves as a fallback — + * the kernel-enforced sandbox enforces the same write-restriction policy. + * + * @param cmd - Raw bash command string (may contain multiple segments via &&, ;, |, etc.) + * @param cwd - Working directory for relative path resolution (defaults to process.cwd()) + * @returns {ok: true} if allowed, or {ok: false, reason} with explanation + */ + +export function classifyBashCommand(cmd: string, cwd: string = process.cwd(), depth: number = 0, shellVars: ReadonlyMap = new Map()): Verdict { + if (depth > 10) return { ok: false, reason: "recursion depth exceeded in command classification" }; + const localVars = new Map(shellVars); + for (const rawSegment of splitUnquotedShellSegments(cmd)) { + const segment = rawSegment.trim(); + if (!segment) continue; + + for (const subcommand of extractCommandSubstitutions(segment)) { + const nested = classifyBashCommand(subcommand, cwd, depth + 1, localVars); + if (!nested.ok) { + return { ok: false, reason: `command substitution blocked: ${nested.reason}` }; + } + } + + const redirectTarget = getUnsafeWriteRedirectTarget(segment, cwd, localVars); + if (redirectTarget) { + return { ok: false, reason: `write redirect blocked outside temp dir: ${redirectTarget}` }; + } + + const mutationReason = getFilesystemMutationReason(segment, cwd, depth, localVars); + if (mutationReason) return { ok: false, reason: mutationReason }; + + for (const [name, value] of getStandaloneShellAssignments(segment, cwd, localVars)) { + localVars.set(name, value); + } + } + + return { ok: true }; +} + +/** + * Classify a shell segment's filesystem mutation risk. + * + * Extracts the command and its targets, then blocks if any target + * resolves outside the OS temp dir. Handles git, sudo, env, eval/exec, + * interpreter inline execution flags (-c/-e), dd of=, sed -i, + * find -exec/-delete, perl/ruby -pi, and package managers. + * Command names are compared case-insensitively (normalized via .toLowerCase()). + * Unknown commands return null (allowed). + */ +// ── Command-specific mutation classifiers (one per command, ≤15 lines each) ── + +/** Strip subshell parens: (rm file) → rm file, then classify recursively. */ +function classifySubshellSegment(segment: string, cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (!segment.startsWith("(") || !segment.endsWith(")")) return null; + const inner = segment.slice(1, -1).trim(); + return inner ? getFilesystemMutationReason(inner, cwd, depth, shellVars) : null; +} + +/** eval/exec: recursively classify the remaining argument string. */ +function classifyEvalExec(command: string, tokens: string[], cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (command !== "eval" && command !== "exec") return null; + const inner = tokens.slice(1).map(stripMatchingQuotes).join(" "); + const nested = classifyBashCommand(inner, cwd, depth + 1, shellVars); + return nested.ok ? null : nested.reason; +} + +/** sudo: classify the command after sudo flags. */ +function classifySudo(command: string, tokens: string[], cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (command !== "sudo") return null; + const nested = classifyBashCommand(tokens.slice(findSudoCommandIndex(tokens)).join(" "), cwd, depth + 1, shellVars); + return nested.ok ? null : nested.reason; +} + +/** env: handle env prefix including -S/--split-string. */ +function classifyEnv(command: string, segment: string, tokens: string[], cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (command !== "env") return null; + if (tokens.length > 1) { + const nested = classifyBashCommand(tokens.slice(1).join(" "), cwd, depth + 1, shellVars); + return nested.ok ? null : nested.reason; + } + // env with only flags (e.g., env -S "cmd") — extract -S value + const sMatch = segment.match(/\benv\b.*?(?:-S|--split-string)\s+/); + if (!sMatch) return null; + const afterS = segment.slice(sMatch.index! + sMatch[0].length).trim(); + const nested = classifyBashCommand(stripMatchingQuotes(afterS), cwd, depth + 1, shellVars); + return nested.ok ? null : nested.reason; +} + +/** git: classify subcommand via three-tier allowlist (immutable/mutable/mixed). */ +function classifyGit(command: string, tokens: string[]): string | null { + if (command !== "git") return null; + return isSafeGitCommand(tokens.slice(1).join(" ")) ? null : "mutable git command blocked outside temp dir"; +} + +/** Interpreters with inline-execution flags — classify inline code recursively. */ +function classifyInterpreter(command: string, tokens: string[], cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (!INTERPRETERS.has(command)) return null; + const args = tokens.slice(1); + for (const flag of INTERPRETER_EXEC_FLAGS[command]) { + const idx = args.indexOf(flag); + if (idx === -1 || idx + 1 >= args.length) continue; + const nested = classifyBashCommand(stripMatchingQuotes(args[idx + 1]), cwd, depth + 1, shellVars); + if (!nested.ok) return `${command} ${flag} blocked: ${nested.reason}`; + } + return null; +} + +/** dd of= target check. */ +function classifyDdOutput(segment: string, cwd: string, shellVars: ReadonlyMap): string | null { + const ddMatch = segment.match(/\bof=([^\s]+)/); + if (!ddMatch || isTempPath(ddMatch[1], cwd, shellVars)) return null; + return `dd output blocked outside temp dir: ${stripMatchingQuotes(ddMatch[1])}`; +} + +/** wget -P/--download-dir outside temp — block. No-flag case falls through to classifyGenericMutation. */ +function classifyWget(command: string, tokens: string[], cwd: string, shellVars: ReadonlyMap): string | null { + if (command !== "wget") return null; + const wArgs = tokens.slice(1); + const hasOutputFlag = wArgs.some((a) => a === "-O" || a.startsWith("-O") || a === "--output-document" || a.startsWith("--output-document=")); + if (hasOutputFlag) return null; + const outputDir = getWgetOutputDir(tokens); + if (outputDir && !isTempPath(outputDir, cwd, shellVars)) return `wget download dir blocked outside temp dir: ${outputDir}`; + return null; +} + +/** curl -O/--remote-name writes to disk — block unless cwd is temp. */ +function classifyCurl(command: string, tokens: string[], cwd: string, shellVars: ReadonlyMap): string | null { + if (command !== "curl") return null; + const { hasRemoteName, outputDir } = getCurlWriteTargets(tokens); + if (hasRemoteName && !isTempPath(outputDir ?? ".", cwd, shellVars)) { + return "curl blocked outside temp dir: current directory (use -o /tmp/... to write to temp)"; + } + return null; +} + +/** xargs: classify the command xargs would run + block targetless mutation commands. */ +function classifyXargs(command: string, tokens: string[], cwd: string, depth: number, shellVars: ReadonlyMap): string | null { + if (command !== "xargs") return null; + const xArgs = tokens.slice(1); + const XARGS_FLAGS_WITH_VALUE = new Set(["-I", "-L", "-n", "-P", "-d", "-E", "-s"]); + let cmdStart = 0; + while (cmdStart < xArgs.length) { + if (XARGS_FLAGS_WITH_VALUE.has(xArgs[cmdStart])) { cmdStart += 2; continue; } + if (xArgs[cmdStart].startsWith("-")) { cmdStart++; continue; } + break; + } + if (cmdStart >= xArgs.length) return null; + const xTokens = xArgs.slice(cmdStart); + const nested = classifyBashCommand(xTokens.join(" "), cwd, depth + 1, shellVars); + if (!nested.ok) return nested.reason; + const xCmd = xTokens[0]?.toLowerCase(); + if (xCmd && getMutationTargets(xCmd, xTokens) !== null) return `xargs ${xCmd} blocked: mutation command via xargs`; + return null; +} + +/** Generic mutation: extract targets via getMutationTargets, block non-temp paths. */ +function classifyGenericMutation(command: string, tokens: string[], cwd: string, shellVars: ReadonlyMap): string | null { + const paths = getMutationTargets(command, tokens); + if (!paths) return null; + for (const target of paths) { + if (!isTempPath(target, cwd, shellVars)) return `${command} blocked outside temp dir: ${stripMatchingQuotes(target)}`; + } + return null; +} + +// ── Main dispatcher ───────────────────────────────────────────────── + +function getFilesystemMutationReason(segment: string, cwd: string, depth: number = 0, shellVars: ReadonlyMap = new Map()): string | null { + const tokens = getCommandTokens(segment); + const command = tokens[0]?.toLowerCase(); + if (!command) return null; + + return classifySubshellSegment(segment, cwd, depth, shellVars) + ?? classifyEvalExec(command, tokens, cwd, depth, shellVars) + ?? classifySudo(command, tokens, cwd, depth, shellVars) + ?? classifyEnv(command, segment, tokens, cwd, depth, shellVars) + ?? classifyGit(command, tokens) + ?? classifyInterpreter(command, tokens, cwd, depth, shellVars) + ?? classifyDdOutput(segment, cwd, shellVars) + ?? classifyPackageManager(command, tokens) + ?? classifyWget(command, tokens, cwd, shellVars) + ?? classifyCurl(command, tokens, cwd, shellVars) + ?? classifyXargs(command, tokens, cwd, depth, shellVars) + ?? classifyGenericMutation(command, tokens, cwd, shellVars); +} + +/** Package manager mutation: block unconditionally regardless of target path. */ +function classifyPackageManager(command: string, tokens: string[]): string | null { + if (!PACKAGE_MANAGERS.has(command)) return null; + if (!isPackageMutation(tokens.slice(1))) return null; + const args = tokens.slice(1).join(" "); + return `${command} ${args} is blocked in readonly mode`; +} + +function skipFlagValues(args: string[], flagsWithValues: Set): string[] { + const result: string[] = []; + let i = 0; + while (i < args.length) { + if (flagsWithValues.has(args[i])) { + i += 2; // skip flag + value + } else { + result.push(args[i]); + i++; + } + } + return result; +} + +function getCurlWriteTargets(tokens: string[]): { hasRemoteName: boolean; outputs: string[]; outputDir: string | null } { + const cArgs = tokens.slice(1); + const outputs: string[] = []; + let hasRemoteName = false; + let outputDir: string | null = null; + for (let i = 0; i < cArgs.length; i++) { + if (cArgs[i] === "--") break; // end of options; remaining args are URLs + if ((cArgs[i] === "-o" || cArgs[i] === "--output") && cArgs[i + 1]) { + outputs.push(cArgs[i + 1]); + i++; + continue; + } + if (cArgs[i] === "--output-dir" && cArgs[i + 1]) { + outputDir = cArgs[i + 1]; + i++; + continue; + } + if (cArgs[i].startsWith("--output=")) { + outputs.push(cArgs[i].slice("--output=".length)); + continue; + } + if (cArgs[i].startsWith("--output-dir=")) { + outputDir = cArgs[i].slice("--output-dir=".length); + continue; + } + if (cArgs[i].startsWith("-o") && cArgs[i].length > 2 && !cArgs[i].startsWith("--")) { + outputs.push(cArgs[i].slice(2)); + continue; + } + if (cArgs[i] === "-O" || cArgs[i] === "--remote-name" || cArgs[i] === "--remote-name-all") { + hasRemoteName = true; + continue; + } + if (isCurlShortFlagBundleWithRemoteName(cArgs[i])) { + hasRemoteName = true; + continue; + } + } + return { hasRemoteName, outputs, outputDir }; +} + +const CURL_VALUE_SHORT_FLAGS = new Set([ + // All 27 value-consuming short flags per curl --help all. + // A, b, u, X pre-existed; C, K fixed -CO/-KO; remaining cover -dO, -oO, etc. + "A", "b", "u", "X", + "C", "K", "y", "Y", "z", + "c", "d", "D", "e", "E", "F", "h", "H", "m", "o", + "P", "Q", "r", "t", "T", "U", "w", "x", +]); + +function isCurlShortFlagBundleWithRemoteName(token: string): boolean { + if (!token.startsWith("-") || token.startsWith("--") || token.length <= 2) return false; + const flags = token.slice(1); + if (!/^[A-Za-z]+$/.test(flags)) return false; + for (const flag of flags) { + if (flag === "O") return true; + if (CURL_VALUE_SHORT_FLAGS.has(flag)) return false; + } + return false; +} + +/** + * Extract the download directory from wget flags (-P / --directory-prefix). + * Returns null when no directory flag is present. + */ +function getWgetOutputDir(tokens: string[]): string | null { + const wArgs = tokens.slice(1); + for (let i = 0; i < wArgs.length; i++) { + if (wArgs[i] === "--") break; + if (wArgs[i] === "-P" && wArgs[i + 1]) { + return wArgs[i + 1]; + } + if (wArgs[i].startsWith("-P") && wArgs[i].length > 2) { + return wArgs[i].slice(2); + } + if (wArgs[i] === "--directory-prefix" && wArgs[i + 1]) { + return wArgs[i + 1]; + } + if (wArgs[i].startsWith("--directory-prefix=")) { + return wArgs[i].slice("--directory-prefix=".length); + } + } + return null; +} + +// ── Mutation target extractors (one per command group, ≤20 lines each) ── + +function getRmTargets(tokens: string[]): string[] { + return nonOptionArgs(skipFlagValues(tokens.slice(1), new Set(["-s", "-o", "--io-size"]))); +} + +function getTruncateTargets(tokens: string[]): string[] { + return nonOptionArgs(skipFlagValues(tokens.slice(1), new Set(["-s", "-r", "--reference", "-o", "--io-size"]))); +} + +function getTouchTargets(tokens: string[]): string[] { + return nonOptionArgs(skipFlagValues(tokens.slice(1), new Set(["-t", "-d", "-r"]))); +} + +function getChmodTargets(tokens: string[]): string[] { + const args = nonOptionArgs(tokens.slice(1)); + return args.slice(1); +} + +function getCpTargets(tokens: string[]): string[] { + const args = nonOptionArgs(tokens.slice(1)); + return args.length > 0 ? [args[args.length - 1]] : []; +} + +function getTeeTargets(tokens: string[]): string[] { + return nonOptionArgs(tokens.slice(1)); +} + +function getPerlRubyTargets(tokens: string[]): string[] | null { + if (!tokens.slice(1).some((arg) => /^-p?i/.test(arg))) return null; + return nonOptionArgs(tokens.slice(1)); +} + +/** Strip -e/--expression flag-value pairs from sed tokens, tracking if any were found. */ +function filterSedExpressionTokens(sedTokens: string[]): { filtered: string[]; hasExpression: boolean } { + const filtered: string[] = []; + let hasExpression = false; + let ti = 0; + while (ti < sedTokens.length) { + if (sedTokens[ti] === "-e" || sedTokens[ti] === "--expression") { + ti += 2; hasExpression = true; + } else if (sedTokens[ti].startsWith("-e")) { + ti += 1; hasExpression = true; // -e'expr' concatenated form + } else if (sedTokens[ti].startsWith("--expression=")) { + ti += 1; hasExpression = true; + } else { + filtered.push(sedTokens[ti]); ti++; + } + } + return { filtered, hasExpression }; +} + +/** sed -i target extraction with -e/--expression and backup-extension handling. */ +function getSedTargets(tokens: string[]): string[] | null { + if (!tokens.slice(1).some((arg) => arg === "-i" || arg.startsWith("-i"))) return null; + const { filtered, hasExpression } = filterSedExpressionTokens(tokens.slice(1)); + const args = nonOptionArgs(filtered); + const extArg = args.length > 0 ? stripMatchingQuotes(args[0]) : ""; + const hasBackupExt = args.length > 0 && (extArg === "" || /^[a-zA-Z0-9._-]{1,10}$/.test(extArg)); + if (hasBackupExt) return hasExpression ? (extArg === "" ? args.slice(1) : args) : args.slice(2); + return hasExpression ? args : args.slice(1); +} + +/** wget target extraction from -O/--output-document flags. */ +function getWgetTargets(tokens: string[]): string[] { + const wArgs = tokens.slice(1); + let outputTarget: string | null = null; + for (let i = 0; i < wArgs.length; i++) { + if (wArgs[i] === "-O" && wArgs[i + 1]) { outputTarget = wArgs[i + 1]; i++; continue; } + if (wArgs[i].startsWith("-O") && wArgs[i].length > 2) { outputTarget = wArgs[i].slice(2); continue; } + if (wArgs[i] === "--output-document" && wArgs[i + 1]) { outputTarget = wArgs[i + 1]; i++; continue; } + if (wArgs[i].startsWith("--output-document=")) { outputTarget = wArgs[i].slice("--output-document=".length); } + } + if (outputTarget !== null) return stripMatchingQuotes(outputTarget) === "-" ? ["/dev/null"] : [outputTarget]; + const outputDir = getWgetOutputDir(tokens); + if (outputDir !== null) return [outputDir]; + return ["."]; // safety net — unreachable via getFilesystemMutationReason +} + +/** curl target extraction from -o/--output and -O/--remote-name flags. */ +function getCurlTargets(tokens: string[]): string[] | null { + const { hasRemoteName, outputs, outputDir } = getCurlWriteTargets(tokens); + const mapped = outputs.map((o) => stripMatchingQuotes(o) === "-" ? "/dev/null" : o); + const remoteNameTarget = outputDir ?? "."; + if (mapped.length > 0) return hasRemoteName ? [...mapped, remoteNameTarget] : mapped; + if (hasRemoteName) return [remoteNameTarget]; + return null; +} + +// ── Main dispatcher ───────────────────────────────────────────────── + +function getMutationTargets(command: string, tokens: string[]): string[] | null { + switch (command) { + case "rm": case "rmdir": case "unlink": case "mkdir": return getRmTargets(tokens); + case "truncate": return getTruncateTargets(tokens); + case "touch": return getTouchTargets(tokens); + case "chmod": case "chown": case "chgrp": return getChmodTargets(tokens); + case "cp": case "mv": case "install": case "ln": return getCpTargets(tokens); + case "tee": return getTeeTargets(tokens); + case "sed": return getSedTargets(tokens); + case "perl": case "ruby": return getPerlRubyTargets(tokens); + case "find": return getFindMutationTargets(tokens.slice(1)); + case "wget": return getWgetTargets(tokens); + case "curl": return getCurlTargets(tokens); + default: return null; + } +} + +function getFindMutationTargets(args: string[]): string[] | null { + // Skip glob-pattern args (e.g., -name '*.txt') — these cannot be filesystem roots. + const roots = args.filter((arg) => arg && !arg.startsWith("-") && !/[*?{}()\[\]~]/.test(arg)); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "-delete") return roots.length > 0 ? roots : ["."]; + if (["-exec", "-execdir", "-ok", "-okdir"].includes(arg)) return roots.length > 0 ? roots : ["."]; + if (["-fprintf", "-fprint", "-fprint0", "-fls"].includes(arg)) { + const output = args[i + 1]; + return output ? [output] : ["."]; + } + } + return null; +} + +function isPackageMutation(args: string[]): boolean { + // Match individual tokens against known package-mutation verbs. + // Token-level matching (vs. substring-on-joined-string) avoids false + // positives when a path or argument contains a verb word (install-sh, etc.). + const VERBS = new Set(["install", "uninstall", "update", "upgrade", "ci", "link", "publish", "add", "remove", "reinstall", "tap", "untap", "download", "build-dep", "i", "un", "ad", "rm", "up", "in", "rb"]); + return args.some((a) => VERBS.has(a.toLowerCase())); +} + +function findSudoCommandIndex(tokens: string[]): number { + const FLAGS_WITH_VALUE = new Set(["-u", "-g", "-p", "-C", "-T", "-h"]); + let i = 1; + while (i < tokens.length) { + const token = tokens[i]; + if (token === "--") return i + 1; + if (!token.startsWith("-")) return i; + if (FLAGS_WITH_VALUE.has(token)) i += 2; + else i += 1; + } + return tokens.length; +} + +/** + * Extract the command tokens from a shell segment, stripping env-prefixes, + * env-var assignments, and the `command` builtin wrapper. + * + * The `env` prefix is handled specially: env flags with values (-u, --unset, + * -S, -g) consume the next token as their value, and env-var assignments + * (KEY=value) before the real command are stripped. + */ +function getCommandTokens(segment: string): string[] { + const tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + let i = 0; + + if (tokens[i] === "env") { + i++; + // env -u VAR and -S "string" take a value — consume as flag-value pairs + const ENV_FLAGS_WITH_VALUE = new Set(["-u", "--unset", "-S", "--split-string", "-g", "--group"]); + while (i < tokens.length && tokens[i].startsWith("-")) { + if (ENV_FLAGS_WITH_VALUE.has(tokens[i])) { + i += 2; // skip flag + its value + } else { + i++; // valueless flag + } + } + while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++; + // Skip -- separator between env assignments and the command + if (i < tokens.length && tokens[i] === "--") i++; + if (i >= tokens.length) return ["env"]; + } + + while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++; + if (tokens[i] === "command") i++; + return tokens.slice(i); +} + +function nonOptionArgs(args: string[]): string[] { + const result: string[] = []; + let stopOptions = false; + for (const arg of args) { + if (!stopOptions && arg === "--") { + stopOptions = true; + continue; + } + if (!stopOptions && arg.startsWith("-") && arg !== "-") continue; + result.push(arg); + } + return result; +} + +function isSafeGitCommand(rest: string): boolean { + const trimmed = rest.trim(); + if (!trimmed) return false; + + const tokens = trimmed.split(/\s+/); + const FLAGS_WITH_VALUE = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace"]); + let subcommand = ""; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (FLAGS_WITH_VALUE.has(token)) { i++; continue; } + if (token.startsWith("-")) continue; + subcommand = token; + break; + } + + if (!subcommand) return false; + if (GIT_IMMUTABLE.has(subcommand)) return true; + if (GIT_MUTABLE.has(subcommand)) return false; + const mixed = GIT_MIXED[subcommand]; + if (!mixed) return false; + const afterSub = trimmed.slice(trimmed.indexOf(subcommand) + subcommand.length).trim(); + return mixed(afterSub); +} + +function stripMatchingQuotes(token: string): string { + if ( + (token.startsWith('"') && token.endsWith('"')) || + (token.startsWith("'") && token.endsWith("'")) + ) { + return token.slice(1, -1); + } + return token; +} + +/** + * Expand shell variable references ($VAR, ${VAR}) in a raw path string. + * Looks up the variable name in the provided map first, then falls back + * to process.env. Returns null if the path contains no variable reference + * or the variable is unknown. + */ +function expandShellVariable(rawPath: string, shellVars: ReadonlyMap, visited: Set = new Set()): string | null { + const braceMatch = rawPath.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-|:=)([^}]*))?\}(.*)$/); + if (braceMatch) { + const [, varName, operator, fallback = "", suffix] = braceMatch; + if (visited.has(varName)) return null; + visited.add(varName); + const value = shellVars.get(varName) ?? process.env[varName]; + if (value !== undefined && value !== "") return value + suffix; + if (operator === ":-" || operator === ":=") return fallback + suffix; + return null; + } + const plainMatch = rawPath.match(/^\$([A-Za-z_][A-Za-z0-9_]*)(.*)$/); + if (plainMatch) { + const varName = plainMatch[1]; + if (visited.has(varName)) return null; + visited.add(varName); + const value = shellVars.get(varName) ?? process.env[varName]; + return value !== undefined && value !== "" ? value + plainMatch[2] : null; + } + return null; +} + +/** + * Extract standalone shell variable assignments from a segment. + * Handles: VAR=value, export VAR=value, declare -r VAR=value, etc. + * Declaration keywords (export/declare/typeset/local/readonly) and their + * flags are skipped before looking for assignments. + * Returns empty map if any non-keyword, non-flag token is not an assignment. + * Special-cases $(mktemp) to a synthetic temp-dir path. + */ +function getStandaloneShellAssignments(segment: string, cwd: string, shellVars: ReadonlyMap = new Map()): Map { + const assignments = new Map(); + let tokens: string[] = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + if (tokens.length === 0) return assignments; + + // Skip declaration keywords and their flags (export -x, declare -r, etc.) + const DECL_KEYWORDS = new Set(["export", "declare", "typeset", "local", "readonly"]); + if (tokens.length > 0 && DECL_KEYWORDS.has(tokens[0]!)) { + tokens = tokens.slice(1) as string[]; + // Skip flags after the keyword (e.g., declare -r -x VAR=val) + while (tokens.length > 0 && tokens[0]!.startsWith("-")) { + tokens = tokens.slice(1) as string[]; + } + } + for (let i = 0; i < tokens.length; i++) { + const match = tokens[i]?.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) continue; + const [, name, initialRawValue] = match; + let rawValue = initialRawValue; + const quoteWrapped = rawValue.startsWith('"'); + const substitutionStart = quoteWrapped ? rawValue.slice(1) : rawValue; + + // Tokenization splits command substitutions on spaces. Rejoin a standalone + // assignment value until the substitution closes so mktemp templates are visible. + if (substitutionStart.startsWith("$(")) { + let depth = 1; + for (const ch of substitutionStart.slice(2)) { + if (ch === "(") depth++; + if (ch === ")") depth--; + } + while ((depth > 0 || (quoteWrapped && !rawValue.endsWith('"'))) && i + 1 < tokens.length) { + rawValue += ` ${tokens[++i]}`; + for (const ch of tokens[i]!) { + if (ch === "(") depth++; + if (ch === ")") depth--; + } + } + } else if (substitutionStart.startsWith("`")) { + while ((!rawValue.endsWith("`") || (quoteWrapped && !rawValue.endsWith('`"'))) && i + 1 < tokens.length) { + rawValue += ` ${tokens[++i]}`; + } + } + + const value = stripMatchingQuotes(rawValue); + const assignmentVars = new Map(shellVars); + for (const [assignedName, assignedValue] of assignments) assignmentVars.set(assignedName, assignedValue); + const syntheticMktempPath = getSafeMktempSyntheticPath(value, cwd, assignmentVars, name); + if (syntheticMktempPath) { + assignments.set(name, syntheticMktempPath); + continue; + } + assignments.set(name, value); + } + return assignments; +} + +function unwrapCommandSubstitution(rawValue: string): string | null { + const normalized = stripMatchingQuotes(rawValue); + if (normalized.startsWith("$(") && normalized.endsWith(")")) return normalized.slice(2, -1); + if (normalized.startsWith("`") && normalized.endsWith("`")) return normalized.slice(1, -1); + return null; +} + +function getSafeMktempSyntheticPath(rawValue: string, cwd: string, shellVars: ReadonlyMap, name: string): string | null { + const innerCmd = unwrapCommandSubstitution(rawValue); + if (!innerCmd) return null; + + const innerTokens = getCommandTokens(innerCmd); + if (innerTokens[0] !== "mktemp") return null; + + let template: string | null = null; + let tmpdirBase: string | null = null; + const args = innerTokens.slice(1); + + for (let i = 0; i < args.length; i++) { + const arg = stripMatchingQuotes(args[i]!); + if (!arg) continue; + if (arg === "--") { + template = stripMatchingQuotes(args[i + 1] ?? ""); + break; + } + if (arg === "-p") { + tmpdirBase = stripMatchingQuotes(args[i + 1] ?? ""); + if (!tmpdirBase) return null; + i++; + continue; + } + if (arg.startsWith("-p") && arg.length > 2) { + tmpdirBase = stripMatchingQuotes(arg.slice(2)); + continue; + } + if (arg === "-t") { + if (process.platform !== "darwin") return null; + template = stripMatchingQuotes(args[i + 1] ?? ""); + if (!template) return null; + tmpdirBase = TEMP_DIR; + i++; + continue; + } + if (arg.startsWith("-t") && arg.length > 2) { + if (process.platform !== "darwin") return null; + template = stripMatchingQuotes(arg.slice(2)); + if (!template) return null; + tmpdirBase = TEMP_DIR; + continue; + } + if (arg === "--tmpdir") { + // Bare --tmpdir defaults to TEMP_DIR, but with two following positionals + // the first is an explicit DIR even if relative, so validate it later. + const next = stripMatchingQuotes(args[i + 1] ?? ""); + const nextNext = stripMatchingQuotes(args[i + 2] ?? ""); + if (next && !next.startsWith("-") && nextNext && !nextNext.startsWith("-")) { + tmpdirBase = next; + i++; + continue; + } + if (next && !next.startsWith("-") && (next.startsWith("/") || next.startsWith("~") || next.startsWith("$") || next.includes("/") || next === "." || next === "..")) { + tmpdirBase = next; + i++; + continue; + } + tmpdirBase = TEMP_DIR; + continue; + } + if (arg.startsWith("--tmpdir=")) { + tmpdirBase = stripMatchingQuotes(arg.slice("--tmpdir=".length)) || TEMP_DIR; + continue; + } + if (arg === "--suffix") { + i++; + continue; + } + if (arg.startsWith("--suffix=")) continue; + if (arg.startsWith("-")) continue; + template = arg; + } + + if (tmpdirBase !== null) { + if (!isTempPath(tmpdirBase, cwd, shellVars)) return null; + if (!template) return path.join(TEMP_DIR, `.pi-mktemp-${name.toLowerCase()}`); + if (path.isAbsolute(template)) return null; + const joinedTemplate = path.join(tmpdirBase, template); + return isTempPath(joinedTemplate, cwd, shellVars) + ? path.join(TEMP_DIR, `.pi-mktemp-${name.toLowerCase()}`) + : null; + } + + if (template && !isTempPath(template, cwd, shellVars)) return null; + return path.join(TEMP_DIR, `.pi-mktemp-${name.toLowerCase()}`); +} + +/** + * Expand a glob pattern to include hidden-file (dotfile) variants. + * + * WORKAROUND: `node:fs.globSync` ignores the `dot: true` option on + * Node.js v24+ — it silently skips dotfiles. This function explicitly + * generates `.*` variants for each wildcard segment so hidden files + * are matched. + */ +function expandHiddenGlobPatterns(pattern: string): string[] { + const segments = pattern.split("/"); + const options = segments.map((segment) => { + if (segment === "**") return [segment, "**/.*", "**/.*/**"]; + // Literal or already-dotfile segments need no expansion. + // Brace patterns ({a,b}) get a redundant .{a,b} variant — harmless. + if (!/[*?{}\[\]]/.test(segment) || segment.startsWith(".")) return [segment]; + return [segment, `.${segment}`]; + }); + const patterns = new Set(); + const visit = (index: number, built: string[]) => { + if (index === options.length) return void patterns.add(built.join("/")); + for (const option of options[index]) visit(index + 1, [...built, option]); + }; + visit(0, []); + return [...patterns]; +} + +function isTempPath(rawPath: string, cwd: string, shellVars: ReadonlyMap = new Map(), visited: Set = new Set()): boolean { + const normalized = stripMatchingQuotes(rawPath); + if (!normalized || normalized === "/dev/null" || /^&\d+$/.test(normalized)) return true; + + // Expand ~ and ~/path to the home directory (os.homedir()). + // ~user/path is not resolvable without getpwuid — block conservatively. + if (normalized.startsWith("~")) { + if (normalized === "~" || normalized.startsWith("~/")) { + const expanded = normalized.replace(/^~/, os.homedir()); + return isTempPath(expanded, cwd); + } + return false; // ~user/path cannot be resolved safely + } + + const expandedVar = expandShellVariable(normalized, shellVars, visited); + if (expandedVar !== null && expandedVar !== normalized) { + return isTempPath(expandedVar, cwd, shellVars, visited); + } + + // Unresolved dynamic paths are unsafe: if a shell var expansion still leaves + // command/process substitution or an unknown $VAR, we cannot prove temp-dir safety. + if (/`|\$\(|<\(/.test(normalized) || /\$(?:\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)/.test(normalized)) { + return false; + } + + if (/[*?{}\[\]]/.test(normalized)) { + // Glob pattern - resolve against cwd and check each target individually. + // Expand hidden variants explicitly because node:fs globSync skips dotfiles. + // Empty glob (no matches) is allowed — no files to mutate. + try { + const matches = globSync(expandHiddenGlobPatterns(normalized), { cwd }); + if (matches.length === 0) return true; + return matches.every((m) => isTempPath(m, cwd)); + } catch { + return false; + } + } + const absolute = path.resolve(cwd, normalized); + // Resolve symlinks so /tmp/link -> /etc/passwd is correctly classified as non-temp. + // Walking up to the nearest existing ancestor handles new files inside symlinked dirs. + const real = resolveRealPath(absolute); + const relative = path.relative(TEMP_DIR, real); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +/** + * Read the file redirect target starting at position `start`. + * + * Handles quoted targets (single/double quotes) and backslash escapes. + * Scope: > (write), >> (append), >| (noclobber override). Heredoc redirects + * (<= cmd.length) return { target: "", end: i }; + + const first = cmd[i]; + if (first === '"' || first === "'") { + const quote = first; + let target = quote; + i++; + while (i < cmd.length) { + const ch = cmd[i]; + target += ch; + if (ch === "\\" && quote === '"' && i + 1 < cmd.length) { + i++; + target += cmd[i]; + continue; + } + if (ch === quote) { + i++; + break; + } + i++; + } + return { target, end: i }; + } + + let target = ""; + while (i < cmd.length) { + const ch = cmd[i]; + if (/\s/.test(ch) || ch === ";" || ch === "|" || ch === "\n") break; + if (ch === "&" && target !== "") break; + target += ch; + i++; + } + return { target, end: i }; +} + +/** + * Detect write redirects (>) to unsafe targets outside the temp dir. + * + * Scope: > (write), >> (append), >| (noclobber override), 2> (stderr), &> (combined). + * Heredoc redirect targets (< = new Map()): string | null { + let quote: '"' | "'" | null = null; + let escaped = false; + + for (let i = 0; i < cmd.length; i++) { + const ch = cmd[i]; + if (escaped) { escaped = false; continue; } + if (ch === "\\") { escaped = true; continue; } + if (quote) { if (ch === quote) quote = null; continue; } + if (ch === '"' || ch === "'") { quote = ch; continue; } + if (ch !== ">") continue; + + const next = cmd[i + 1]; + // >&N = fd redirect (e.g., 2>&1) — not a file write, skip + if (next === "&" && /^[\d-]$/.test(cmd[i + 2] ?? "")) continue; + // >= is the comparison operator (e.g., in [[ or node -e), not a write redirect + if (next === "=") continue; + // >& = combined stdout+stderr redirect to a file, treat as 2-char operator + const opLen = next === ">" || next === "|" || next === "&" ? 2 : 1; + const { target, end } = readRedirectTarget(cmd, i + opLen); + if (!isTempPath(target, cwd, shellVars)) return stripMatchingQuotes(target) || "(unknown target)"; + i = Math.max(i, end - 1); + } + + return null; +} + +/** + * Split a shell command string into segments separated by shell operators. + * + * Handles quoted strings (single/double quotes) and backslash escapes. + * Shell operator handling: + * ; — sequential (segment boundary) + * | — pipe (segment boundary) + * & — background (segment boundary, but >& and <& are redirects not separators) + * && — AND (segment boundary) + * || — OR (segment boundary) + * \n — newline (segment boundary) + * The >| and >& operators are consumed as part of the preceding segment. + */ +function splitUnquotedShellSegments(cmd: string): string[] { + const segments: string[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + let escaped = false; + + for (let i = 0; i < cmd.length; i++) { + const ch = cmd[i]; + const next = cmd[i + 1]; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + if (ch === "\\") { + current += ch; + escaped = true; + continue; + } + if (quote) { + current += ch; + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + current += ch; + continue; + } + if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) { + segments.push(current); + current = ""; + i++; + continue; + } + const prev = current[current.length - 1]; + if (ch === "|" && prev === ">") { + current += ch; + continue; + } + if (ch === "&" && (prev === ">" || prev === "<" || next === ">")) { + current += ch; + continue; + } + if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") { + segments.push(current); + current = ""; + continue; + } + current += ch; + } + segments.push(current); + return segments; +} + +/** + * Extract command substitution targets ($(...) and backticks) from a shell line. + * + * Uses simple depth-tracked matching. This is a best-effort guard — nested + * nesting, backslash escapes, and quote-aware tracking are intentionally + * skipped for simplicity since this is not a security boundary. + */ +function extractCommandSubstitutions(line: string): string[] { + const commands: string[] = []; + + // Backtick substitutions: `` `cmd` `` + const backtickRe = /`([^`]*)`/g; + let match: RegExpExecArray | null; + while ((match = backtickRe.exec(line)) !== null) { + if (match[1].trim()) commands.push(match[1].trim()); + } + + // $() substitutions: handles arbitrary nesting via depth counter + for (let i = 0; i < line.length; i++) { + if (line[i] !== "$" || line[i + 1] !== "(") continue; + let depth = 1; + let cmd = ""; + let j = i + 2; + for (; j < line.length && depth > 0; j++) { + if (line[j] === "(" && line[j - 1] === "$") depth++; + else if (line[j] === ")") depth--; + if (depth > 0) cmd += line[j]; + } + if (cmd.trim()) commands.push(cmd.trim()); + i = j; + } + + // <() process substitutions: extract inner command for recursive classification. + // Handles one level of nesting inside <(). + const procSubRe = /<\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g; + let procMatch: RegExpExecArray | null; + while ((procMatch = procSubRe.exec(line)) !== null) { + if (procMatch[1].trim()) commands.push(procMatch[1].trim()); + } + + return commands; +} + +// ── Shared readonly bash guard (consumed by parent tool_call hook and child spawnHook) ── + +export type ReadonlyBashGuardResult = + | { action: "allow" } + | { action: "block"; reason: string } + | { action: "sandbox"; sandboxedCommand: string }; + +/** + * Apply the readonly bash guard to a command. + * + * L1: OS-level sandboxing — wraps command if available (sandbox-exec / bwrap). + * L2: Command-pattern inspection — blocks if OS sandbox unavailable. + * + * @param cmd - Raw bash command string + * @param cwd - Working directory for path resolution + * @returns Structured result: allow, block (with reason), or sandbox (with wrapped command) + */ +export function applyReadonlyBashGuard(cmd: string, cwd: string): ReadonlyBashGuardResult { + // L1: OS sandbox (primary enforcement when available) + if (canUseOsSandbox()) { + const verdict = classifyBashCommand(cmd, cwd); + if (verdict.ok === false) { + return { action: "block", reason: `Readonly mode: command blocked.\nReason: ${verdict.reason}\nCommand: ${cmd}` }; + } + return { action: "sandbox", sandboxedCommand: wrapCommandWithOsSandbox(cmd) }; + } + + // L2: Pattern inspection fallback (no sandbox available) + const verdict = classifyBashCommand(cmd, cwd); + if (verdict.ok === false) { + return { action: "block", reason: `Readonly mode: command blocked.\nReason: ${verdict.reason}\nCommand: ${cmd}` }; + } + return { action: "allow" }; +} diff --git a/readonly-cache.ts b/readonly-cache.ts new file mode 100644 index 0000000..50d0efd --- /dev/null +++ b/readonly-cache.ts @@ -0,0 +1,217 @@ +/** + * Readonly cache for skill/prompt-template frontmatter. + * + * Populated lazily in `before_agent_start` from: + * 1. Loaded skills (via `systemPromptOptions.skills`). + * 2. Resolved prompt commands from `pi.getCommands()`. + * 3. Standard prompt directories as a partial fallback: + * `~/.pi/agent/prompts/` and trusted `cwd/.pi/prompts/`. + * + * All production prompt-resolution happens through + * `populatePromptCacheFromResolvedCommandsAndDirs`. The narrower + * `populateFromPromptDirs`, `populateFromPromptCommands`, and + * `populateFromPromptTemplates` have been removed as dead code — + * they duplicated the logic of the production path. Any test that + * needs to exercise prompt caching should go through the production + * function with an empty command list. + */ + +import { readFileSync, statSync, readdirSync } from "node:fs"; +import { join, extname, basename } from "node:path"; +import { homedir } from "node:os"; +import { parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import type { Skill, SlashCommandInfo } from "@earendil-works/pi-coding-agent"; + +export interface ReadonlyCacheEntry { + readonly: boolean | null; + mtimeMs: number; + filePath: string; +} + +export interface ReadonlyCacheIssue { + kind: "invalid-readonly-value" | "malformed-frontmatter" | "unreadable-file"; + filePath: string; +} + +export interface ReadonlyCacheStore { + readonlySkillCache: Map; + readonlyPromptCache: Map; + readonlySkillIssues: Map; + readonlyPromptIssues: Map; +} + +interface CacheReadResult { + entry: ReadonlyCacheEntry | null; + issue: ReadonlyCacheIssue | null; +} + +function readCacheEntry(filePath: string, previous?: ReadonlyCacheEntry): CacheReadResult { + let st; + try { + st = statSync(filePath); + } catch (error: any) { + return error?.code === "ENOENT" || error?.code === "ENOTDIR" + ? { entry: null, issue: null } + : { entry: null, issue: { kind: "unreadable-file", filePath } }; + } + + if (previous && previous.filePath === filePath && st.mtimeMs === previous.mtimeMs) { + return { entry: previous, issue: null }; + } + + let content: string; + try { + content = readFileSync(filePath, "utf-8"); + } catch { + return { entry: null, issue: { kind: "unreadable-file", filePath } }; + } + + try { + const { frontmatter } = parseFrontmatter>(content); + const readonly = frontmatter["readonly"]; + if (readonly === undefined || typeof readonly === "boolean") { + return { + entry: { readonly: readonly ?? null, mtimeMs: st.mtimeMs, filePath }, + issue: null, + }; + } + return { + entry: { readonly: null, mtimeMs: st.mtimeMs, filePath }, + issue: { kind: "invalid-readonly-value", filePath }, + }; + } catch { + return { + entry: null, + issue: { kind: "malformed-frontmatter", filePath }, + }; + } +} + +function replaceCache(target: Map, next: Map): void { + target.clear(); + for (const [name, entry] of next) target.set(name, entry); +} + +function replaceIssues(target: Map, next: Map): void { + target.clear(); + for (const [name, issue] of next) target.set(name, issue); +} + +function setEntry( + nextCache: Map, + nextIssues: Map, + name: string, + result: CacheReadResult, +): void { + if (result.entry) nextCache.set(name, result.entry); + if (result.issue) nextIssues.set(name, result.issue); +} + +export function cacheLookupSkill(store: ReadonlyCacheStore, name: string): boolean | null { + return store.readonlySkillCache.get(name)?.readonly ?? null; +} + +export function cacheLookupPrompt(store: ReadonlyCacheStore, name: string): boolean | null { + return store.readonlyPromptCache.get(name)?.readonly ?? null; +} + +export function cacheLookupCommand(store: ReadonlyCacheStore, name: string): boolean | null { + return cacheLookupPrompt(store, name); +} + +export function cacheLookupSkillIssue(store: ReadonlyCacheStore, name: string): ReadonlyCacheIssue | null { + return store.readonlySkillIssues.get(name) ?? null; +} + +export function cacheLookupCommandIssue(store: ReadonlyCacheStore, name: string): ReadonlyCacheIssue | null { + return store.readonlyPromptIssues.get(name) ?? null; +} + +/** + * Format a user-facing warning message for a readonly frontmatter issue. + * Used when a skill or prompt has invalid `readonly` frontmatter or the + * source file cannot be read. Missing `readonly` is a normal no-op. + */ +export function formatReadonlyFrontmatterIssue(commandRef: string, issue: ReadonlyCacheIssue): string { + const detail = issue.kind === "invalid-readonly-value" + ? "`readonly` frontmatter must be `true` or `false`" + : issue.kind === "malformed-frontmatter" + ? "frontmatter could not be parsed" + : "prompt/skill file could not be read"; + return `Readonly frontmatter ignored for \`${commandRef}\`: ${detail} at \`${issue.filePath}\`.`; +} + +export function populateFromSkills(store: ReadonlyCacheStore, skills: Skill[]): void { + const nextCache = new Map(); + const nextIssues = new Map(); + for (const skill of skills) { + const result = readCacheEntry(skill.filePath, store.readonlySkillCache.get(skill.name)); + setEntry(nextCache, nextIssues, skill.name, result); + } + replaceCache(store.readonlySkillCache, nextCache); + replaceIssues(store.readonlySkillIssues, nextIssues); +} + +function collectPromptFilesFromDir( + store: ReadonlyCacheStore, + dir: string, + nextCache: Map, + nextIssues: Map, + blockedNames: Set, +): void { + let files: string[]; + try { + files = readdirSync(dir); + } catch { + return; + } + for (const file of files) { + if (extname(file) !== ".md") continue; + const name = basename(file, ".md"); + if (!name || blockedNames.has(name) || nextCache.has(name) || nextIssues.has(name)) continue; + const result = readCacheEntry(join(dir, file), store.readonlyPromptCache.get(name)); + setEntry(nextCache, nextIssues, name, result); + } +} + +/** + * Populate the prompt cache from resolved prompt commands plus the standard + * prompt directories that Pi also uses on disk. + * + * Priority for duplicate names: + * 1. Resolved prompt commands from `pi.getCommands()`. + * 2. Trusted project prompts in `cwd/.pi/prompts/`. + * 3. Global prompts in `~/.pi/agent/prompts/`. + * + * This is intentionally not a full prompt-source resolver. Anything outside + * those standard dirs must already be surfaced by `pi.getCommands()`. + */ +export function populatePromptCacheFromResolvedCommandsAndDirs( + store: ReadonlyCacheStore, + commands: SlashCommandInfo[], + cwd: string, + projectTrusted: boolean, +): void { + const nextCache = new Map(); + const nextIssues = new Map(); + const blockedNames = new Set(); + + for (const command of commands) { + if (!command.name) continue; + if (command.source !== "prompt") { + blockedNames.add(command.name); + continue; + } + if (nextCache.has(command.name) || nextIssues.has(command.name)) continue; + const result = readCacheEntry(command.sourceInfo.path, store.readonlyPromptCache.get(command.name)); + setEntry(nextCache, nextIssues, command.name, result); + } + + if (projectTrusted) { + collectPromptFilesFromDir(store, join(cwd, ".pi", "prompts"), nextCache, nextIssues, blockedNames); + } + collectPromptFilesFromDir(store, join(homedir(), ".pi", "agent", "prompts"), nextCache, nextIssues, blockedNames); + + replaceCache(store.readonlyPromptCache, nextCache); + replaceIssues(store.readonlyPromptIssues, nextIssues); +} diff --git a/readonly-rehydration.ts b/readonly-rehydration.ts new file mode 100644 index 0000000..0acc1a5 --- /dev/null +++ b/readonly-rehydration.ts @@ -0,0 +1,34 @@ +/** + * Readonly rehydration logic — extract branch-scanning into a pure function + * for testability. The rehydrateReadonlyState wrapper in index.ts calls this + * and handles the nudge side-effect. + */ + +/** + * Scan a session branch for the `agenticoding-readonly` custom entry and + * return whether readonly should be enabled. The most recent entry (found + * by scanning in reverse) wins. + * + * @param branch - Session branch entries from sessionManager.getBranch() + * @param pi - Used to check the `--readonly` CLI flag as fallback + * @returns `true` if readonly should be enabled after rehydration + */ +export function getReadonlyFromBranch( + branch: readonly unknown[], + pi: { getFlag: (name: string) => unknown }, +): boolean { + // Scan branch in reverse for the most recent readonly entry + for (let i = branch.length - 1; i >= 0; i--) { + const entry = branch[i]; + if (!entry || typeof entry !== "object") continue; + const e = entry as Record; + if (e.type !== "custom" || e.customType !== "agenticoding-readonly") continue; + const d = e.data as Record | undefined; + return d?.enabled === true; + } + // No branch entry found — fall back to CLI flag + if (pi.getFlag("readonly") === true) { + return true; + } + return false; +} diff --git a/resolve-path.ts b/resolve-path.ts new file mode 100644 index 0000000..f1c4ced --- /dev/null +++ b/resolve-path.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Resolve a path's real location, following symlinks. + * If the path doesn't exist, walk up to the nearest existing ancestor + * and resolve that, then append the remaining components. + * This handles the common case where a new file is created inside a + * symlinked temp dir (/tmp -> /private/tmp on macOS). + */ +export function resolveRealPath(p: string): string { + try { + return fs.realpathSync(p); + } catch { + const parent = path.dirname(p); + if (parent === p) return p; // hit root + try { + const realParent = fs.realpathSync(parent); + return path.join(realParent, path.basename(p)); + } catch { + return path.join(resolveRealPath(parent), path.basename(p)); + } + } +} diff --git a/spawn/index.ts b/spawn/index.ts index 2353c03..dec51d1 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -20,6 +20,8 @@ import type { TextContent } from "@earendil-works/pi-ai"; import { AuthStorage, createAgentSession, + createBashToolDefinition, + defineTool, ModelRegistry, SessionManager, } from "@earendil-works/pi-coding-agent"; @@ -28,6 +30,7 @@ import { Type } from "typebox"; import type { AgenticodingState } from "../state.js"; import { formatPageList } from "../notebook/store.js"; import { createNotebookToolDefinitions } from "../notebook/tools.js"; +import { applyReadonlyBashGuard } from "../readonly-bash.js"; import { renderSpawnCall, renderSpawnResult, @@ -74,13 +77,24 @@ function getLastAssistantOutcome(messages: AssistantMessageLike[]): SpawnOutcome * Line-count limit is applied first, then byte limit. * May end mid-line if the byte limit is the tighter constraint. */ -function truncateText(text: string, maxLines: number, maxBytes: number): string { +export function truncateText(text: string, maxLines: number, maxBytes: number): string { const lines = text.split("\n"); let truncated = lines.slice(0, maxLines).join("\n"); - if (new TextEncoder().encode(truncated).length > maxBytes) { - truncated = new TextDecoder().decode( - new TextEncoder().encode(truncated).slice(0, maxBytes), - ); + const encoded = new TextEncoder().encode(truncated); + if (encoded.length > maxBytes) { + // Shrink byte-by-byte at the boundary until we have valid UTF-8. + // This avoids splitting a multi-byte character mid-sequence. + // An empty slice (0 bytes) is always valid and decodes to empty string. + let slice = encoded.slice(0, maxBytes); + for (;;) { + try { + truncated = new TextDecoder("utf-8", { fatal: true }).decode(slice); + break; + } catch { + if (slice.length === 0) break; + slice = slice.slice(0, slice.length - 1); + } + } } return truncated; } @@ -138,6 +152,45 @@ export function buildChildToolNames( return [...new Set([...inheritedTools, ...childTools.map((tool) => tool.name)])]; } +/** + * Filter child tool names for readonly mode. + * Removes write/edit from the tool list entirely — children start with + * a fresh context, so there is no cache to preserve. + */ +export function filterReadonlyToolNames(toolNames: string[], readonlyEnabled: boolean): string[] { + return readonlyEnabled + ? toolNames.filter((name) => name !== "write" && name !== "edit") + : toolNames; +} + +/** + * Create a bash tool definition for readonly-mode child sessions. + * + * Applies OS-level sandboxing (sandbox-exec on macOS, bwrap on Linux) when available. + * Falls back to classifyBashCommand command-pattern inspection when no OS sandbox + * is available (Windows). The fallback blocks filesystem writes/deletions outside + * the OS temp dir using the same logic as the parent's tool_call hook. + */ +function createReadonlyChildBashTool( + cwd: string, +) { + const bashTool = createBashToolDefinition(cwd, { + spawnHook: (spawnContext) => { + const result = applyReadonlyBashGuard(spawnContext.command, cwd); + if (result.action === "block") { + throw new Error(result.reason); + } + if (result.action === "sandbox") { + spawnContext.command = result.sandboxedCommand; + } + return spawnContext; + }, + }); + return defineTool(bashTool); +} + + + // ── Spawn tool metadata ── const SPAWN_DESCRIPTION = @@ -167,7 +220,6 @@ const SPAWN_PARAMETERS = Type.Object({ }); - /** * Build the custom tool set for child agent sessions. * @@ -186,7 +238,6 @@ export function createChildTools( } - // ── Shared spawn execution logic ────────────────────────────────────── /** @@ -230,15 +281,21 @@ export async function executeSpawn( const notebookListing = listing ? "Available notebook pages:\n" + listing : "No notebook pages."; + const readonlyNotice = state.readonlyEnabled + ? "\n\nReadonly restrictions apply. Do not attempt filesystem writes or deletions outside the OS temp dir. Environment inheritance is allowed." + : ""; + const authorityNote = state.readonlyEnabled + ? "You inherit readonly authority in this session." + : "You have the same authority as the parent."; const fullPrompt = `You are a focused child agent spawned by a parent agent. ` + - `You have the same authority as the parent. ` + + `${authorityNote} ` + `Children cannot spawn further children. ` + `Your result will be read by the parent, so be concise and complete.\n\n` + `${notebookListing}\n\n` + `If you write notebook pages, store only durable grounding knowledge for future contexts. ` + `Keep transient task state in your final reply to the parent.\n\n` + - `## Task\n\n${params.prompt}\n\n` + + `## Task\n\n${params.prompt}${readonlyNotice}\n\n` + `When complete, provide a concise summary of findings. ` + `Keep the result under ${CHILD_MAX_LINES} lines / ${(CHILD_MAX_BYTES / 1024).toFixed(0)}KB.`; @@ -249,14 +306,31 @@ export async function executeSpawn( const childTools = createChildTools(pi, state, { isStale }); const parentToolNames = pi.getActiveTools(); const childToolNames = buildChildToolNames(parentToolNames, childTools, pi.getAllTools()); + // Children: readonly vs non-readonly tool strategy differs from the parent. + // Parent keeps write/edit in the tool list and blocks at call time to avoid + // context-cache misses (index.ts). Children start with a fresh context — no + // cache to preserve — so we remove write/edit from the tool list entirely + // (cleaner than advertising tools that always error). The readonly bash guard + // (sandbox-exec/bwrap or classifyBashCommand fallback) still propagates to + // children via createReadonlyChildBashTool below. + // + // This is a guardrail for a coding agent, not a security boundary. + const effectiveChildTools = [ + ...childTools, + ...(state.readonlyEnabled && childToolNames.includes("bash") + ? [createReadonlyChildBashTool(ctx.cwd)] + : []), + ]; + + const effectiveToolNames = filterReadonlyToolNames(childToolNames, state.readonlyEnabled); const { session } = await sessionFactory({ sessionManager: SessionManager.inMemory(), model: childModel, thinkingLevel: childThinking, cwd: ctx.cwd, - tools: childToolNames, - customTools: childTools, + tools: effectiveToolNames, + customTools: effectiveChildTools, authStorage, modelRegistry, }); @@ -265,7 +339,7 @@ export async function executeSpawn( let wasAborted = false; const abortChild = () => { wasAborted = true; - session.abort().catch(e => console.error("[spawn] abort failed:", toolCallId, e)); + session.abort().catch(() => {}); }; const clearChildSession = () => { if (state.childSessions.get(toolCallId) === session) { @@ -277,7 +351,7 @@ export async function executeSpawn( }; const abortAndInvalidate = async () => { clearChildSession(); - await session.abort().catch(e => console.error("[spawn] abort failed:", toolCallId, e)); + await session.abort().catch(() => {}); throw invalidatedError; }; @@ -361,7 +435,6 @@ export async function executeSpawn( } } catch (error: unknown) { statsUnavailable = true; - console.warn("[spawn] Failed to collect child session stats:", error, toolCallId); } if (isStale()) { @@ -424,7 +497,17 @@ export function registerSpawnTool( ctx: ExtensionContext, ) { const parentThinking: ThinkingValue = pi.getThinkingLevel(); - return executeSpawn(_toolCallId, pi, ctx, state, params, signal, onUpdate, parentThinking, sessionFactory); + return executeSpawn( + _toolCallId, + pi, + ctx, + state, + params, + signal, + onUpdate, + parentThinking, + sessionFactory, + ); }, renderCall: renderSpawnCall, diff --git a/spawn/renderer.ts b/spawn/renderer.ts index 5c837a2..0577893 100644 --- a/spawn/renderer.ts +++ b/spawn/renderer.ts @@ -537,7 +537,6 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget : undefined; } catch (error) { this.unsubscribe = undefined; - console.warn("[spawn] Failed to subscribe to child session events:", this.ownedToolCallId, error); } } @@ -607,7 +606,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.state = undefined; this.attachedChildSessionEpoch = undefined; if (session && ownedToolCallId && liveChildSessions?.get(ownedToolCallId) === session) { - session.abort().catch(e => console.error("[spawn] abort failed:", ownedToolCallId, e)); + session.abort().catch(() => {}); liveChildSessions.delete(ownedToolCallId); } } @@ -697,11 +696,6 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget if (isExpectedToolComponentFailure(error)) { return undefined; } - const failureKey = `${toolCallId}:${toolName}`; - if (!this.toolComponentFailures.has(failureKey)) { - this.toolComponentFailures.add(failureKey); - console.warn("[spawn] Failed to create tool component:", toolCallId, toolName, error); - } return undefined; } } @@ -929,7 +923,6 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget if (isExpectedToolComponentFailure(error)) { return; } - console.warn(`[spawn] streaming component error (${eventType}):`, this.ownedToolCallId, error); } // ── Event handlers ─────────────────────────────────────────────── @@ -1144,7 +1137,6 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget this.resetRenderBatching(); // Prevent a single bad event from killing the subscription. // The TUI degrades gracefully — stale content until next successful event. - console.warn("[spawn] Event handler error:", event.type, this.ownedToolCallId, error); } } } diff --git a/state.ts b/state.ts index 626c696..1bb3f52 100644 --- a/state.ts +++ b/state.ts @@ -6,6 +6,7 @@ */ import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import type { ReadonlyCacheEntry, ReadonlyCacheIssue } from "./readonly-cache.js"; export interface AgenticodingState { /** Compact notebook pages keyed by kebab-case name */ @@ -35,9 +36,13 @@ export interface AgenticodingState { /** User-requested handoff that must result in a real tool-driven compaction. */ pendingRequestedHandoff: { - direction: string; - enforcementAttempts: number; toolCalled: boolean; + /** Temporary readonly exception: allow only the handoff tool for this request. */ + readonlyBypassActive: boolean; + /** Fresh context after compaction resumes in readonly mode. */ + resumeReadonlyAfterHandoff: boolean; + /** Turn counter for enforcement nudge. Cleared after N consecutive failed attempts. */ + enforcementAttempts: number; } | null; /** @@ -63,12 +68,54 @@ export interface AgenticodingState { * Increment on /new so stale child updates/results cannot touch fresh state. */ childSessionEpoch: number; + + /** Whether readonly mode is active — blocks write/edit and bash writes outside temp; handoff requires explicit /handoff. */ + readonlyEnabled: boolean; + + /** One-shot flag: deliver a readonly ON or OFF nudge via context hook, then clear. */ + readonlyNudgePending: boolean; + + /** Session-owned readonly frontmatter cache for loaded skills. */ + readonlySkillCache: Map; + + /** Session-owned readonly frontmatter cache for resolved prompt commands. */ + readonlyPromptCache: Map; + + /** Frontmatter issues keyed by skill name. */ + readonlySkillIssues: Map; + + /** Frontmatter issues keyed by prompt command name. */ + readonlyPromptIssues: Map; + + /** + * FIFO slash-command intents extracted from queued user inputs, deferred to + * before_agent_start where the readonly frontmatter cache is populated. + * Empty = no pending toggle. + * + * `type` preserves `/skill:name` vs generic `/name` so lookup can target the + * correct readonly source without conflating skills and prompt templates. + * Enqueued in `input`, drained in `before_agent_start` until the first real + * readonly decision, cleared on session reset. + */ + pendingReadonlyCommands: Array<{ type: "skill" | "command"; name: string }>; + + /** + * Last context-percentage band at which the watchdog nudge was delivered. + * null = never delivered. Bands: null (<30), 0 (30-49), 1 (50-69), 2 (70+). + * Used to throttle nudges — only nudge when crossing into a higher band. + */ + lastWatchdogBand: number | null; + } /** Create a fresh state instance. Call reset() on /new. */ export function createState(): AgenticodingState { const childSessions = new Map(); const liveChildSessions = new Map(); + const readonlySkillCache = new Map(); + const readonlyPromptCache = new Map(); + const readonlySkillIssues = new Map(); + const readonlyPromptIssues = new Map(); const state: AgenticodingState = { notebookPages: new Map(), epoch: 0, @@ -81,6 +128,14 @@ export function createState(): AgenticodingState { childSessions, liveChildSessions, childSessionEpoch: 0, + readonlyEnabled: false, + readonlyNudgePending: false, + readonlySkillCache, + readonlyPromptCache, + readonlySkillIssues, + readonlyPromptIssues, + pendingReadonlyCommands: [], + lastWatchdogBand: null, }; // Prevent replacement — spawn lifecycle code and renderer ownership checks // depend on stable map identity. Only .clear() and .delete() are valid — @@ -111,6 +166,14 @@ export function resetState(state: AgenticodingState): void { state.lastContextPercent = null; state.pendingHandoff = null; state.pendingRequestedHandoff = null; + state.readonlyEnabled = false; + state.readonlyNudgePending = false; + state.readonlySkillCache.clear(); + state.readonlyPromptCache.clear(); + state.readonlySkillIssues.clear(); + state.readonlyPromptIssues.clear(); + state.pendingReadonlyCommands.length = 0; + state.lastWatchdogBand = null; abortAndClearChildSessions(state); } @@ -123,6 +186,6 @@ export function abortAndClearChildSessions(state: AgenticodingState): void { state.childSessions.clear(); state.liveChildSessions.clear(); for (const [session, id] of seen) { - session.abort().catch((e: unknown) => console.warn("[spawn] abort failed:", id, e)); + session.abort().catch(() => {}); } } diff --git a/system-prompt.ts b/system-prompt.ts index ae7b809..d098407 100644 --- a/system-prompt.ts +++ b/system-prompt.ts @@ -11,18 +11,22 @@ export const CONTEXT_PRIMER = ` One context, one job. Research is one job. Planning is one job. Execution is one job. When the job changes, call the handoff tool. -### The primacy-zone heuristic +### Plan then execute +Before acting, deliberate internally. Does the work still fit the +current topic? If yes, break it into phases, size each sub-task, +and delegate >10k-token sub-tasks via spawn. If no, prefer handoff. +Consider spawn for verification. When planning, the plan must include full +delegation plan if relevant for the task at hand. +End by presenting the concise plan optimized for a human checkpoint. + +### The primacy-zone You use long context unevenly. Performance can degrade as context grows — -even far from the window limit. Treat the first ~30% as a practical heuristic -for keeping the current job near the front of attention. The system tells you -exact context usage after each turn, and watchdog reminders may be injected -before LLM calls when context is past the heuristic. Watchdog reminders are -advisory only. +even far from the window limit. Treat the first ~30% as the optimal working zone. ### Spawn — isolate noise Delegate isolated work to child agents. They are trusted extensions of you, with their own context and the same authority. You receive only condensed -results. Parent context stays at orchestration level. Siblings run in parallel. +results. Your context stays at orchestration level. Siblings run in parallel. ### Notebook — durable cross-context grounding Treat the notebook as durable grounding for future contexts. Each page covers diff --git a/temp-dir.ts b/temp-dir.ts new file mode 100644 index 0000000..b8ae0ad --- /dev/null +++ b/temp-dir.ts @@ -0,0 +1,17 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Canonical (symlink-resolved) OS temp dir path. + * + * Resolved at module import time. Shared by readonly-bash.ts and os-sandbox.ts + * so both modules agree on the same temp directory. + * + * This lives in its own module to avoid a cyclic dependency between + * readonly-bash.ts (imports from os-sandbox.ts) and os-sandbox.ts. + */ +export const TEMP_DIR = (() => { + const resolved = path.resolve(os.tmpdir()); + try { return fs.realpathSync(resolved); } catch { return resolved; } +})(); diff --git a/tests/unit/config-invariants.test.ts b/tests/unit/config-invariants.test.ts new file mode 100644 index 0000000..1eeac4d --- /dev/null +++ b/tests/unit/config-invariants.test.ts @@ -0,0 +1,145 @@ +/** + * Invariant tests for the audit-ci security audit configuration. + * + * Validates that allowlist entries have unexpired expiry dates, that the + * CI workflow ordering (audit → unit → e2e) is preserved, and that the + * allowlist matches the current lockfile's actual vulnerability state. + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +type AuditRecord = { + active: boolean; + expiry: string; + notes: string; +}; + +type AuditConfig = { + $schema: string; + moderate: boolean; + allowlist: Array>; +}; + +type PackageJson = { + engines: { + node: string; + }; +}; + +const AUDIT_SCHEMA = "https://github.com/IBM/audit-ci/raw/main/docs/schema.json"; +const REPO_ROOT_URL = new URL("../../", import.meta.url); +const REPO_ROOT = fileURLToPath(REPO_ROOT_URL); +const AUDIT_CONFIG_PATH = new URL("audit-ci.jsonc", REPO_ROOT_URL); +const AUDIT_CLI_PATH = fileURLToPath( + new URL("node_modules/audit-ci/dist/bin.js", REPO_ROOT_URL), +); +const PACKAGE_JSON_PATH = new URL("package.json", REPO_ROOT_URL); +const WORKFLOW_PATH = new URL(".github/workflows/test.yml", REPO_ROOT_URL); +const EXPECTED_MATRIX = new Set([ + "ubuntu-latest@22", + "ubuntu-latest@24", + "macos-latest@24", + "windows-latest@24", +]); +const EXPECTED_ALLOWLIST_KEYS = new Set([ + "GHSA-96hv-2xvq-fx4p", + "GHSA-f38q-mgvj-vph7", + "GHSA-wcpc-wj8m-hjx6", + "GHSA-vmh5-mc38-953g|@earendil-works/pi-coding-agent>undici", + "GHSA-pr7r-676h-xcf6|@earendil-works/pi-coding-agent>undici", + "GHSA-38rv-x7px-6hhq|@earendil-works/pi-coding-agent>undici", + "GHSA-p88m-4jfj-68fv|@earendil-works/pi-coding-agent>undici", + "GHSA-vxpw-j846-p89q|@earendil-works/pi-coding-agent>undici", +]); + +function readText(url: URL): string { + return readFileSync(url, "utf8"); +} + +function parseAuditConfig(): AuditConfig { + const lines = readText(AUDIT_CONFIG_PATH) + .split("\n") + .filter((line) => !line.trimStart().startsWith("//")); + return JSON.parse(lines.join("\n")) as AuditConfig; +} + +function parsePackageJson(): PackageJson { + return JSON.parse(readText(PACKAGE_JSON_PATH)) as PackageJson; +} + +function parseMatrixEntries(workflow: string): Set { + const entries = workflow.matchAll(/- os: ([^\n]+)\n\s+node-version: "([^"]+)"/g); + return new Set(Array.from(entries, ([, os, node]) => `${os.trim()}@${node}`)); +} + +function stepIndex(workflow: string, step: string): number { + const index = workflow.indexOf(`- name: ${step}`); + assert.notEqual(index, -1, `missing workflow step: ${step}`); + return index; +} + +function allowlistEntries(config: AuditConfig): Array<[string, AuditRecord]> { + return config.allowlist.map((entry) => { + const [key, value] = Object.entries(entry)[0] ?? []; + assert.ok(key, "allowlist entry must define exactly one scoped advisory path"); + assert.ok(value, `missing metadata for allowlist entry: ${key}`); + return [key, value]; + }); +} + +function parseIsoDate(value: string): number { + const timestamp = Date.parse(`${value}T00:00:00Z`); + assert.notEqual(Number.isNaN(timestamp), true, `invalid ISO date: ${value}`); + return timestamp; +} + +function minimumNodeVersion(value: string): number { + const match = value.match(/^>=(?\d+)$/); + assert.ok(match?.groups?.major, `unsupported engines.node format: ${value}`); + return Number.parseInt(match.groups.major, 10); +} + +function runAuditCi(): void { + const result = spawnSync(process.execPath, [AUDIT_CLI_PATH, "--config", "audit-ci.jsonc"], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n")); +} + +test("audit-ci config keeps an expiry-tracked, path-scoped allowlist", () => { + const config = parseAuditConfig(); + assert.equal(config.$schema, AUDIT_SCHEMA); + assert.equal(config.moderate, true); + + const entries = allowlistEntries(config); + assert.deepEqual(new Set(entries.map(([key]) => key)), EXPECTED_ALLOWLIST_KEYS); + const today = Date.parse(new Date().toISOString().slice(0, 10) + "T00:00:00Z"); + for (const [key, value] of entries) { + assert.match(key, /^GHSA-[a-z0-9-]+(\|.*)?$/); + assert.equal(value.active, true); + assert.match(value.expiry, /^\d{4}-\d{2}-\d{2}$/); + assert.ok(parseIsoDate(value.expiry) >= today, `expired allowlist entry: ${key}`); + assert.notEqual(value.notes.trim(), ""); + } +}); + +test("workflow keeps the expected matrix and audit/test order", () => { + const workflow = readText(WORKFLOW_PATH); + const packageJson = parsePackageJson(); + assert.match(workflow, /fail-fast:\s+false/); + assert.deepEqual(parseMatrixEntries(workflow), EXPECTED_MATRIX); + assert.ok(stepIndex(workflow, "Security audit") < stepIndex(workflow, "Unit tests")); + assert.ok(stepIndex(workflow, "Unit tests") < stepIndex(workflow, "E2E tests")); + assert.match(workflow, /run: npx audit-ci --config audit-ci\.jsonc/); + assert.ok(EXPECTED_MATRIX.has(`ubuntu-latest@${minimumNodeVersion(packageJson.engines.node)}`)); +}); + + +test("audit-ci config matches the current lockfile vulnerabilities", () => { + runAuditCi(); +}); diff --git a/tests/unit/handoff.test.ts b/tests/unit/handoff.test.ts index 90b5084..7d586c3 100644 --- a/tests/unit/handoff.test.ts +++ b/tests/unit/handoff.test.ts @@ -20,17 +20,15 @@ test("/handoff sends the direction back through the LLM without opening the edit }); assert.deepEqual(state.pendingRequestedHandoff, { - direction: "implement auth", + readonlyBypassActive: false, + resumeReadonlyAfterHandoff: false, enforcementAttempts: 0, toolCalled: false, }); - assert.deepEqual(pi.sentUserMessages, [ - { - content: - "Handoff direction: implement auth\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge that aligns with the direction above to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.", - options: undefined, - }, - ]); + assert.equal(pi.sentUserMessages.length, 1); + assert.match(pi.sentUserMessages[0].content, /Handoff direction: implement auth/); + assert.match(pi.sentUserMessages[0].content, /You must perform a real handoff now/); + assert.equal(pi.sentUserMessages[0].options, undefined); }); test("/handoff requires a direction", async () => { @@ -53,7 +51,7 @@ test("handoff tool triggers compaction and resumes with the compacted task", asy const pi = createTestPI(); const state = createState(); state.notebookPages.set("auth-refresh", "sensitive notebook body"); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + state.pendingRequestedHandoff = { toolCalled: false, readonlyBypassActive: true, resumeReadonlyAfterHandoff: true, enforcementAttempts: 0 }; registerHandoffTool(pi as any, state); let compactOptions: any; @@ -91,7 +89,7 @@ test("handoff compaction replaces old context with the queued task", async () => const pi = createTestPI(); const state = createState(); state.pendingHandoff = { task: "Goal: continue", source: "tool" }; - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true }; + state.pendingRequestedHandoff = { enforcementAttempts: 1, toolCalled: true, readonlyBypassActive: false, resumeReadonlyAfterHandoff: false }; state.activeNotebookTopic = "oauth"; state.activeNotebookTopicSource = "human"; registerHandoffCompaction(pi as any, state); @@ -106,9 +104,10 @@ test("handoff compaction replaces old context with the queued task", async () => ); assert.equal(state.pendingHandoff, null); - assert.equal(state.pendingRequestedHandoff, null); - assert.equal(state.activeNotebookTopic, null); - assert.equal(state.activeNotebookTopicSource, null); + assert.notEqual(state.pendingRequestedHandoff, null, "pendingRequestedHandoff stays until onComplete in tool.ts"); + // Notebook topic is cleared in handoff tool's onComplete, not in compaction itself + assert.equal(state.activeNotebookTopic, "oauth"); + assert.equal(state.activeNotebookTopicSource, "human"); assert.equal(result.compaction.summary, "Goal: continue"); assert.equal(result.compaction.tokensBefore, 123); assert.equal(result.compaction.firstKeptEntryId, "leaf-1-handoff-cut"); @@ -153,7 +152,7 @@ test("handoff compaction clears the handoff status indicator", async () => { test("handoff compaction error clears pending state and status", async () => { const pi = createTestPI(); const state = createState(); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + state.pendingRequestedHandoff = { toolCalled: false, readonlyBypassActive: true, resumeReadonlyAfterHandoff: true, enforcementAttempts: 0 }; registerHandoffTool(pi as any, state); let compactOptions: any; const statuses = new Map(); @@ -176,7 +175,7 @@ test("handoff compaction error clears pending state and status", async () => { assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); }); -test("turn_end fallback clears stale requested handoff status", async () => { +test("turn_end fallback keeps requested handoff status sticky until real handoff happens", async () => { const pi = createTestPI(); registerAgenticoding(pi as any); const statuses = new Map(); @@ -201,7 +200,7 @@ test("turn_end fallback clears stale requested handoff status", async () => { getContextUsage: () => null, }); - assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined); + assert.equal(statuses.get(STATUS_KEY_HANDOFF), "🤝 Handoff in progress"); }); test("session_start new clears stale handoff status and warning widget", async () => { diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 59ae5f2..a831cbe 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -1,9 +1,13 @@ // ── Shared test helpers ────────────────────────────────────────── // Imported by other test files via `./helpers.js` -// Includes createTestPI(), test utilities, theme constants, etc. +// Includes createTestPI(), test utilities, theme constants, readonly helpers, etc. import type { Theme } from "@earendil-works/pi-coding-agent"; import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import os from "node:os"; +import registerAgenticoding from "../../index.js"; export const theme = { fg: (_name: string, text: string) => text, @@ -87,9 +91,12 @@ export function createTestPI() { const _handlers = new Map(); const _tools = new Map(); const _commands = new Map(); + const _shortcuts = new Map(); + const _flags = new Map(); const _activeTools: string[] = []; const _allToolNames: string[] = []; const _toolSources = new Map(); + const _slashCommands: any[] = []; const _sentUserMessages: Array<{ content: string; options: any }> = []; const _appendedEntries: Array<{ customType: string; data: any }> = []; @@ -143,18 +150,25 @@ export function createTestPI() { setSessionName: () => {}, getSessionName: () => undefined, exec: () => Promise.resolve({ exitCode: 0, stdout: "", stderr: "", code: 0, killed: false, signal: null } as any), - getCommands: () => [], + getCommands: () => [..._slashCommands], + setCommands: (commands: any[]) => { + _slashCommands.length = 0; + _slashCommands.push(...commands); + }, setModel: () => Promise.resolve(true), registerProvider: () => {}, - registerShortcut: () => {}, - registerFlag: () => {}, - getFlag: () => undefined, + registerShortcut: (key: string, def: any) => { _shortcuts.set(key, def); }, + registerFlag: (name: string, def: any) => { + if (!_flags.has(name)) _flags.set(name, def.default); + }, + getFlag: (name: string) => _flags.get(name), registerMessageRenderer: () => {}, setLabel: () => {}, unregisterProvider: () => {}, events: { on: () => () => {}, emit: () => {} } as import("@earendil-works/pi-coding-agent").EventBus, setEditorText: () => {}, get commands() { return _commands; }, + get shortcuts() { return _shortcuts; }, get tools() { return _tools; }, get handlers() { return _handlers; }, get activeTools() { return _activeTools; }, @@ -162,6 +176,7 @@ export function createTestPI() { _activeTools.length = 0; _activeTools.push(...tools); }, + get flags() { return _flags; }, get sentUserMessages() { return _sentUserMessages; }, get appendedEntries() { return _appendedEntries; }, get allToolNames() { return _allToolNames; }, @@ -177,6 +192,68 @@ type _TestPICoversExtensionAPI = typeof createTestPI extends () => import("@eare // eslint-disable-next-line @typescript-eslint/no-unused-vars const _testPIVerified: _TestPICoversExtensionAPI = true; +// ── Readonly test helpers ──────────────────────────────────────────── + +import type registerAgenticodingType from "../../index.js"; // type-only re-export for clients + +export type ToolCall = (event: { toolName: string; input?: Record }, ctx: { cwd?: string }) => Promise; + +/** + * Create a test PI instance with agenticoding registered and the tool_call handler extracted. + */ +export function registerReadonlyPI() { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [toolCall] = pi.handlers.get("tool_call") as ToolCall[]; + return { pi, toolCall }; +} + +/** + * Create a minimal UI context with the required shape for tool_call/auth tests. + */ +export function makeReadonlyUICtx(overrides: Record = {}) { + return { + hasUI: true, + ui: { + notify: () => {}, + theme: { fg: (_name: string, text: string) => text }, + setStatus: () => {}, + setWidget: () => {}, + }, + getContextUsage: () => null, + ...overrides, + }; +} + +// ── Temp directory helpers ────────────────────────────────────────── + +export async function tmpDir(): Promise { + return mkdtemp(join(os.tmpdir(), "pi-test-")); +} + +export async function withTempHome(run: (homeDir: string) => Promise): Promise { + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + const homeDir = await tmpDir(); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + try { + return await run(homeDir); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + await rm(homeDir, { recursive: true, force: true }); + } +} + export const EMPTY_USAGE = { input: 0, output: 0, diff --git a/tests/unit/notebook.test.ts b/tests/unit/notebook.test.ts index 521a895..520e2c1 100644 --- a/tests/unit/notebook.test.ts +++ b/tests/unit/notebook.test.ts @@ -87,6 +87,31 @@ test("notebook rehydration clears stale in-memory notebook state when persisted assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]); }); +test("notebook rehydration ignores null and malformed branch entries", async () => { + const pi = createTestPI(); + const state = createState(); + registerNotebookRehydration(pi as any, state); + const [handler] = pi.handlers.get("session_start")!; + + await handler( + {}, + { + sessionManager: { + getBranch: () => [ + null, + undefined, + "bad-string", + { type: "custom", customType: "notebook-entry", data: { epoch: 1, name: "keep", content: "valid" } }, + null, + { customType: "notebook-entry" }, + ], + }, + }, + ); + + assert.equal(state.epoch, 1); + assert.deepEqual(Array.from(state.notebookPages.entries()), [["keep", "valid"]]); +}); test("session_start rehydrates the latest persisted notebook state through the full hook chain", async () => { const pi = createTestPI(); diff --git a/tests/unit/os-sandbox.test.ts b/tests/unit/os-sandbox.test.ts new file mode 100644 index 0000000..45c22e5 --- /dev/null +++ b/tests/unit/os-sandbox.test.ts @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { execFileSync } from "node:child_process"; +import { canUseOsSandbox, wrapCommandWithOsSandbox, buildMacProfile } from "../../os-sandbox.js"; + +test("wrapped command blocks non-temp writes and allows temp writes", () => { + if (!canUseOsSandbox()) return; + + const outsidePath = path.join(process.cwd(), `.pi-readonly-outside-${Date.now()}`); + const insideDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-readonly-sandbox-")); + const insidePath = path.join(insideDir, "inside.txt"); + try { + assert.throws( + () => execFileSync("/bin/bash", ["-c", wrapCommandWithOsSandbox(`echo blocked > "${outsidePath}"`)], { encoding: "utf8", timeout: 5000 }), + /(Operation not permitted|Permission denied|readonly mode)/, + "sandbox should block writes outside temp", + ); + assert.equal(fs.existsSync(outsidePath), false, "outside temp file should not be created"); + + execFileSync("/bin/bash", ["-c", wrapCommandWithOsSandbox(`echo allowed > "${insidePath}"`)], { encoding: "utf8", timeout: 5000 }); + assert.equal(fs.readFileSync(insidePath, "utf8").trim(), "allowed", "sandbox should allow temp writes"); + } finally { + try { fs.rmSync(outsidePath, { force: true }); } catch { /* best-effort cleanup */ } + try { fs.rmSync(insideDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } +}); + +// ── buildMacProfile invariants ────────────────────────────────── + +test("buildMacProfile rejects paths containing quotes", () => { + assert.throws(() => buildMacProfile("/tmp/evil'"), /contain.*quote/); + assert.throws(() => buildMacProfile('/tmp/evil"'), /contain.*quote/); +}); + +// ── Behavioral contract: observable sandbox effects ────────────── + +test("sandbox allows writes to /dev/null", () => { + if (!canUseOsSandbox()) return; + + // CONTRACT: /dev/null redirects are always allowed (not a real write) + execFileSync("/bin/bash", ["-c", wrapCommandWithOsSandbox('echo discard > /dev/null')], { encoding: "utf8", timeout: 5000 }); +}); + +test("sandbox allows writes through a symlinked temp path", () => { + if (!canUseOsSandbox()) return; + + const realDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-readonly-real-")); + const linkDir = path.join(os.tmpdir(), `pi-readonly-link-${Date.now()}`); + try { + // Create symlink to mimic /tmp → /private/tmp on macOS + fs.symlinkSync(realDir, linkDir); + const insidePath = path.join(linkDir, "via-symlink.txt"); + + execFileSync("/bin/bash", ["-c", wrapCommandWithOsSandbox(`echo symlink-works > "${insidePath}"`)], { encoding: "utf8", timeout: 5000 }); + assert.equal(fs.readFileSync(insidePath, "utf8").trim(), "symlink-works", "sandbox should allow writes through symlinks to temp"); + } finally { + try { fs.rmSync(linkDir, { force: true }); } catch { /* best-effort cleanup */ } + try { fs.rmSync(realDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } +}); + diff --git a/tests/unit/readonly-bash-classifier.test.ts b/tests/unit/readonly-bash-classifier.test.ts new file mode 100644 index 0000000..95e4c88 --- /dev/null +++ b/tests/unit/readonly-bash-classifier.test.ts @@ -0,0 +1,321 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { registerReadonlyPI, makeReadonlyUICtx } from "./helpers.js"; +import type { ToolCall } from "./helpers.js"; + +// ── Helpers ─────────────────────────────────────────────────────── + +async function enableReadonly(pi: ReturnType) { + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); +} + +async function assertBlocked(toolCall: ToolCall, command: string, cwd = "/workspace") { + const result = await toolCall({ toolName: "bash", input: { command } }, { cwd }); + assert.equal(result.block, true, `expected block: ${command}`); +} + +async function assertAllowed(toolCall: ToolCall, command: string, cwd = "/workspace") { + assert.equal(await toolCall({ toolName: "bash", input: { command } }, { cwd }), undefined, `expected allow: ${command}`); +} + +// ── Behavioral contract tests (via tool_call hook — real code path) ── + +test("blocks bash writes outside temp dir", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outsideTemp = path.join(os.homedir(), "readonly-test-file"); + + // Representative mutation commands targeting a concrete non-temp path. + // Tests the CONTRACT: writes outside temp dir are blocked. + await assertBlocked(toolCall, `touch ${outsideTemp}`); + await assertBlocked(toolCall, `rm -f ${outsideTemp}`); + await assertBlocked(toolCall, `echo "test" > ${outsideTemp}`); +}); + +test("allows bash reads and non-mutating commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + // CONTRACT: reads are allowed (catches over-blocking). + await assertAllowed(toolCall, "ls -la"); + await assertAllowed(toolCall, "echo hello"); + await assertAllowed(toolCall, "git status"); + await assertAllowed(toolCall, "git log --oneline"); + await assertAllowed(toolCall, "git stash list"); +}); + +test("allows bash writes to temp dir", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + + // CONTRACT: temp dir writes are allowed. + await assertAllowed(toolCall, `echo ok > ${tmp}/readonly-test.txt`); + await assertAllowed(toolCall, `rm ${tmp}/readonly-test.txt`); +}); + +test("blocks command substitutions that write outside temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outsideTemp = path.join(os.homedir(), "readonly-test-file"); + + // CONTRACT: evasion via command substitution is caught. + await assertBlocked(toolCall, `touch $(echo ${outsideTemp})`); +}); + +test("blocks write redirects outside temp dir", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outsideTemp = path.join(os.homedir(), "readonly-test-file"); + + // CONTRACT: write redirects to non-temp paths are blocked. + await assertBlocked(toolCall, `echo hello > ${outsideTemp}`); +}); + +test("blocks package managers unconditionally", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + // CONTRACT: package managers blocked regardless of target path. + await assertBlocked(toolCall, "npm install lodash"); + await assertBlocked(toolCall, "pip install requests"); + await assertBlocked(toolCall, "yarn add lodash"); +}); + +test("classifies git commands correctly", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + // CONTRACT: immutable git commands allowed, mutable blocked. + await assertAllowed(toolCall, "git status"); + await assertAllowed(toolCall, "git log --oneline"); + await assertBlocked(toolCall, "git add ."); + await assertBlocked(toolCall, "git commit -m 'msg'"); +}); + +test("blocks cwd-relative downloads outside temp but allows inside temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + + // CONTRACT: cwd-relative downloads follow temp-dir rules. + await assertBlocked(toolCall, "curl -O https://example.com/file.txt", "/workspace"); + await assertAllowed(toolCall, "curl -O https://example.com/file.txt", tmp); + await assertBlocked(toolCall, "wget https://example.com/file.txt", "/workspace"); +}); + +test("allows readonly-safe git inspection subcommands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + await assertAllowed(toolCall, "git reflog"); + await assertAllowed(toolCall, "git branch -l"); + await assertAllowed(toolCall, "git config -l"); +}); + +test("allows piped read commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + + await assertAllowed(toolCall, "cat /etc/hosts | grep localhost"); + await assertAllowed(toolCall, "ls -la | head -5"); + await assertAllowed(toolCall, "git log | grep commit"); +}); + +test("blocks chained commands that write outside temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outside = path.join(os.homedir(), "readonly-test-file"); + + await assertBlocked(toolCall, `echo a && touch ${outside}`); + await assertBlocked(toolCall, `ls; rm -f ${outside}`); + await assertBlocked(toolCall, `echo ok || touch ${outside}`); +}); + +test("allows chained commands that only read or write to temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + + await assertAllowed(toolCall, "echo a && ls"); + await assertAllowed(toolCall, `echo a > ${tmp}/x && cat ${tmp}/x`); +}); + +test("blocks heredoc redirects outside temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outside = path.join(os.homedir(), "readonly-test-file"); + + await assertBlocked(toolCall, `cat <<'EOF' > ${outside}\nhello\nEOF`); +}); + +test("blocks dd of= outside temp and allows it in temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + const outside = path.join(os.homedir(), "readonly-test-file"); + + await assertBlocked(toolCall, `dd if=/dev/zero of=${outside} bs=1 count=1`); + await assertAllowed(toolCall, `dd if=/dev/zero of=${tmp}/dd-test bs=1 count=1`); +}); + +test("allows hidden-file globs inside temp dir", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const cwd = await mkdtemp(path.join(os.tmpdir(), "readonly-hidden-allow-")); + + try { + await writeFile(path.join(cwd, ".secret"), "ok"); + await assertAllowed(toolCall, "rm -f *", cwd); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("blocks hidden-file globs outside temp dir", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + // Home dir already has hidden files (.gitconfig, .ssh, etc.) and is + // outside TEMP_DIR — no filesystem writes needed for the glob to match. + await assertBlocked(toolCall, "rm -f *", os.homedir()); +}); + +// ── Untested bash pattern coverage (from review findings) ────────── + +test("blocks sudo commands that write outside temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "sudo rm /etc/passwd"); + await assertBlocked(toolCall, "sudo -u root touch /etc/test"); + await assertBlocked(toolCall, "sudo -h localhost rm /etc/test"); +}); + +test("allows sudo commands that only read", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertAllowed(toolCall, "sudo ls /etc"); + await assertAllowed(toolCall, "sudo cat /etc/hosts"); +}); + +test("blocks env -S with mutation command", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, 'env -S "rm -rf /"'); + await assertBlocked(toolCall, "env -S 'touch /etc/test'"); +}); + +test("allows env with read commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertAllowed(toolCall, "env -S 'ls /etc'"); + await assertAllowed(toolCall, "env VAR=value ls /tmp"); +}); + +test("blocks eval and exec wrappers with mutation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "eval 'rm /etc/passwd'"); + await assertBlocked(toolCall, "exec touch /etc/test"); +}); + +test("blocks interpreter inline execution that shells out to mutation commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "node -e 'rm /etc/passwd'"); + await assertBlocked(toolCall, "python -c 'touch /etc/test'"); + await assertBlocked(toolCall, "perl -e 'rm /etc/passwd'"); + await assertBlocked(toolCall, "ruby -e 'touch /etc/test'"); +}); + +test("blocks process substitution with mutation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "cat <(rm /etc/passwd)"); +}); + +test("blocks xargs with mutation command", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "echo /etc/test | xargs rm"); + // xargs npm is a known L2 bypass (documented at the top of readonly-bash.ts): + // npm alone has no verb args, so the classifier cannot detect the mutation + // at inspection time — only L1 (OS sandbox) catches this at runtime. + // await assertBlocked(toolCall, "printf 'install' | xargs npm"); +}); + +test("allows xargs with read commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertAllowed(toolCall, "echo /tmp/test | xargs ls"); +}); + +test("blocks xargs flag variants with mutation commands", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "printf '/etc/passwd\n' | xargs -I {} rm {} "); + await assertBlocked(toolCall, "printf '/etc/passwd\0' | xargs -0 rm"); + await assertBlocked(toolCall, "printf '/etc/passwd\n' | xargs -n 1 rm"); +}); + +test("blocks git branch creation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "git branch new-branch"); + await assertBlocked(toolCall, "git checkout -b new-branch"); +}); + +test("blocks git tag creation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "git tag v1.0.0"); + await assertBlocked(toolCall, "git tag -a v1.0.0 -m 'release'"); +}); + +test("blocks command substitution with nested mutation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const outside = path.join(os.homedir(), "readonly-test-file"); + await assertBlocked(toolCall, `echo $(touch ${outside})`); + await assertBlocked(toolCall, `echo $(rm /etc/passwd)`); +}); + +test("blocks wget download-dir writes outside temp and allows them in temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + await assertBlocked(toolCall, "wget -P /workspace https://example.com/file.txt"); + await assertBlocked(toolCall, "wget --directory-prefix=/workspace https://example.com/file.txt"); + await assertAllowed(toolCall, `wget -P ${tmp} https://example.com/file.txt`); +}); + +test("blocks mixed curl output modes outside temp and allows them in temp", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + const tmp = os.tmpdir(); + const tmpOutput = path.join(tmp, "out.txt"); + await assertBlocked(toolCall, `curl -o ${tmpOutput} -O https://example.com/file.txt`, "/workspace"); + await assertAllowed(toolCall, `curl -o ${tmpOutput} -O https://example.com/file.txt`, tmp); +}); + +// ── Subshell and nested-wrapper edge cases ────────────────────── + +test("blocks double-parenthesized mutation", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "((rm /etc/passwd))"); +}); + +test("allows double-parenthesized read command", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertAllowed(toolCall, "((echo hello))"); +}); + +test("blocks nested wrapper sudo+env+xargs", async () => { + const { pi, toolCall } = registerReadonlyPI(); + await enableReadonly(pi); + await assertBlocked(toolCall, "sudo env xargs rm /etc/passwd"); +}); diff --git a/tests/unit/readonly-cache.test.ts b/tests/unit/readonly-cache.test.ts new file mode 100644 index 0000000..cf1d141 --- /dev/null +++ b/tests/unit/readonly-cache.test.ts @@ -0,0 +1,665 @@ +/** + * Readonly cache tests. + * + * Exercises populateFromSkills, populatePromptCacheFromResolvedCommandsAndDirs, + * and cache lookups using real temp files with frontmatter — no mocks, same + * pattern as readonly-bash-classifier.test.ts. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpDir, withTempHome } from "./helpers.js"; +import { + cacheLookupCommand, + cacheLookupCommandIssue, + cacheLookupPrompt, + cacheLookupSkill, + cacheLookupSkillIssue, + populateFromSkills, + populatePromptCacheFromResolvedCommandsAndDirs, +} from "../../readonly-cache.js"; +import { createState } from "../../state.js"; +import type { Skill } from "@earendil-works/pi-coding-agent"; + +// ── Helpers ─────────────────────────────────────────────────────── + +async function writeMd(dir: string, name: string, frontmatter: Record): Promise { + const fm = Object.entries(frontmatter) + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) + .join("\n"); + const filePath = join(dir, `${name}.md`); + await writeFile(filePath, `---\n${fm}\n---\n\nBody content.\n`); + return filePath; +} + +function makeSkill(name: string, filePath: string): Skill { + return { + name, + description: `Test skill ${name}`, + filePath, + baseDir: "", + sourceInfo: { path: filePath, source: "test", scope: "temporary", origin: "top-level" }, + disableModelInvocation: false, + }; +} + +// ── Tests ───────────────────────────────────────────────────────── + +test("cache lookups return null for unknown names", () => { + const state = createState(); + assert.equal(cacheLookupSkill(state, "nonexistent-skill"), null); + assert.equal(cacheLookupCommand(state, "nonexistent-command"), null); +}); + +test("populateFromSkills caches a skill with readonly: true", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "my-skill", { readonly: true, description: "Test" }); + populateFromSkills(state, [makeSkill("my-skill", filePath)]); + + assert.equal(cacheLookupSkill(state, "my-skill"), true); + assert.equal(cacheLookupCommand(state, "my-skill"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills caches a skill with readonly: false", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "safe-skill", { readonly: false, description: "Test" }); + populateFromSkills(state, [makeSkill("safe-skill", filePath)]); + + assert.equal(cacheLookupSkill(state, "safe-skill"), false); + assert.equal(cacheLookupCommand(state, "safe-skill"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills returns null for skill without readonly frontmatter", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "no-readonly", { description: "Test" }); + populateFromSkills(state, [makeSkill("no-readonly", filePath)]); + + assert.equal(cacheLookupSkill(state, "no-readonly"), null); + assert.equal(cacheLookupCommand(state, "no-readonly"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills silently skips missing file", () => { + const state = createState(); + populateFromSkills(state, [makeSkill("missing", "/nonexistent/path/skill.md")]); + assert.equal(cacheLookupSkill(state, "missing"), null); + assert.equal(cacheLookupCommand(state, "missing"), null); +}); + +test("populateFromSkills caches multiple skills", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const fp1 = await writeMd(dir, "alpha", { readonly: true, description: "A" }); + const fp2 = await writeMd(dir, "beta", { readonly: false, description: "B" }); + const fp3 = await writeMd(dir, "gamma", { description: "C" }); + populateFromSkills(state, [ + makeSkill("alpha", fp1), + makeSkill("beta", fp2), + makeSkill("gamma", fp3), + ]); + + assert.equal(cacheLookupSkill(state, "alpha"), true); + assert.equal(cacheLookupSkill(state, "beta"), false); + assert.equal(cacheLookupSkill(state, "gamma"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills returns null for non-boolean readonly frontmatter", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const fp1 = await writeMd(dir, "string-ro", { readonly: "yes" }); + const fp2 = await writeMd(dir, "number-ro", { readonly: 1 }); + const fp3 = await writeMd(dir, "array-ro", { readonly: [true] }); + populateFromSkills(state, [ + makeSkill("string-ro", fp1), + makeSkill("number-ro", fp2), + makeSkill("array-ro", fp3), + ]); + + assert.equal(cacheLookupSkill(state, "string-ro"), null); + assert.equal(cacheLookupSkillIssue(state, "string-ro")?.kind, "invalid-readonly-value"); + assert.equal(cacheLookupSkill(state, "number-ro"), null); + assert.equal(cacheLookupSkillIssue(state, "number-ro")?.kind, "invalid-readonly-value"); + assert.equal(cacheLookupSkill(state, "array-ro"), null); + assert.equal(cacheLookupSkillIssue(state, "array-ro")?.kind, "invalid-readonly-value"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("cacheLookupCommand falls back to prompts when no skill is loaded", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "prompt-only", { readonly: true }); + + populateFromSkills(state, []); + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + + assert.equal(cacheLookupSkill(state, "prompt-only"), null); + assert.equal(cacheLookupCommand(state, "prompt-only"), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/name prompt lookup stays distinct from /skill:name lookup for the same name", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const skillPath = await writeMd(dir, "shared-name", { readonly: false }); + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "shared-name", { readonly: true }); + + populateFromSkills(state, [makeSkill("shared-name", skillPath)]); + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + + assert.equal(cacheLookupSkill(state, "shared-name"), false); + assert.equal(cacheLookupPrompt(state, "shared-name"), true); + assert.equal(cacheLookupCommand(state, "shared-name"), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs caches prompt commands", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "command-prompt", { readonly: false }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "command-prompt", + source: "prompt", + description: "Command prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary", origin: "top-level" }, + }], dir, false); + + assert.equal(cacheLookupCommand(state, "command-prompt"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs caches resolved prompt template file paths", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "resolved-prompt", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "resolved-prompt", + source: "prompt", + description: "Resolved prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary", origin: "top-level" }, + }], dir, false); + + assert.equal(cacheLookupPrompt(state, "resolved-prompt"), true); + assert.equal(cacheLookupCommand(state, "resolved-prompt"), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs caches .md files from project dir", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "proj-prompt", { readonly: false }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + + assert.equal(cacheLookupCommand(state, "proj-prompt"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs skips non-.md files", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, "readme.txt"), "not markdown"); + await writeMd(projectDir, "real-prompt", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + + assert.equal(cacheLookupCommand(state, "real-prompt"), true); + assert.equal(cacheLookupCommand(state, "readme"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs skips nonexistent dir silently", () => { + const state = createState(); + populatePromptCacheFromResolvedCommandsAndDirs(state, [], "/nonexistent/workspace", true); + assert.equal(cacheLookupCommand(state, "anything"), null); +}); + +test("project prompt overrides global prompt for the same name", async () => { + await withTempHome(async (homeDir) => { + const state = createState(); + const workspace = await tmpDir(); + try { + const globalDir = join(homeDir, ".pi", "agent", "prompts"); + const projectDir = join(workspace, ".pi", "prompts"); + await mkdir(globalDir, { recursive: true }); + await mkdir(projectDir, { recursive: true }); + await writeMd(globalDir, "shared", { readonly: false }); + await writeMd(projectDir, "shared", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], workspace, true); + assert.equal(cacheLookupCommand(state, "shared"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); +}); + + +test("resolved prompt command stays authoritative over directory fallback", async () => { + await withTempHome(async (homeDir) => { + const state = createState(); + const workspace = await tmpDir(); + try { + const globalDir = join(homeDir, ".pi", "agent", "prompts"); + const projectDir = join(workspace, ".pi", "prompts"); + await mkdir(globalDir, { recursive: true }); + await mkdir(projectDir, { recursive: true }); + const resolvedPath = await writeMd(workspace, "shared-resolved", { readonly: false }); + await writeMd(globalDir, "shared-resolved", { readonly: true }); + await writeMd(projectDir, "shared-resolved", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "shared-resolved", + source: "prompt", + description: "Resolved command", + sourceInfo: { path: resolvedPath, source: "test", scope: "temporary", origin: "top-level" }, + }], workspace, true); + assert.equal(cacheLookupCommand(state, "shared-resolved"), false); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); +}); + +test("resolved non-prompt command blocks prompt-dir fallback for the same name", async () => { + const state = createState(); + const workspace = await tmpDir(); + try { + const projectDir = join(workspace, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + const promptPath = await writeMd(projectDir, "shared-owned", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "shared-owned", + source: "builtin" as any, + description: "Builtin command", + sourceInfo: { path: promptPath, source: "test", scope: "temporary", origin: "top-level" }, + }], workspace, true); + assert.equal(cacheLookupCommand(state, "shared-owned"), null); + assert.equal(cacheLookupCommandIssue(state, "shared-owned"), null); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("resolved prompt command rebinding to a different file refreshes the cache", async () => { + const state = createState(); + const workspace = await tmpDir(); + try { + const pathA = await writeMd(workspace, "shared-a", { readonly: true }); + const pathB = await writeMd(workspace, "shared-b", { readonly: false }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "shared-rebound", + source: "prompt", + description: "Resolved command A", + sourceInfo: { path: pathA, source: "test", scope: "temporary", origin: "top-level" }, + }], workspace, false); + assert.equal(cacheLookupCommand(state, "shared-rebound"), true); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [{ + name: "shared-rebound", + source: "prompt", + description: "Resolved command B", + sourceInfo: { path: pathB, source: "test", scope: "temporary", origin: "top-level" }, + }], workspace, false); + assert.equal(cacheLookupCommand(state, "shared-rebound"), false); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs does not scan project dir when projectTrusted is false", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "proj-only", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, false); + + assert.equal(cacheLookupCommand(state, "proj-only"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs evicts deleted prompt entries on rebuild", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + const filePath = await writeMd(projectDir, "deleted-prompt", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + assert.equal(cacheLookupCommand(state, "deleted-prompt"), true); + + await rm(filePath, { force: true }); + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + assert.equal(cacheLookupCommand(state, "deleted-prompt"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs records invalid readonly value issues", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "broken-prompt", { readonly: "yes" }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + assert.equal(cacheLookupCommand(state, "broken-prompt"), null); + assert.equal(cacheLookupCommandIssue(state, "broken-prompt")?.kind, "invalid-readonly-value"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs records unreadable prompt issues", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await mkdir(join(projectDir, "dir-prompt.md"), { recursive: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + assert.equal(cacheLookupCommand(state, "dir-prompt"), null); + assert.equal(cacheLookupCommandIssue(state, "dir-prompt")?.kind, "unreadable-file"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs evicts untrusted project prompt entries on rebuild", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const projectDir = join(dir, ".pi", "prompts"); + await mkdir(projectDir, { recursive: true }); + await writeMd(projectDir, "trusted-only", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, true); + assert.equal(cacheLookupCommand(state, "trusted-only"), true); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], dir, false); + assert.equal(cacheLookupCommand(state, "trusted-only"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("project/global precedence rebinding refreshes the cache for the same prompt name", async () => { + await withTempHome(async (homeDir) => { + const state = createState(); + const workspace = await tmpDir(); + try { + const globalDir = join(homeDir, ".pi", "agent", "prompts"); + const projectDir = join(workspace, ".pi", "prompts"); + await mkdir(globalDir, { recursive: true }); + await mkdir(projectDir, { recursive: true }); + await writeMd(globalDir, "shared-priority", { readonly: false }); + await writeMd(projectDir, "shared-priority", { readonly: true }); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], workspace, true); + assert.equal(cacheLookupCommand(state, "shared-priority"), true); + + populatePromptCacheFromResolvedCommandsAndDirs(state, [], workspace, false); + assert.equal(cacheLookupCommand(state, "shared-priority"), false); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); +}); + +test("populateFromSkills reuses the cached entry while mtime is unchanged", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "stable-skill", { readonly: true }); + populateFromSkills(state, [makeSkill("stable-skill", filePath)]); + const firstEntry = state.readonlySkillCache.get("stable-skill"); + assert.equal(firstEntry?.readonly, true); + + populateFromSkills(state, [makeSkill("stable-skill", filePath)]); + const secondEntry = state.readonlySkillCache.get("stable-skill"); + assert.equal(secondEntry, firstEntry); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills records malformed frontmatter issues", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-skill.md"); + await writeFile(filePath, `---\nreadonly: [\n---\n\nBody content.\n`); + populateFromSkills(state, [makeSkill("broken-skill", filePath)]); + + assert.equal(cacheLookupSkill(state, "broken-skill"), null); + assert.equal(cacheLookupSkillIssue(state, "broken-skill")?.kind, "malformed-frontmatter"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills refreshes changed frontmatter when mtime changes", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "mutable-skill", { readonly: true }); + populateFromSkills(state, [makeSkill("mutable-skill", filePath)]); + assert.equal(cacheLookupSkill(state, "mutable-skill"), true); + + await writeFile(filePath, `---\nreadonly: false\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populateFromSkills(state, [makeSkill("mutable-skill", filePath)]); + assert.equal(cacheLookupSkill(state, "mutable-skill"), false); + assert.equal(cacheLookupCommand(state, "mutable-skill"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills clears an invalid issue after the skill is fixed", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "recover-skill", { readonly: "yes" }); + populateFromSkills(state, [makeSkill("recover-skill", filePath)]); + assert.equal(cacheLookupSkillIssue(state, "recover-skill")?.kind, "invalid-readonly-value"); + + await writeFile(filePath, `---\nreadonly: true\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populateFromSkills(state, [makeSkill("recover-skill", filePath)]); + assert.equal(cacheLookupSkill(state, "recover-skill"), true); + assert.equal(cacheLookupSkillIssue(state, "recover-skill"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populateFromSkills clears an unreadable issue after the skill becomes readable", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = join(dir, "recover-readable-skill.md"); + await mkdir(filePath, { recursive: true }); + populateFromSkills(state, [makeSkill("recover-readable-skill", filePath)]); + assert.equal(cacheLookupSkillIssue(state, "recover-readable-skill")?.kind, "unreadable-file"); + + await rm(filePath, { recursive: true, force: true }); + await writeFile(filePath, `---\nreadonly: false\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populateFromSkills(state, [makeSkill("recover-readable-skill", filePath)]); + assert.equal(cacheLookupSkill(state, "recover-readable-skill"), false); + assert.equal(cacheLookupSkillIssue(state, "recover-readable-skill"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs refreshes a prompt when the same path changes", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "mutable-prompt", { readonly: true }); + const commands = [{ + name: "mutable-prompt", + source: "prompt" as const, + description: "Mutable prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + }]; + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommand(state, "mutable-prompt"), true); + + await writeFile(filePath, `---\nreadonly: false\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommand(state, "mutable-prompt"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs clears an invalid issue after the prompt is fixed", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = await writeMd(dir, "recover-prompt", { readonly: "yes" }); + const commands = [{ + name: "recover-prompt", + source: "prompt" as const, + description: "Recover prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + }]; + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommandIssue(state, "recover-prompt")?.kind, "invalid-readonly-value"); + + await writeFile(filePath, `---\nreadonly: true\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommand(state, "recover-prompt"), true); + assert.equal(cacheLookupCommandIssue(state, "recover-prompt"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs records malformed frontmatter issues", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-yaml.md"); + await writeFile(filePath, `---\nreadonly: [\n---\n\nBody content.\n`); + const commands = [{ + name: "broken-yaml", + source: "prompt" as const, + description: "Broken yaml prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + }]; + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommand(state, "broken-yaml"), null); + assert.equal(cacheLookupCommandIssue(state, "broken-yaml")?.kind, "malformed-frontmatter"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("populatePromptCacheFromResolvedCommandsAndDirs clears an unreadable issue after the prompt becomes readable", async () => { + const state = createState(); + const dir = await tmpDir(); + try { + const filePath = join(dir, "recover-readable.md"); + await mkdir(filePath, { recursive: true }); + const commands = [{ + name: "recover-readable", + source: "prompt" as const, + description: "Recover readable prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + }]; + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommandIssue(state, "recover-readable")?.kind, "unreadable-file"); + + await rm(filePath, { recursive: true, force: true }); + await writeFile(filePath, `---\nreadonly: false\n---\n\nBody content.\n`); + const future = new Date(Date.now() + 2_000); + await utimes(filePath, future, future); + + populatePromptCacheFromResolvedCommandsAndDirs(state, commands, dir, false); + assert.equal(cacheLookupCommand(state, "recover-readable"), false); + assert.equal(cacheLookupCommandIssue(state, "recover-readable"), null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/readonly-frontmatter.test.ts b/tests/unit/readonly-frontmatter.test.ts new file mode 100644 index 0000000..0c1c01b --- /dev/null +++ b/tests/unit/readonly-frontmatter.test.ts @@ -0,0 +1,890 @@ +/** + * Readonly frontmatter integration tests. + * + * Exercises the full pipeline: + * input queue → before_agent_start → consumePendingReadonlyToggle + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { registerReadonlyPI, makeReadonlyUICtx, tmpDir, withTempHome } from "./helpers.js"; + +async function writePrompt(dir: string, name: string, readonly: boolean): Promise { + const filePath = join(dir, `${name}.md`); + await writeFile(filePath, `---\nreadonly: ${readonly}\ndescription: "Test"\n---\n\nBody content.\n`); + return filePath; +} + +function makeBeforeStartCtx(cwd = process.cwd()) { + return { + ...makeReadonlyUICtx(), + cwd, + isProjectTrusted: () => true, + }; +} + +function makeNotifyBeforeStartCtx() { + const notifications: Array<{ message: string; level: string }> = []; + return { + ctx: { + ...makeReadonlyUICtx({ + ui: { + notify: (message: string, level: string) => { notifications.push({ message, level }); }, + theme: { fg: (_name: string, text: string) => text }, + setStatus: () => {}, + setWidget: () => {}, + }, + }), + cwd: process.cwd(), + isProjectTrusted: () => true, + }, + notifications, + }; +} + +function makeResolvedCommand(name: string, filePath: string, source: "prompt" | "builtin" = "prompt") { + return { + name, + source, + description: "Test prompt", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + }; +} + +function makePromptCommand(name: string, filePath: string) { + return makeResolvedCommand(name, filePath, "prompt"); +} + +function makeSkill(name: string, filePath: string) { + return { + name, + description: "Test skill", + filePath, + baseDir: "", + sourceInfo: { path: filePath, source: "test", scope: "temporary" as const, origin: "top-level" as const }, + disableModelInvocation: false, + }; +} + +async function runPromptToggle( + text: string, + readonly: boolean, + name = text.slice(1), +): Promise<{ toolCall: ReturnType["toolCall"]; pi: ReturnType["pi"] }> { + const dir = await tmpDir(); + const filePath = await writePrompt(dir, name, readonly); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand(name, filePath)]); + await inputHandler({ text, source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + await rm(dir, { recursive: true, force: true }); + return { pi, toolCall }; +} + +test("single /name input activates readonly frontmatter", async () => { + const { toolCall } = await runPromptToggle("/my-prompt", true); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); +}); + +test("readonly: false frontmatter keeps readonly disabled and stays silent when already disabled", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "safe-prompt", false); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const [contextHook] = pi.handlers.get("context")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("safe-prompt", filePath)]); + + await inputHandler({ text: "/safe-prompt", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(notifications.length, 0); + const contextResult = await contextHook({ messages: [] }, { getContextUsage: () => ({ percent: 40 }) }); + assert.equal(contextResult.messages.find((message: any) => /readonly/i.test(message.content ?? "")), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("suffixed /name command activates readonly frontmatter", async () => { + const { toolCall } = await runPromptToggle("/review:1", true, "review:1"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); +}); + +test("dotted /name command activates readonly frontmatter", async () => { + const { toolCall } = await runPromptToggle("/review.pr", true, "review.pr"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); +}); + +test("unknown /command without frontmatter produces no toggle", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + + await inputHandler({ text: "/nonexistent-cmd", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); +}); + +test("unknown /command does not delay the next valid prompt frontmatter toggle", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "review", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("review", filePath)]); + + await inputHandler({ text: "/nonexistent-cmd", source: "interactive" }, ctx); + await inputHandler({ text: "/review", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + const entries = pi.appendedEntries.filter((entry: any) => entry.customType === "agenticoding-readonly"); + assert.equal(entries.length, 1); + assert.equal(entries[0]?.data.enabled, true); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("before_agent_start skips readonly cache population while no slash-command toggle is pending", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken.md"); + await writeFile(filePath, `---\nreadonly: "yes"\n---\n\nBody content.\n`); + const { pi } = registerReadonlyPI(); + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("broken", filePath)]); + + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + + +test("input handler queues a prompt command even when the registry is unavailable until before_agent_start", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "late-review", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + + await inputHandler({ text: "/late-review", source: "interactive" }, ctx); + pi.setCommands([makePromptCommand("late-review", filePath)]); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("late-resolved non-prompt /name does not inherit readonly from a same-named prompt file", async () => { + const workspace = await tmpDir(); + try { + const promptDir = join(workspace, ".pi", "prompts"); + await mkdir(promptDir, { recursive: true }); + const promptPath = await writePrompt(promptDir, "shared", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(workspace); + + await inputHandler({ text: "/shared", source: "interactive" }, ctx); + pi.setCommands([makeResolvedCommand("shared", promptPath, "builtin")]); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("known non-prompt /name already present in the registry does not enqueue a deferred toggle", async () => { + const workspace = await tmpDir(); + try { + const promptDir = join(workspace, ".pi", "prompts"); + await mkdir(promptDir, { recursive: true }); + const promptPath = await writePrompt(promptDir, "shared-known", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(workspace); + + pi.setCommands([makeResolvedCommand("shared-known", promptPath, "builtin")]); + await inputHandler({ text: "/shared-known", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("headless /name frontmatter stays a no-op through the deferred pipeline", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "headless-review", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + pi.setCommands([makePromptCommand("headless-review", filePath)]); + + await inputHandler({ text: "/headless-review", source: "interactive" }, { + hasUI: false, + getContextUsage: () => null, + } as any); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, { + hasUI: false, + cwd: process.cwd(), + isProjectTrusted: () => false, + getContextUsage: () => null, + } as any); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("unresolved /name uses trusted cwd/.pi/prompts frontmatter via deferred fallback", async () => { + const workspace = await tmpDir(); + try { + const promptDir = join(workspace, ".pi", "prompts"); + await mkdir(promptDir, { recursive: true }); + await writePrompt(promptDir, "fallback-only", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(workspace); + + await inputHandler({ text: "/fallback-only", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly")?.data.enabled, true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + + +test("unresolved /name uses ~/.pi/agent/prompts frontmatter via deferred fallback", async () => { + await withTempHome(async (homeDir) => { + const workspace = await tmpDir(); + try { + const promptDir = join(homeDir, ".pi", "agent", "prompts"); + await mkdir(promptDir, { recursive: true }); + await writePrompt(promptDir, "global-fallback", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(workspace); + + await inputHandler({ text: "/global-fallback", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly")?.data.enabled, true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); +}); + +test("queued slash + extension message preserves the first pending command", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "my-prompt", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("my-prompt", filePath)]); + + await inputHandler({ text: "/my-prompt", source: "interactive" }, ctx); + await inputHandler({ text: "Proceed.", source: "extension" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("queued slash + plain text preserves the first pending command", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "my-prompt", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("my-prompt", filePath)]); + + await inputHandler({ text: "/my-prompt", source: "interactive", streamingBehavior: "steer" }, ctx); + await inputHandler({ text: "also fix this", source: "interactive", streamingBehavior: "steer" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("queued slash commands are consumed FIFO across before_agent_start calls", async () => { + const dir = await tmpDir(); + try { + const filePathA = await writePrompt(dir, "cmd-a", true); + const filePathB = await writePrompt(dir, "cmd-b", false); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([ + makePromptCommand("cmd-a", filePathA), + makePromptCommand("cmd-b", filePathB), + ]); + + await inputHandler({ text: "/cmd-a", source: "interactive" }, ctx); + await inputHandler({ text: "/cmd-b", source: "interactive" }, ctx); + + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + assert.equal(pi.appendedEntries.at(-1)?.data.enabled, true, "first before_agent_start should consume /cmd-a"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + assert.equal(pi.appendedEntries.at(-1)?.data.enabled, false, "second before_agent_start should consume /cmd-b"); + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("non-prompt slash commands do not delay the next prompt frontmatter toggle", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "review", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("review", filePath)]); + + await inputHandler({ text: "/notebook", source: "interactive" }, ctx); + await inputHandler({ text: "/review", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + const entries = pi.appendedEntries.filter((entry: any) => entry.customType === "agenticoding-readonly"); + assert.equal(entries.length, 1); + assert.equal(entries[0]?.data.enabled, true); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/skill:name activates readonly from skill frontmatter", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "my-skill", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + + await inputHandler({ text: "/skill:my-skill", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("my-skill", filePath)] }, + }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/skill:name preserves dotted skill names for readonly frontmatter", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "review.pr", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + + await inputHandler({ text: "/skill:review.pr", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("review.pr", filePath)] }, + }, ctx); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("readonly success notifications use the exact slash-command source", async () => { + const dir = await tmpDir(); + try { + const promptPath = await writePrompt(dir, "shared", true); + const skillPath = await writePrompt(dir, "shared-skill", false); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("shared", promptPath)]); + + await inputHandler({ text: "/shared", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [makeSkill("shared", skillPath)] } }, ctx); + assert.match(notifications.at(-1)?.message ?? "", /\/shared/); + assert.doesNotMatch(notifications.at(-1)?.message ?? "", /\/skill:shared/); + + await inputHandler({ text: "/skill:shared", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [makeSkill("shared", skillPath)] } }, ctx); + assert.match(notifications.at(-1)?.message ?? "", /\/skill:shared/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("invalid /prompt readonly value records a warning for the prompt source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-prompt.md"); + await writeFile(filePath, `---\nreadonly: "yes"\ndescription: "Broken"\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("broken-prompt", filePath)]); + + await inputHandler({ text: "/broken-prompt", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.data.type, "command"); + assert.match(notifications.at(-1)?.message ?? "", /\/broken-prompt/); + assert.match(notifications.at(-1)?.message ?? "", /`readonly` frontmatter must be `true` or `false`/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("invalid /skill:name readonly value records a warning for the skill source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-skill.md"); + await writeFile(filePath, `---\nreadonly: "yes"\ndescription: "Broken"\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + + await inputHandler({ text: "/skill:broken-skill", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("broken-skill", filePath)] }, + }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.data.type, "skill"); + assert.match(notifications.at(-1)?.message ?? "", /\/skill:broken-skill/); + assert.match(notifications.at(-1)?.message ?? "", /`readonly` frontmatter must be `true` or `false`/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("unreadable /prompt frontmatter records a warning for the prompt source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "dir-prompt.md"); + await mkdir(filePath, { recursive: true }); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("dir-prompt", filePath)]); + + await inputHandler({ text: "/dir-prompt", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.data.type, "command"); + assert.match(notifications.at(-1)?.message ?? "", /\/dir-prompt/); + assert.match(notifications.at(-1)?.message ?? "", /prompt\/skill file could not be read/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("unreadable /skill:name frontmatter records a warning for the skill source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "dir-skill.md"); + await mkdir(filePath, { recursive: true }); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + + await inputHandler({ text: "/skill:dir-skill", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("dir-skill", filePath)] }, + }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.data.type, "skill"); + assert.match(notifications.at(-1)?.message ?? "", /\/skill:dir-skill/); + assert.match(notifications.at(-1)?.message ?? "", /prompt\/skill file could not be read/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("invalid queued frontmatter warns and the next valid queued command still toggles readonly", async () => { + const dir = await tmpDir(); + try { + const brokenPath = join(dir, "broken-then-valid.md"); + await writeFile(brokenPath, `---\nreadonly: "yes"\n---\n\nBody content.\n`); + const validPath = await writePrompt(dir, "valid-after-broken", true); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([ + makePromptCommand("broken-then-valid", brokenPath), + makePromptCommand("valid-after-broken", validPath), + ]); + + await inputHandler({ text: "/broken-then-valid", source: "interactive" }, ctx); + await inputHandler({ text: "/valid-after-broken", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(pi.appendedEntries.at(-2)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly"); + assert.equal(pi.appendedEntries.at(-1)?.data.enabled, true); + assert.match(notifications.at(-2)?.message ?? "", /`readonly` frontmatter must be `true` or `false`/); + assert.match(notifications.at(-1)?.message ?? "", /Readonly mode enabled/); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("prompt without readonly frontmatter stays a silent no-op through the deferred pipeline", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "no-readonly.md"); + await writeFile(filePath, `---\ndescription: "No readonly"\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("no-readonly", filePath)]); + + await inputHandler({ text: "/no-readonly", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + assert.equal(notifications.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("skill without readonly frontmatter stays a silent no-op through the deferred pipeline", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "no-readonly-skill.md"); + await writeFile(filePath, `---\ndescription: "No readonly"\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + + await inputHandler({ text: "/skill:no-readonly-skill", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("no-readonly-skill", filePath)] }, + }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + assert.equal(notifications.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/readonly bypasses deferred frontmatter lookup", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "readonly.md"); + await writeFile(filePath, `---\nreadonly: "yes"\n---\n\nBody content.\n`); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("readonly", filePath)]); + + await inputHandler({ text: "/readonly", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + assert.equal(notifications.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/handoff bypasses deferred frontmatter lookup", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "handoff.md"); + await writeFile(filePath, `---\nreadonly: "yes"\n---\n\nBody content.\n`); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("handoff", filePath)]); + + await inputHandler({ text: "/handoff continue", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + assert.equal(notifications.length, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("/notebook bypasses deferred frontmatter lookup", async () => { + const workspace = await tmpDir(); + try { + const promptDir = join(workspace, ".pi", "prompts"); + await mkdir(promptDir, { recursive: true }); + await writeFile(join(promptDir, "notebook.md"), `---\nreadonly: "yes"\n---\n\nBody content.\n`); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + + await inputHandler({ text: "/notebook", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, { + ...ctx, + cwd: workspace, + isProjectTrusted: () => true, + }); + + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly"), undefined); + assert.equal(pi.appendedEntries.find((entry: any) => entry.customType === "agenticoding-readonly-frontmatter-issue"), undefined); + assert.equal(notifications.length, 0); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("malformed /prompt frontmatter records a parse warning for the prompt source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-yaml-prompt.md"); + await writeFile(filePath, `---\nreadonly: [\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + pi.setCommands([makePromptCommand("broken-yaml-prompt", filePath)]); + + await inputHandler({ text: "/broken-yaml-prompt", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.match(notifications.at(-1)?.message ?? "", /\/broken-yaml-prompt/); + assert.match(notifications.at(-1)?.message ?? "", /frontmatter could not be parsed/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("malformed /skill:name frontmatter records a parse warning for the skill source", async () => { + const dir = await tmpDir(); + try { + const filePath = join(dir, "broken-yaml-skill.md"); + await writeFile(filePath, `---\nreadonly: [\n---\n\nBody content.\n`); + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const { ctx, notifications } = makeNotifyBeforeStartCtx(); + + await inputHandler({ text: "/skill:broken-yaml-skill", source: "interactive" }, ctx); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [makeSkill("broken-yaml-skill", filePath)] }, + }, ctx); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly-frontmatter-issue"); + assert.equal(pi.appendedEntries.at(-1)?.data.type, "skill"); + assert.match(notifications.at(-1)?.message ?? "", /\/skill:broken-yaml-skill/); + assert.match(notifications.at(-1)?.message ?? "", /frontmatter could not be parsed/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("deferred readonly enable emits a one-shot context nudge", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "nudge-on", true); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const [contextHook] = pi.handlers.get("context")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("nudge-on", filePath)]); + + await inputHandler({ text: "/nudge-on", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + const firstResult = await contextHook({ messages: [] }, { getContextUsage: () => ({ percent: 20 }) }); + assert.equal(firstResult.messages.filter((message: any) => message.customType === "agenticoding-readonly-nudge").length, 1); + assert.match(firstResult.messages.find((message: any) => message.customType === "agenticoding-readonly-nudge")?.content ?? "", /Readonly mode is active/); + + const secondResult = await contextHook({ messages: [] }, { getContextUsage: () => ({ percent: 20 }) }); + assert.equal(secondResult?.messages?.find((message: any) => message.customType === "agenticoding-readonly-nudge"), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("deferred readonly disable emits a one-shot context nudge", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "nudge-off", false); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const [contextHook] = pi.handlers.get("context")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("nudge-off", filePath)]); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + + await inputHandler({ text: "/nudge-off", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + const firstResult = await contextHook({ messages: [] }, { getContextUsage: () => ({ percent: 40 }) }); + assert.equal(firstResult.messages.filter((message: any) => message.customType === "agenticoding-readonly-nudge").length, 1); + assert.match(firstResult.messages.find((message: any) => message.customType === "agenticoding-readonly-nudge")?.content ?? "", /Readonly mode has been turned off/); + + const secondResult = await contextHook({ messages: [] }, { getContextUsage: () => ({ percent: 40 }) }); + assert.equal(secondResult?.messages?.filter((message: any) => message.customType === "agenticoding-readonly-nudge").length ?? 0, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("one readonly entry is appended per consumed queued toggle", async () => { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, "prompt-a", true); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const ctx = makeBeforeStartCtx(); + pi.setCommands([makePromptCommand("prompt-a", filePath)]); + + await inputHandler({ text: "/prompt-a", source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + const entries = pi.appendedEntries.filter((entry: any) => entry.customType === "agenticoding-readonly"); + assert.equal(entries.length, 1); + assert.equal(entries[0].data.enabled, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function assertHandoffAlignment(name: string, readonly: boolean, task: string): Promise { + const dir = await tmpDir(); + try { + const filePath = await writePrompt(dir, name, readonly); + const { pi } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + const [beforeCompactHandler] = pi.handlers.get("session_before_compact")!; + const ctx = makeBeforeStartCtx(); + + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler(task, { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + pi.setCommands([makePromptCommand(name, filePath)]); + await inputHandler({ text: `/${name}`, source: "interactive" }, ctx); + await beforeStartHandler({ systemPrompt: "", systemPromptOptions: { skills: [] } }, ctx); + + assert.equal(pi.appendedEntries.at(-1)?.customType, "agenticoding-readonly"); + assert.equal(pi.appendedEntries.at(-1)?.data.enabled, readonly); + + await pi.tools.get("handoff").execute( + "1", + { task }, + undefined, + undefined, + { compact: () => {} }, + ); + const compaction = await beforeCompactHandler( + { preparation: { tokensBefore: 1 }, branchEntries: [{ id: "leaf-1" }] }, + {}, + ); + const summary = compaction.compaction.summary; + assert.equal(summary.includes("Fresh context resumes in readonly mode."), readonly); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("frontmatter toggle aligns pending handoff with readonly: true", async () => { + await assertHandoffAlignment("review-prompt", true, "continue review"); +}); + +test("frontmatter toggle aligns pending handoff with readonly: false", async () => { + await assertHandoffAlignment("safe-prompt", false, "continue work"); +}); diff --git a/tests/unit/readonly-handoff.test.ts b/tests/unit/readonly-handoff.test.ts new file mode 100644 index 0000000..dfe3354 --- /dev/null +++ b/tests/unit/readonly-handoff.test.ts @@ -0,0 +1,165 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import registerAgenticoding from "../../index.js"; +import { createTestPI, makeReadonlyUICtx } from "./helpers.js"; + +function createHandoffPI() { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [toolCall] = pi.handlers.get("tool_call")!; + const [beforeCompact] = pi.handlers.get("session_before_compact")!; + const sessionStartHandlers = pi.handlers.get("session_start") ?? []; + const sessionStart = async (event: unknown, ctx: unknown) => { + for (const handler of sessionStartHandlers) { + await handler(event, ctx); + } + }; + return { pi, toolCall, beforeCompact, sessionStart }; +} + +function makeReadonlyResumeCtx(branch: unknown[]) { + return { + hasUI: false, + getContextUsage: () => null, + sessionManager: { + getBranch: () => branch, + }, + }; +} + +test("/handoff command creates temporary bypass for handoff tool only", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + assert.equal(await toolCall({ toolName: "handoff", input: { task: "continue readonly work" } }, {}), undefined, + "handoff should be unblocked after explicit /handoff"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/test", content: "x" } }, {})).block, true, + "write should stay blocked"); +}); + +test("after handoff compaction, bypass is cleared and readonly persists", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + // Execute handoff tool and capture the compact callback + let compactOptions: any; + await pi.tools.get("handoff").execute( + "handoff-1", + { task: "Continue readonly work" }, + undefined, + undefined, + { + hasUI: true, + ui: { setStatus: () => {} }, + compact: (options: any) => { compactOptions = options; }, + }, + ); + + // Trigger onComplete (simulates successful compaction) + compactOptions.onComplete({}); + + // Observable contract: bypass cleared, readonly still active + assert.equal((await toolCall({ toolName: "handoff", input: { task: "direct call" } }, {})).block, true, + "bypass cleared: direct handoff should be blocked"); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/test", content: "x" } }, {})).block, true, + "readonly persists: write should still be blocked after compaction"); +}); + +test("retry succeeds after a failed compaction attempt", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + // Execute handoff tool and capture the compact callback + let compactOptions: any; + await pi.tools.get("handoff").execute( + "handoff-1", + { task: "Continue readonly work" }, + undefined, + undefined, + { + hasUI: true, + ui: { setStatus: () => {} }, + compact: (options: any) => { compactOptions = options; }, + }, + ); + + // Trigger onError — simulates failed compaction + compactOptions.onError(); + + // Observable contract: a retry handoff call succeeds after failed compaction + await assert.doesNotReject( + () => pi.tools.get("handoff").execute( + "handoff-retry", + { task: "retry handoff" }, + undefined, + undefined, + { + hasUI: true, + ui: { setStatus: () => {} }, + compact: () => {}, + }, + ), + "retry handoff should succeed after failed compaction", + ); + + // Readonly still active + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/test", content: "x" } }, {})).block, true, + "readonly persists: write should still be blocked after failed compaction"); +}); + +test("/handoff re-enables bypass after compaction", async () => { + const { pi, toolCall } = createHandoffPI(); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + + // Create bypass, then complete the handoff to clear it + await pi.commands.get("handoff").handler("first handoff", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + let compactOptions: any; + await pi.tools.get("handoff").execute( + "handoff-1", + { task: "first handoff" }, + undefined, + undefined, + { + hasUI: true, + ui: { setStatus: () => {} }, + compact: (options: any) => { compactOptions = options; }, + }, + ); + compactOptions.onComplete({}); + + // Second /handoff re-enables the bypass + await pi.commands.get("handoff").handler("second readonly handoff", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + assert.equal(await toolCall({ toolName: "handoff", input: { task: "second readonly handoff" } }, {}), undefined, + "second /handoff should re-enable the bypass"); +}); + +test("session resume restores readonly enforcement from persisted state", async () => { + const { toolCall, sessionStart } = createHandoffPI(); + const branch = [ + { type: "custom", customType: "agenticoding-readonly", data: { enabled: false } }, + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + ]; + + await sessionStart({ reason: "resume" }, makeReadonlyResumeCtx(branch) as any); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + assert.equal(await toolCall({ toolName: "read", input: { path: "/tmp/x" } }, {}), undefined); +}); diff --git a/tests/unit/readonly-mode.test.ts b/tests/unit/readonly-mode.test.ts new file mode 100644 index 0000000..cf1687f --- /dev/null +++ b/tests/unit/readonly-mode.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import { registerReadonlyPI, makeReadonlyUICtx } from "./helpers.js"; + +test("readonly toggle on blocks write, edit, handoff, and bash mutations", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const ctx = makeReadonlyUICtx(); + + await pi.commands.get("readonly").handler("", ctx as any); + + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); + assert.equal((await toolCall({ toolName: "edit", input: { path: "/tmp/x", edits: [] } }, {})).block, true); + assert.equal((await toolCall({ toolName: "handoff", input: { task: "pivot" } }, {})).block, true); + assert.equal((await toolCall({ toolName: "bash", input: { command: "rm -rf /" } }, { cwd: "/workspace" })).block, true); + assert.equal(await toolCall({ toolName: "bash", input: { command: `rm ${os.tmpdir()}/x` } }, { cwd: "/workspace" }), undefined); + assert.equal(await toolCall({ toolName: "read", input: { path: "/tmp/x" } }, {}), undefined); +}); + +test("readonly toggle off restores write, handoff, and bash access", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const ctx = makeReadonlyUICtx(); + + await pi.commands.get("readonly").handler("", ctx as any); + await pi.commands.get("readonly").handler("", ctx as any); + + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + assert.equal(await toolCall({ toolName: "handoff", input: { task: "pivot" } }, {}), undefined); + assert.equal(await toolCall({ toolName: "bash", input: { command: "rm -rf /" } }, { cwd: "/workspace" }), undefined); +}); + +test("readonly toggle is a no-op in headless mode", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const [inputHandler] = pi.handlers.get("input")!; + const [beforeStartHandler] = pi.handlers.get("before_agent_start")!; + + // Toggle readonly via the command handler with hasUI: false. + // The handler guards on ctx.hasUI — in headless mode the toggle + // is a no-op, preserving the contract: readonly is a TUI-only feature. + await pi.commands.get("readonly").handler("", { + hasUI: false, + getContextUsage: () => null, + } as any); + + await inputHandler({ text: "/review", source: "interactive" }, { + hasUI: false, + getContextUsage: () => null, + } as any); + await beforeStartHandler({ + systemPrompt: "", + systemPromptOptions: { skills: [] }, + }, { + hasUI: false, + cwd: process.cwd(), + isProjectTrusted: () => false, + getContextUsage: () => null, + } as any); + + // Write should remain unblocked for both manual and deferred readonly paths. + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); +}); + +test("readonly shortcut only toggles while idle", async () => { + const { pi, toolCall } = registerReadonlyPI(); + const shortcut = pi.shortcuts.get("ctrl+shift+r"); + assert.ok(shortcut); + + await shortcut.handler({ ...makeReadonlyUICtx(), isIdle: () => false } as any); + assert.equal(await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {}), undefined); + + await shortcut.handler({ ...makeReadonlyUICtx(), isIdle: () => true } as any); + assert.equal((await toolCall({ toolName: "write", input: { path: "/tmp/x", content: "x" } }, {})).block, true); +}); + +test("readonly toggle delivers an activation nudge via context hook", async () => { + const { pi } = registerReadonlyPI(); + const [contextHook] = pi.handlers.get("context")!; + + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + + const result = await contextHook( + { messages: [] }, + { getContextUsage: () => ({ percent: 40 }) }, + ); + const readonlyNudge = result.messages.find((message: any) => /readonly/i.test(message.content ?? "")); + assert.ok(readonlyNudge, "context hook should deliver a readonly activation nudge"); + assert.equal(readonlyNudge.role, "custom"); + assert.equal(readonlyNudge.display, false); +}); + +test("readonly toggle off delivers a deactivation nudge via context hook", async () => { + const { pi } = registerReadonlyPI(); + const [contextHook] = pi.handlers.get("context")!; + + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + + const result = await contextHook( + { messages: [] }, + { getContextUsage: () => ({ percent: 40 }) }, + ); + const readonlyNudge = result.messages.find((message: any) => /readonly|turned off|disabled/i.test(message.content ?? "")); + assert.ok(readonlyNudge, "context hook should deliver a readonly deactivation nudge"); + assert.match(readonlyNudge.content, /readonly/i); + assert.match(readonlyNudge.content, /off|disabled|turned off/i); +}); + + diff --git a/tests/unit/readonly-rehydration.test.ts b/tests/unit/readonly-rehydration.test.ts new file mode 100644 index 0000000..be11d91 --- /dev/null +++ b/tests/unit/readonly-rehydration.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getReadonlyFromBranch } from "../../readonly-rehydration.js"; + +function makePI(readonlyFlag = false) { + return { getFlag: () => readonlyFlag }; +} + +test("getReadonlyFromBranch returns false for empty branch with no CLI flag", () => { + assert.equal(getReadonlyFromBranch([], makePI(false)), false); +}); + +test("getReadonlyFromBranch returns true for empty branch with CLI flag", () => { + assert.equal(getReadonlyFromBranch([], makePI(true)), true); +}); + +test("getReadonlyFromBranch uses a persisted readonly entry when present", () => { + assert.equal( + getReadonlyFromBranch([ + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + ], makePI(false)), + true, + ); + assert.equal( + getReadonlyFromBranch([ + { type: "custom", customType: "agenticoding-readonly", data: { enabled: false } }, + ], makePI(true)), + false, + ); +}); + +test("getReadonlyFromBranch picks the latest entry (true wins)", () => { + const branch = [ + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + { type: "custom", customType: "agenticoding-readonly", data: { enabled: false } }, + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + ]; + assert.equal(getReadonlyFromBranch(branch, makePI(false)), true); +}); + +test("getReadonlyFromBranch picks the latest entry (false wins)", () => { + const branch = [ + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + { type: "custom", customType: "agenticoding-readonly", data: { enabled: true } }, + { type: "custom", customType: "agenticoding-readonly", data: { enabled: false } }, + ]; + assert.equal(getReadonlyFromBranch(branch, makePI(false)), false); +}); + +test("getReadonlyFromBranch skips entries with wrong customType", () => { + const branch = [ + { type: "custom", customType: "other-extension", data: { enabled: true } }, + ]; + assert.equal(getReadonlyFromBranch(branch, makePI(false)), false); +}); + +test("getReadonlyFromBranch falls back to the CLI flag when no valid readonly entry exists", () => { + assert.equal(getReadonlyFromBranch([null, "string", 42], makePI(true)), true); + assert.equal( + getReadonlyFromBranch([ + { type: "custom", customType: "agenticoding-readonly", data: null }, + ], makePI(false)), + false, + ); +}); diff --git a/tests/unit/readonly-spawn.test.ts b/tests/unit/readonly-spawn.test.ts new file mode 100644 index 0000000..6a69979 --- /dev/null +++ b/tests/unit/readonly-spawn.test.ts @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { createState } from "../../state.js"; +import { registerSpawnTool } from "../../spawn/index.js"; +import { createTestPI } from "./helpers.js"; + +async function spawnWithCapture( + readonlyEnabled: boolean, + inspect: (config: any, prompt: string) => Promise | void, + activeTools?: string[], +) { + const pi = createTestPI(); + const tools = activeTools ?? ["read", "bash", "write", "edit", "spawn", "handoff"]; + pi.setActiveTools(tools); + pi.setAllTools(tools); + const state = createState(); + state.readonlyEnabled = readonlyEnabled; + + const sessionFactory = async (config: any) => { + const session = { + messages: [] as any[], + prompt: async (prompt: string) => { + await inspect(config, prompt); + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + }, + abort: async () => {}, + getSessionStats: () => undefined, + }; + return { session: session as any }; + }; + + registerSpawnTool(pi as any, state, sessionFactory as any); + await pi.tools.get("spawn").execute( + "spawn-readonly", + { prompt: "test" }, + undefined, + undefined, + { model: { id: "mock-model" }, cwd: process.cwd() }, + ); +} + +test("readonly spawn child prompt tells the child it inherits readonly authority", async () => { + let prompt = ""; + + await spawnWithCapture(true, (_config, childPrompt) => { + prompt = childPrompt; + }); + + assert.match(prompt, /inherit readonly authority/i); + assert.match(prompt, /readonly restrictions apply/i); +}); + +test("non-readonly spawn child prompt keeps normal authority", async () => { + let prompt = ""; + + await spawnWithCapture(false, (_config, childPrompt) => { + prompt = childPrompt; + }); + + assert.match(prompt, /same authority as the parent/i); + assert.doesNotMatch(prompt, /readonly restrictions apply/i); +}); + +test("readonly spawn child bash tool blocks non-temp writes and allows temp writes", async () => { + const outsideTemp = path.join(os.homedir(), "readonly-child-test"); + const insideTemp = path.join(os.tmpdir(), `readonly-child-test-${Date.now()}`); + + await spawnWithCapture(true, async (config) => { + const bashTool = config.customTools.find((tool: any) => tool.name === "bash"); + + assert.ok(bashTool, "readonly child should receive a bash tool"); + await assert.rejects( + () => bashTool.execute("bash-1", { command: `touch ${outsideTemp}` }), + /Readonly mode:/, + ); + await assert.doesNotReject( + () => bashTool.execute("bash-2", { command: `touch ${insideTemp} && rm ${insideTemp}` }), + ); + }); +}); diff --git a/tests/unit/resolve-path.test.ts b/tests/unit/resolve-path.test.ts new file mode 100644 index 0000000..6a31c2f --- /dev/null +++ b/tests/unit/resolve-path.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import { resolveRealPath } from "../../resolve-path.js"; + +const DEEP_PATH_PARTS = ["__pi_test_deep", "a", "b", "c"]; + +test("resolveRealPath: non-existent path inside temp dir preserves full path", () => { + const tmp = os.tmpdir(); + const nonExistent = path.join(tmp, ...DEEP_PATH_PARTS); + const result = resolveRealPath(nonExistent); + // Use path.join for platform-native separators (\ vs /) + const expectedSuffix = path.join(...DEEP_PATH_PARTS); + assert.ok( + result.includes(expectedSuffix), + `should preserve all path components — expected "${expectedSuffix}" in "${result}"`, + ); +}); + +test("resolveRealPath follows symlinks", () => { + const dir = os.tmpdir(); + const target = path.join(dir, `pi-test-target-${Date.now()}`); + const link = path.join(dir, `pi-test-link-${Date.now()}`); + fs.mkdirSync(target); + try { + fs.symlinkSync(target, link); + const resolved = resolveRealPath(link); + // Use resolveRealPath on target too to handle macOS /var → /private/var + assert.equal(resolved, resolveRealPath(target)); + } finally { + fs.rmSync(link, { force: true }); + fs.rmSync(target, { force: true, recursive: true }); + } +}); diff --git a/tests/unit/spawn-event.test.ts b/tests/unit/spawn-event.test.ts index 45feaf9..81808eb 100644 --- a/tests/unit/spawn-event.test.ts +++ b/tests/unit/spawn-event.test.ts @@ -73,8 +73,6 @@ test("nested spawn handleEvent recovers from malformed events", () => { // Emit a malformed event that will throw inside handleEvent emit({ type: "message_start", message: null }); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[1]), /message_start/); // Subsequent valid events still process emit({ type: "message_start", message: { role: "assistant", content: [] } }); @@ -447,8 +445,6 @@ test("nested spawn recovers batching state after event handler error", async () const lines = component.render(120); assert.ok(lines.some((l: string) => l.includes("thinking")), "error recovery should allow subsequent events to render"); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[0]), /Event handler error/); }); test("handleEvent gracefully degrades with null message events", () => { diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 817b3d4..8ad0475 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -10,6 +10,7 @@ import { createChildTools, executeSpawn, registerSpawnTool, + truncateText, } from "../../spawn/index.js"; import { renderSpawnResult } from "../../spawn/renderer.js"; import { createTestPI, createRenderContext, createSession, createSubscribableSession, messageText, makeTUICtx, theme, createTestAssistantMessage, createTestAssistantStream } from "./helpers.js"; @@ -222,6 +223,13 @@ test("spawn execute builds prompt with notebook pages and task", async () => { assert.match(seenPrompt, /entry-a: preview line/); }); +test("truncateText handles multi-byte boundaries correctly", () => { + assert.equal(truncateText("🙂", 10, 2), ""); + assert.equal(truncateText("🙂", 10, 4), "🙂"); + assert.equal(truncateText("", 10, 1024), ""); + assert.equal(truncateText("hello", 10, 1024), "hello"); +}); + test("spawn renderResult falls back to static text when no live session is stored", () => { const state = createState(); const pi = createTestPI(); @@ -353,9 +361,6 @@ test("spawn execute marks stats unavailable when stats collection throws", async assert.equal(result.details.stats, undefined); assert.equal(result.details.statsUnavailable, true); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[1]), /stats failed/); - assert.equal(h.warnings[0].args[2], "spawn-1"); }); test("spawn execute throws when child produces no output", async () => { @@ -674,6 +679,42 @@ test("child tool names exclude inactive registered and active phantom tools", () assert.equal(toolNames.includes("spawn"), false); }); +test("buildChildToolNames (2-arg fallback) removes spawn and handoff from inherited tools", () => { + const result = buildChildToolNames(["read", "bash", "spawn", "handoff"], []); + assert.ok(!result.includes("spawn"), "spawn must be filtered out"); + assert.ok(!result.includes("handoff"), "handoff must be filtered out"); + assert.ok(result.includes("read"), "read must be preserved"); + assert.ok(result.includes("bash"), "bash must be preserved"); +}); + +test("buildChildToolNames (2-arg fallback) preserves non-spawn/handoff tools", () => { + const result = buildChildToolNames(["read", "bash", "write", "edit"], []); + assert.deepEqual(result.sort(), ["bash", "edit", "read", "write"]); +}); + +test("buildChildToolNames (2-arg fallback) adds custom child tools to the list", () => { + const result = buildChildToolNames(["read"], [{ name: "custom-tool", description: "", parameters: {}, label: "", execute: async () => ({ content: [], details: undefined }) }]); + assert.ok(result.includes("custom-tool"), "custom child tool must be added"); + assert.ok(result.includes("read"), "inherited tool must be preserved"); +}); + +test("buildChildToolNames (2-arg fallback) deduplicates overlapping inherited and custom names", () => { + const result = buildChildToolNames(["read", "bash"], [{ name: "bash", description: "", parameters: {}, label: "", execute: async () => ({ content: [], details: undefined }) }]); + assert.deepEqual(result, ["read", "bash"]); +}); + +test("buildChildToolNames (2-arg fallback) handles empty parent tool names", () => { + assert.deepEqual(buildChildToolNames([], [{ name: "only-child", description: "", parameters: {}, label: "", execute: async () => ({ content: [], details: undefined }) }]), ["only-child"]); +}); + +test("buildChildToolNames (2-arg fallback) handles empty custom tools", () => { + assert.deepEqual(buildChildToolNames(["read", "bash"], []), ["read", "bash"]); +}); + +test("buildChildToolNames (2-arg fallback) handles both empty inputs", () => { + assert.deepEqual(buildChildToolNames([], []), []); +}); + test("spawn execute short-circuits when signal is already aborted", async () => { const pi = createTestPI(); pi.setActiveTools(["read", "bash", "spawn"]); @@ -1131,8 +1172,6 @@ test("nested spawn attachSession recovers from subscribe throwing", () => { // Should not crash, session attached, ownership transferred assert.equal(state.childSessions.has("tool-call-1"), false); - assert.equal(h.warnings.length, 1); - assert.match(String(h.warnings[0].args[0]), /Failed to subscribe/); // Should still render from session messages despite subscribe failure const lines = component.render(120); diff --git a/tests/unit/state-invariants.test.ts b/tests/unit/state-invariants.test.ts index c52778a..d2ded47 100644 --- a/tests/unit/state-invariants.test.ts +++ b/tests/unit/state-invariants.test.ts @@ -109,6 +109,14 @@ function assertResetClears(state: AgenticodingState): void { assert.equal(state.pendingHandoff, null, "pendingHandoff must be null after reset"); assert.equal(state.pendingRequestedHandoff, null, "pendingRequestedHandoff must be null after reset"); assert.equal(state.pendingTopicBoundaryHint, null, "pendingTopicBoundaryHint must be null after reset"); + assert.equal(state.readonlyEnabled, false, "readonlyEnabled must be false after reset"); + assert.equal(state.readonlyNudgePending, false, "readonlyNudgePending must be false after reset"); + assert.equal(state.readonlySkillCache.size, 0, "readonlySkillCache must be empty after reset"); + assert.equal(state.readonlyPromptCache.size, 0, "readonlyPromptCache must be empty after reset"); + assert.equal(state.readonlySkillIssues.size, 0, "readonlySkillIssues must be empty after reset"); + assert.equal(state.readonlyPromptIssues.size, 0, "readonlyPromptIssues must be empty after reset"); + assert.equal(state.pendingReadonlyCommands.length, 0, "pendingReadonlyCommands must be empty after reset"); + assert.equal(state.lastWatchdogBand, null, "lastWatchdogBand must be null after reset"); } // ── Properties ──────────────────────────────────────────────────────── @@ -257,6 +265,13 @@ test("Property 4: Reset clears all state fields", async () => { const s2 = createState(); setActiveNotebookTopic(s2, "test-topic", "agent"); await saveNotebookPage(mockPi, s2, "my-page", "some content"); + s2.readonlyEnabled = true; + s2.readonlyNudgePending = true; + s2.readonlySkillCache.set("skill-a", { readonly: true, mtimeMs: 1, filePath: "/tmp/skill-a.md" }); + s2.readonlyPromptCache.set("prompt-a", { readonly: false, mtimeMs: 1, filePath: "/tmp/prompt-a.md" }); + s2.readonlySkillIssues.set("skill-b", { kind: "invalid-readonly-value", filePath: "/tmp/skill-b.md" }); + s2.readonlyPromptIssues.set("prompt-b", { kind: "unreadable-file", filePath: "/tmp/prompt-b.md" }); + s2.pendingReadonlyCommands.push({ type: "skill", name: "skill-a" }); resetState(s2); assertResetClears(s2); }, diff --git a/tests/unit/system-prompt.test.ts b/tests/unit/system-prompt.test.ts index 45e6a89..79d9d72 100644 --- a/tests/unit/system-prompt.test.ts +++ b/tests/unit/system-prompt.test.ts @@ -33,7 +33,6 @@ test("CONTEXT_PRIMER states the notebook, topic, and handoff contracts", () => { assert.match(rulesSection, /one subject, thread, or subsystem/i); }); - test("before_agent_start injects notebook contracts plus live topic and page data", async () => { const pi = createTestPI(); registerAgenticoding(pi as any); @@ -42,7 +41,8 @@ test("before_agent_start injects notebook contracts plus live topic and page dat await notebookWrite.execute("1", { name: "alpha", content: "first line\nsecond line" }, undefined, undefined, makeTUICtx()); const [handler] = pi.handlers.get("before_agent_start")!; - const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); + const ctx = { ...makeTUICtx({ hasUI: false }), cwd: process.cwd(), isProjectTrusted: () => false }; + const result = await handler({ systemPrompt: "Base system prompt." }, ctx); assert.match(result.systemPrompt, /Base system prompt\./); assert.match(result.systemPrompt, /## Context management/); @@ -54,12 +54,12 @@ test("before_agent_start injects notebook contracts plus live topic and page dat assert.match(result.systemPrompt, /alpha: first line/); }); - test("before_agent_start injects no-topic guidance when the topic is unset", async () => { const pi = createTestPI(); registerAgenticoding(pi as any); const [handler] = pi.handlers.get("before_agent_start")!; - const result = await handler({ systemPrompt: "Base system prompt." }, makeTUICtx({ hasUI: false })); + const ctx = { ...makeTUICtx({ hasUI: false }), cwd: process.cwd(), isProjectTrusted: () => false }; + const result = await handler({ systemPrompt: "Base system prompt." }, ctx); assert.match(result.systemPrompt, /## Active Notebook Topic/); assert.match(result.systemPrompt, /No active notebook topic is set\./); diff --git a/tests/unit/tui-indicators.test.ts b/tests/unit/tui-indicators.test.ts index 83c2a18..fc2f152 100644 --- a/tests/unit/tui-indicators.test.ts +++ b/tests/unit/tui-indicators.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createState } from "../../state.js"; -import { updateIndicators, STATUS_KEY_TOPIC } from "../../tui.js"; +import { updateIndicators, STATUS_KEY_TOPIC, STATUS_KEY_READONLY } from "../../tui.js"; import { makeTUICtx } from "./helpers.js"; test("updateIndicators sets context usage status with correct color tone", () => { @@ -89,6 +89,43 @@ test("updateIndicators shows active notebook topic when set", () => { assert.equal(record.statuses.get(STATUS_KEY_TOPIC), "🧭 oauth"); }); +test("updateIndicators shows readonly indicator when enabled", () => { + const state = createState(); + state.readonlyEnabled = true; + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: null, record }); + + updateIndicators(ctx, state); + const s = record.statuses.get(STATUS_KEY_READONLY); + assert.ok(s?.includes("\u{1F512}"), "readonly indicator should show lock emoji when enabled"); + assert.ok(s?.includes("readonly"), "readonly indicator should show 'readonly' text when enabled"); +}); + +test("updateIndicators hides readonly indicator when disabled", () => { + const state = createState(); + state.readonlyEnabled = false; + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: null, record }); + + updateIndicators(ctx, state); + assert.equal(record.statuses.get(STATUS_KEY_READONLY), undefined, "readonly indicator should be undefined when disabled"); +}); + +test("updateIndicators shows readonly-specific warning widget at 70%+ context", () => { + const state = createState(); + state.readonlyEnabled = true; + const record = { statuses: new Map(), widgets: new Map() }; + const ctx = makeTUICtx({ percent: 85, record }); + + updateIndicators(ctx, state); + const w = record.widgets.get("agenticoding-warning"); + assert.ok(w, "warning widget should be present at 85%"); + assert.ok(w[0].includes("readonly"), "widget should mention readonly"); + assert.ok(w[0].includes("spawn"), "widget should mention spawn"); + assert.ok(w[0].includes("handoff"), "widget should mention handoff"); + assert.ok(w[0].includes("resumes readonly"), "widget should mention readonly resumption"); +}); + test("updateIndicators hides widget below 70% context", () => { const state = createState(); const record = { statuses: new Map(), widgets: new Map() }; diff --git a/tests/unit/watchdog.test.ts b/tests/unit/watchdog.test.ts index 2de756a..c5edf45 100644 --- a/tests/unit/watchdog.test.ts +++ b/tests/unit/watchdog.test.ts @@ -4,7 +4,8 @@ import { createState } from "../../state.js"; import { registerWatchdog } from "../../watchdog.js"; import { buildNudge } from "../../watchdog.js"; import registerAgenticoding from "../../index.js"; -import { createTestPI } from "./helpers.js"; +import { registerHandoffCommand } from "../../handoff/command.js"; +import { createTestPI, makeReadonlyUICtx } from "./helpers.js"; test("watchdog records context usage without user notifications", async () => { const pi = createTestPI(); @@ -22,7 +23,6 @@ test("watchdog records context usage without user notifications", async () => { }, ); - assert.equal(state.lastContextPercent, 70); assert.deepEqual(notifications, []); }); @@ -44,10 +44,11 @@ test("context injects watchdog reminder before each LLM call", async () => { assert.equal(result.messages[1].role, "custom"); assert.equal(result.messages[1].customType, "agenticoding-watchdog"); assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /Context at 70%/); - assert.match(result.messages[1].content, /Active notebook topic: oauth/); - assert.match(result.messages[1].content, /spawn it instead of polluting the parent context/i); - assert.doesNotMatch(result.messages[1].content, /If you're mid-job and still clear|consider a handoff and draft a clear brief for what comes next/i); + assert.match(result.messages[1].content, /70%/); + assert.match(result.messages[1].content, /oauth/); + assert.match(result.messages[1].content, /spawn/i); + assert.match(result.messages[1].content, /parent context/i); + assert.doesNotMatch(result.messages[1].content, /draft a clear brief|what comes next/i); }); @@ -64,7 +65,9 @@ test("context injects a boundary nudge below 30% after an explicit topic change" ); assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /Notebook topic changed from oauth to billing/); + assert.match(result.messages[1].content, /oauth/i); + assert.match(result.messages[1].content, /billing/i); + assert.match(result.messages[1].content, /topic changed/i); }); @@ -82,8 +85,9 @@ test("context injects a no-topic nudge when context is high", async () => { assert.equal(result.messages[1].role, "custom"); assert.equal(result.messages[1].customType, "agenticoding-watchdog"); assert.equal(result.messages[1].display, false); - assert.match(result.messages[1].content, /No active notebook topic is set/); - assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i); + assert.match(result.messages[1].content, /no active notebook topic/i); + assert.match(result.messages[1].content, /fresh topic/i); + assert.match(result.messages[1].content, /handoff/i); }); @@ -98,7 +102,9 @@ test("context consumes a boundary hint after the first injected nudge", async () { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, { getContextUsage: () => ({ percent: 20 }) }, ); - assert.match(first.messages[1].content, /Notebook topic changed from oauth to billing/); + assert.match(first.messages[1].content, /oauth/i); + assert.match(first.messages[1].content, /billing/i); + assert.match(first.messages[1].content, /topic changed/i); const second = await handler( { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, @@ -108,11 +114,10 @@ test("context consumes a boundary hint after the first injected nudge", async () }); -test("buildNudge no longer emits the old percent-only handoff text", () => { - const old = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null }, 46); - assert.doesNotMatch(old, /One context, one job\.|If you're mid-job and still clear|consider a handoff and draft a clear brief/i); - assert.match(old, /Active notebook topic: oauth/); - assert.match(old, /prefer spawn/i); +test("buildNudge emits topic and spawn guidance", () => { + const nudge = buildNudge({ activeNotebookTopic: "oauth", pendingTopicBoundaryHint: null, readonlyEnabled: false, pendingRequestedHandoff: null }, 46); + assert.match(nudge, /Active notebook topic: oauth/); + assert.match(nudge, /prefer spawn/i); }); @@ -121,24 +126,31 @@ test("buildNudge handles null percent and boundary hints before topic guidance", { activeNotebookTopic: "oauth", pendingTopicBoundaryHint: { from: "oauth", to: "billing", source: "human" }, + readonlyEnabled: false, + pendingRequestedHandoff: null, }, null, ); assert.match(boundary, /Notebook topic changed from oauth to billing/); assert.doesNotMatch(boundary, /Active notebook topic: oauth/); - const noTopic = buildNudge({ activeNotebookTopic: null, pendingTopicBoundaryHint: null }, null); + const noTopic = buildNudge({ activeNotebookTopic: null, pendingTopicBoundaryHint: null, readonlyEnabled: false, pendingRequestedHandoff: null }, null); assert.match(noTopic, /Topic-aware context reminder/); assert.match(noTopic, /No active notebook topic is set/); }); -test("watchdog stays advisory when a requested handoff is not completed", async () => { +test("watchdog stays advisory for a fresh user-requested handoff", async () => { const pi = createTestPI(); const state = createState(); - state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false }; + registerHandoffCommand(pi as any, state); registerWatchdog(pi as any, state); const [handler] = pi.handlers.get("agent_end")!; + await pi.commands.get("handoff").handler("implement auth", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + const notifications: string[] = []; await handler( {}, @@ -152,7 +164,83 @@ test("watchdog stays advisory when a requested handoff is not completed", async }, ); - assert.equal(state.pendingRequestedHandoff, null); + assert.equal(state.pendingRequestedHandoff?.toolCalled, false); + assert.ok(state.pendingRequestedHandoff, "handoff request should remain active after one turn"); assert.deepEqual(notifications, []); - assert.deepEqual(pi.sentUserMessages, []); +}); + +test("watchdog auto-cancels a user-requested handoff after enough unanswered turns", async () => { + const pi = createTestPI(); + const state = createState(); + registerHandoffCommand(pi as any, state); + registerWatchdog(pi as any, state); + const [handler] = pi.handlers.get("agent_end")!; + + await pi.commands.get("handoff").handler("implement auth", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + const notifications: unknown[] = []; + const ctx = { + hasUI: true, + ui: { notify: (message: unknown) => notifications.push(message), setStatus: () => {} }, + getContextUsage: () => ({ percent: 20 }), + }; + + for (let i = 0; i < 5; i++) { + await handler({}, ctx); + } + + assert.equal(state.pendingRequestedHandoff, null, "pending handoff should be auto-cancelled"); + assert.ok(notifications.length > 0, "user should receive a cancellation notification"); + assert.match(notifications[0] as string, /cancelled/i, "notification should mention cancellation"); +}); + +// ── Readonly-specific injection contracts ───────────────────────── + +test("context injects a readonly-mode nudge after toggle", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + const [handler] = pi.handlers.get("context")!; + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { getContextUsage: () => null }, + ); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[1].customType, "agenticoding-readonly-nudge"); + assert.match(result.messages[1].content, /readonly/i); + assert.match(result.messages[1].content, /write/i); + assert.match(result.messages[1].content, /edit/i); + assert.match(result.messages[1].content, /handoff/i); + assert.match(result.messages[1].content, /bash/i); +}); + +test("context injects readonly handoff guidance after explicit user /handoff", async () => { + const pi = createTestPI(); + registerAgenticoding(pi as any); + const [handler] = pi.handlers.get("context")!; + await pi.commands.get("readonly").handler("", makeReadonlyUICtx() as any); + await handler( + { messages: [{ role: "user", content: "clear initial readonly nudge", timestamp: 1 }] }, + { getContextUsage: () => null }, + ); + await pi.commands.get("handoff").handler("continue readonly work", { + ...makeReadonlyUICtx(), + isIdle: () => true, + } as any); + + const result = await handler( + { messages: [{ role: "user", content: "hi", timestamp: 2 }] }, + { getContextUsage: () => ({ percent: 70 }) }, + ); + const watchdogMessage = result.messages.find((message: any) => message.customType === "agenticoding-watchdog"); + + assert.ok(watchdogMessage, "requested handoff should inject watchdog guidance"); + assert.match(watchdogMessage.content, /handoff/i); + assert.match(watchdogMessage.content, /readonly/i); + assert.match(watchdogMessage.content, /resume/i); }); diff --git a/tui.ts b/tui.ts index 9205b2c..aac68c5 100644 --- a/tui.ts +++ b/tui.ts @@ -25,6 +25,9 @@ export const STATUS_KEY_NOTEBOOK = "agenticoding-notebook"; /** Status bar key for the active notebook topic. */ export const STATUS_KEY_TOPIC = "agenticoding-topic"; +/** Status bar key for the readonly mode indicator. */ +export const STATUS_KEY_READONLY = "agenticoding-readonly"; + /** Update TUI indicators: context usage, notebook count, topic, warning widget. */ export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState): void { if (!ctx.hasUI) return; @@ -48,6 +51,12 @@ export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState : theme.fg("dim", "\u{1F4D2} 0"), ); + // Readonly mode indicator + ctx.ui.setStatus( + STATUS_KEY_READONLY, + state.readonlyEnabled ? theme.fg("warning", "\u{1F512} readonly") : undefined, + ); + // Active notebook topic — show a dim placeholder when unset so the frame is discoverable ctx.ui.setStatus( STATUS_KEY_TOPIC, @@ -58,9 +67,12 @@ export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState // High-context warning widget (above editor) if (usage && usage.percent !== null && usage.percent >= 70) { - const warning = state.activeNotebookTopic - ? `Context at ${Math.round(usage.percent)}% — use topic fit: same topic → spawn, different topic → handoff` - : `Context at ${Math.round(usage.percent)}% — no active topic; handoff soon unless you can assign one cleanly`; + const pct = Math.round(usage.percent); + const warning = state.readonlyEnabled + ? `Context at ${pct}% — readonly: same topic → spawn; different topic → use /handoff for a real pivot; fresh context resumes readonly` + : state.activeNotebookTopic + ? `Context at ${pct}% — use topic fit: same topic → spawn, different topic → handoff` + : `Context at ${pct}% — no active topic; handoff soon unless you can assign one cleanly`; ctx.ui.setWidget(WIDGET_KEY_WARNING, [ theme.fg("error", "\u26A0 ") + theme.fg("warning", warning), ]); diff --git a/watchdog.ts b/watchdog.ts index 2800817..65d08ec 100644 --- a/watchdog.ts +++ b/watchdog.ts @@ -1,23 +1,45 @@ /** - * Watchdog: advisory primacy-zone reminder. + * Watchdog: primacy-zone reminder plus sticky enforcement for user-requested handoff. * * Exposes nudge text generation and records the latest context usage at * `agent_end` for UI/state purposes. Actual reminder injection happens in the * `context` hook so it can appear before every LLM call in the same agent run. - * - * Never force-disengages — the watchdog is advisory only. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { AgenticodingState } from "./state.js"; import { STATUS_KEY_HANDOFF } from "./tui.js"; -export function buildNudge(state: Pick, percent: number | null): string { +/** Max turns a user-requested handoff remains sticky before auto-clear. + * 5 turns gives the LLM ~2-3 response cycles to draft and execute + * a handoff brief before we give up and notify the user. */ +const MAX_HANDOFF_ATTEMPTS = 5; + +export function buildNudge( + state: Pick, + percent: number | null, +): string { const pct = percent === null ? null : Math.round(percent); const topic = state.activeNotebookTopic; const boundary = state.pendingTopicBoundaryHint; + const readonly = state.readonlyEnabled; + const requestedHandoff = state.pendingRequestedHandoff; + + if (requestedHandoff) { + const readonlyContinuation = requestedHandoff.resumeReadonlyAfterHandoff + ? "Readonly remains active for normal mutations. A temporary exception allows only the handoff tool for this request. After handoff, the fresh context will resume in readonly mode and the exception will be cleared. Draft the brief for readonly continuation." + : "Complete a real handoff now rather than continuing normal work. Draft the brief so the next context can start cleanly."; + return `User explicitly requested /handoff. +You must complete a real handoff in this session now. +Save durable findings to the notebook if needed, then call handoff. +${readonlyContinuation}`; + } if (boundary) { + if (readonly) { + return `Notebook topic changed from ${boundary.from ?? "(unset)"} to ${boundary.to}. +Readonly mode is active. Use spawn only for subtasks that still fit the current topic. If the user explicitly requests /handoff, complete it and ensure the brief says the fresh context resumes in readonly mode.`; + } return `Notebook topic changed from ${boundary.from ?? "(unset)"} to ${boundary.to}. Treat this as a strong task-boundary signal. Prefer a deliberate handoff before continuing under the new topic: save durable findings to the notebook, draft a @@ -25,6 +47,24 @@ concise situational brief, and call handoff. Only continue inline if this was merely a rename rather than a real pivot.`; } + if (readonly) { + const contextLead = pct === null + ? "Readonly mode is active." + : `Context at ${pct}% — readonly mode is active.`; + + const readonlyAdvice = "Use spawn only for same-topic delegation. If the user explicitly requests /handoff, complete it and ensure the brief says the fresh context resumes in readonly mode."; + if (topic) { + return `${contextLead} +Active notebook topic: ${topic}. +${readonlyAdvice} +Save durable findings to the notebook before moving on.`; + } + return `${contextLead} +${readonlyAdvice} +Assign a short stable topic with notebook_topic_set to track the current frame.`; + } + + // ── Not readonly — existing logic unchanged ────────────────────── const contextLead = pct === null ? "Topic-aware context reminder." : pct >= 70 @@ -57,13 +97,18 @@ No active notebook topic is set. ${noTopicUrgency}`; */ export function registerWatchdog(pi: ExtensionAPI, state: AgenticodingState): void { pi.on("agent_end", async (_event: unknown, ctx: ExtensionContext) => { + // ── Enforcement counter: prevent infinite handoff nudges ── const requestedHandoff = state.pendingRequestedHandoff; - if (requestedHandoff) { + if (requestedHandoff && !requestedHandoff.toolCalled) { requestedHandoff.enforcementAttempts += 1; - if (!requestedHandoff.toolCalled) { + if (requestedHandoff.enforcementAttempts >= MAX_HANDOFF_ATTEMPTS) { state.pendingRequestedHandoff = null; if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined); + ctx.ui.notify( + `User-requested /handoff cancelled after ${MAX_HANDOFF_ATTEMPTS} turns without completion. Use /handoff again to retry.`, + "warning", + ); } } }