From 0a8f3f325994a0fbb116b3c517b6f6a2c04b4c00 Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp Date: Wed, 6 May 2026 09:39:44 -0400 Subject: [PATCH 1/2] feat(cli): add startup input prompting Read local Contract Markdown caller inputs before forwarding run commands so interactive terminals can supply missing values deterministically. Preserve explicit CLI arguments and non-local targets, fail fast in non-TTY mode, and document/test the startup prompting behavior. --- tools/cli/README.md | 36 ++++ tools/cli/src/commands/base.ts | 15 +- tools/cli/src/index.ts | 16 +- tools/cli/src/prose/index.ts | 11 ++ tools/cli/src/prose/startup-inputs.ts | 269 ++++++++++++++++++++++++++ tools/cli/tests/cli/cli.test.ts | 244 +++++++++++++++++++++++ 6 files changed, 589 insertions(+), 2 deletions(-) create mode 100644 tools/cli/src/prose/startup-inputs.ts diff --git a/tools/cli/README.md b/tools/cli/README.md index dff2b217..8977cda4 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -125,6 +125,42 @@ prose doctor --harness claude-sdk --install - `SIGINT` and `SIGTERM` are propagated through the active harness. - Arguments after `--` are forwarded literally, including `--harness`. +### Startup input prompting + +For `prose run `, the CLI deterministically reads the +file-level `### Requires` section before invoking the selected harness. Missing +caller inputs declared as backtick-wrapped bullets are prompted for when stdin +is an interactive terminal: + +```markdown +### Requires + +- `project`: project name +- `audience`: who the run is for +``` + +```bash +prose run demo.prose.md +``` + +The same inputs can be supplied up front: + +```bash +prose run demo.prose.md --project "OpenProse" --audience "contributors" +prose run demo.prose.md --project=OpenProse --audience=contributors +``` + +Prompted values are forwarded to the harness as ordinary caller input flags. +The CLI does not write run state for startup prompting. + +In non-interactive contexts, missing caller inputs fail before harness +invocation instead of hanging. Use `--no-prompt` to require explicit inputs even +in an interactive terminal: + +```bash +prose run demo.prose.md --no-prompt --project "OpenProse" +``` + The tarball installer is intentionally a Node.js installer: the CLI package is JavaScript, so the installed shim executes Node.js 18 or newer. The script verifies release checksums by default, rejects unsafe tar paths, symlinks, diff --git a/tools/cli/src/commands/base.ts b/tools/cli/src/commands/base.ts index 6f3dde68..a07de13e 100644 --- a/tools/cli/src/commands/base.ts +++ b/tools/cli/src/commands/base.ts @@ -1,6 +1,7 @@ import { Command } from "@oclif/core"; import type { CommandName } from "../prose/index.js"; import { canonicalPrompt, CommandModelError, usageFor } from "../prose/index.js"; +import { resolveStartupInputs, type PromptInputLike, type StartupInputReader } from "../prose/startup-inputs.js"; import { createHarness, type HarnessName } from "../harnesses/index.js"; import type { Harness, WritableStreamLike } from "../harnesses/types.js"; import { ensureOpenProseSkill, loadOpenProseSkillBootstrap, type OpenProseSkillBootstrap } from "../skills/open-prose.js"; @@ -23,8 +24,10 @@ export interface ForwardRunOptions { env: Readonly>; stdout: WritableStreamLike; stderr: WritableStreamLike; + stdin?: PromptInputLike; signal?: AbortSignal; harnessFactory?: (name: string) => Harness; + startupInputReader?: StartupInputReader; skillBootstrap?: SkillBootstrapLoader | false; skillPreflight?: SkillPreflight | false; } @@ -52,6 +55,7 @@ export abstract class ProseForwardCommand extends Command { env: process.env, stdout: process.stdout, stderr: process.stderr, + stdin: process.stdin, signal: controller.signal, }); if (exitCode !== 0) { @@ -88,7 +92,16 @@ function isOclifExit(error: unknown): boolean { export async function runForwardedProseCommand(options: ForwardRunOptions): Promise { const { harness, args } = splitHarnessArgs(options.argv, options.env, options.command); - const prompt = canonicalPrompt(options.command, args); + canonicalPrompt(options.command, args); + const startupInputs = await resolveStartupInputs({ + command: options.command, + args, + cwd: options.cwd, + stderr: options.stderr, + ...(options.stdin === undefined ? {} : { stdin: options.stdin }), + ...(options.startupInputReader === undefined ? {} : { inputReader: options.startupInputReader }), + }); + const prompt = canonicalPrompt(options.command, startupInputs.args); if (shouldRunSkillPreflight(options)) { await runSkillPreflight(harness, options); } diff --git a/tools/cli/src/index.ts b/tools/cli/src/index.ts index aa309a9a..52580443 100644 --- a/tools/cli/src/index.ts +++ b/tools/cli/src/index.ts @@ -7,7 +7,21 @@ import { fileURLToPath } from "node:url"; import { normalizeEntrypointArgv } from "./commands/base.js"; export { normalizeEntrypointArgv, runForwardedProseCommand, splitHarnessArgs } from "./commands/base.js"; -export { supportedCommands, canonicalPrompt, CommandModelError, usageFor } from "./prose/index.js"; +export { + CommandModelError, + canonicalPrompt, + parseRunCallerInputArgs, + readCallerInterface, + resolveStartupInputs, + supportedCommands, + usageFor, + type CallerInterfaceInput, + type PromptInputLike, + type ResolveStartupInputsOptions, + type ResolveStartupInputsResult, + type StartupInputPromptRequest, + type StartupInputReader, +} from "./prose/index.js"; export { ATTACHED_OPENPROSE_ROOT_PATH, OPENPROSE_JUDGE_SOURCE_PATH, diff --git a/tools/cli/src/prose/index.ts b/tools/cli/src/prose/index.ts index cdd96043..260124b0 100644 --- a/tools/cli/src/prose/index.ts +++ b/tools/cli/src/prose/index.ts @@ -5,6 +5,17 @@ export { usageFor, type CommandName, } from "./command-model.js"; +export { + parseRunCallerInputArgs, + readCallerInterface, + resolveStartupInputs, + type CallerInterfaceInput, + type PromptInputLike, + type ResolveStartupInputsOptions, + type ResolveStartupInputsResult, + type StartupInputPromptRequest, + type StartupInputReader, +} from "./startup-inputs.js"; export { ATTACHED_OPENPROSE_ROOT_PATH, USER_OPENPROSE_ROOT_PATH, diff --git a/tools/cli/src/prose/startup-inputs.ts b/tools/cli/src/prose/startup-inputs.ts new file mode 100644 index 00000000..1f9b471c --- /dev/null +++ b/tools/cli/src/prose/startup-inputs.ts @@ -0,0 +1,269 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { CommandModelError, type CommandName, usageFor } from "./command-model.js"; +import type { WritableStreamLike } from "../harnesses/types.js"; + +export interface PromptInputLike extends AsyncIterable { + readonly isTTY?: boolean; +} + +export interface StartupInputPromptRequest { + readonly name: string; + readonly description?: string; +} + +export type StartupInputReader = (request: StartupInputPromptRequest) => Promise; + +export interface ResolveStartupInputsOptions { + command: CommandName; + args: readonly string[]; + cwd: string; + stderr: WritableStreamLike; + stdin?: PromptInputLike; + inputReader?: StartupInputReader; +} + +export interface ResolveStartupInputsResult { + args: string[]; + collected: string[]; +} + +export interface CallerInterfaceInput { + name: string; + description?: string; +} + +interface ParsedRunArgs { + args: string[]; + noPrompt: boolean; + provided: Set; +} + +export async function resolveStartupInputs(options: ResolveStartupInputsOptions): Promise { + if (options.command !== "run") { + return { args: [...options.args], collected: [] }; + } + + const target = options.args[0]; + if (!isLocalContractMarkdownTarget(target)) { + return { args: [...options.args], collected: [] }; + } + + const callerInterface = await readCallerInterface(resolve(options.cwd, target)); + if (callerInterface.length === 0) { + return { args: [...options.args], collected: [] }; + } + + const parsedArgs = parseRunCallerInputArgs(options.args, callerInterface); + const missing = callerInterface.filter((input) => !parsedArgs.provided.has(input.name)); + if (missing.length === 0) { + return { args: parsedArgs.args, collected: [] }; + } + + if (parsedArgs.noPrompt || options.stdin?.isTTY !== true) { + throw new CommandModelError(missingInputsMessage(missing), usageFor("run")); + } + + const inputReader = options.inputReader ?? createLineInputReader(options.stdin); + const collected: string[] = []; + const completedArgs = [...parsedArgs.args]; + for (const input of missing) { + const value = await promptForInput(input, inputReader, options.stderr); + completedArgs.push(`--${input.name}`, value); + collected.push(input.name); + } + + return { args: completedArgs, collected }; +} + +export async function readCallerInterface(path: string): Promise { + let text: string; + try { + text = await readFile(path, "utf8"); + } catch { + return []; + } + + const inputs: CallerInterfaceInput[] = []; + let inRequires = false; + for (const line of text.split(/\r?\n/)) { + if (/^##\s+/.test(line)) { + break; + } + + if (/^###\s+Requires\s*$/i.test(line)) { + inRequires = true; + continue; + } + + if (/^###\s+/.test(line)) { + inRequires = false; + continue; + } + + if (!inRequires) { + continue; + } + + const match = /^\s*-\s+`([^`]+)`(?:\s*:\s*(.*)|\s+[—-]\s*(.*)|\s*)$/.exec(line); + if (match === null) { + continue; + } + + const name = match[1]?.trim(); + if (!name) { + continue; + } + const description = (match[2] ?? match[3])?.trim(); + inputs.push({ + name, + ...(description === undefined || description.length === 0 ? {} : { description }), + }); + } + + return dedupeInputs(inputs); +} + +export function parseRunCallerInputArgs(args: readonly string[], callerInterface: readonly CallerInterfaceInput[]): ParsedRunArgs { + const inputNames = new Set(callerInterface.map((input) => input.name)); + const parsed: string[] = []; + const provided = new Set(); + let noPrompt = false; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) { + continue; + } + + if (arg === "--") { + parsed.push(...args.slice(index)); + break; + } + + if (index > 0 && arg === "--no-prompt") { + noPrompt = true; + continue; + } + + const equalsMatch = /^--([^=]+)=(.*)$/.exec(arg); + if (index > 0 && equalsMatch !== null && inputNames.has(equalsMatch[1] ?? "")) { + const name = equalsMatch[1] ?? ""; + const value = equalsMatch[2] ?? ""; + if (value.length === 0) { + throw new CommandModelError(`Missing value for --${name}.`, usageFor("run")); + } + provided.add(name); + parsed.push(arg); + continue; + } + + if (index > 0 && arg.startsWith("--")) { + const name = arg.slice(2); + if (inputNames.has(name)) { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new CommandModelError(`Missing value for --${name}.`, usageFor("run")); + } + provided.add(name); + parsed.push(arg, value); + index += 1; + continue; + } + } + + parsed.push(arg); + } + + return { args: parsed, noPrompt, provided }; +} + +function isLocalContractMarkdownTarget(target: string | undefined): target is string { + return target !== undefined && target.endsWith(".prose.md") && !target.includes("://"); +} + +function dedupeInputs(inputs: readonly CallerInterfaceInput[]): CallerInterfaceInput[] { + const seen = new Set(); + const deduped: CallerInterfaceInput[] = []; + for (const input of inputs) { + if (seen.has(input.name)) { + continue; + } + seen.add(input.name); + deduped.push(input); + } + return deduped; +} + +function missingInputsMessage(inputs: readonly CallerInterfaceInput[]): string { + const names = inputs.map((input) => input.name); + const flags = names.map((name) => `--${name}`).join(", "); + return `Missing required caller inputs: ${names.join(", ")}.\nProvide them with ${flags}, or run in an interactive terminal.`; +} + +async function promptForInput( + input: CallerInterfaceInput, + inputReader: StartupInputReader, + stderr: WritableStreamLike, +): Promise { + for (;;) { + stderr.write(`${input.name}: `); + const rawValue = await inputReader(input); + if (rawValue === undefined) { + throw new CommandModelError(`No value provided for --${input.name}.`, usageFor("run")); + } + const value = rawValue.trim(); + if (value.length > 0) { + return value; + } + stderr.write("Value required.\n"); + } +} + +function createLineInputReader(stdin: PromptInputLike): StartupInputReader { + const reader = new LineInputReader(stdin); + return () => reader.readLine(); +} + +class LineInputReader { + private readonly iterator: AsyncIterator; + private bufferedLines: string[] = []; + private pending = ""; + private ended = false; + + constructor(input: AsyncIterable) { + this.iterator = input[Symbol.asyncIterator](); + } + + async readLine(): Promise { + for (;;) { + const buffered = this.bufferedLines.shift(); + if (buffered !== undefined) { + return buffered; + } + + if (this.ended) { + if (this.pending.length === 0) { + return undefined; + } + const pending = this.pending; + this.pending = ""; + return pending; + } + + const next = await this.iterator.next(); + if (next.done === true) { + this.ended = true; + continue; + } + this.appendChunk(next.value); + } + } + + private appendChunk(chunk: string | Uint8Array): void { + const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + const parts = `${this.pending}${text}`.split(/\r?\n/); + this.pending = parts.pop() ?? ""; + this.bufferedLines.push(...parts); + } +} diff --git a/tools/cli/tests/cli/cli.test.ts b/tools/cli/tests/cli/cli.test.ts index 5a5b886c..f65e4cac 100644 --- a/tools/cli/tests/cli/cli.test.ts +++ b/tools/cli/tests/cli/cli.test.ts @@ -6,8 +6,10 @@ import { describe, expect, it } from "vitest"; import { isDirectEntrypoint, normalizeEntrypointArgv, + readCallerInterface, runForwardedProseCommand, splitHarnessArgs, + type PromptInputLike, } from "../../src/index.js"; import commands from "../../src/commands/index.js"; import { runCompileCommand } from "../../src/commands/compile.js"; @@ -40,6 +42,45 @@ function writeManifestWithErrorDiagnostic(path: string): void { writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`); } +function fakeStdin(isTTY: boolean): PromptInputLike { + return { + isTTY, + async *[Symbol.asyncIterator]() { + return; + }, + }; +} + +function writePromptableService(path: string): void { + writeFileSync( + path, + `--- +name: demo +kind: service +--- + +# Demo + +### Requires + +- \`project\`: project name +- \`audience\`: who this is for +- free-form note ignored by startup prompting +- \`constraint\`: main constraint + +### Ensures + +- \`brief\`: short result + +## worker + +### Requires + +- \`internal\`: not a caller input for startup prompting +`, + ); +} + describe("Oclif entrypoint helpers", () => { it("registers serve as a local runtime command", () => { expect(commands.serve).toBeDefined(); @@ -109,6 +150,25 @@ describe("harness argument splitting", () => { }); }); +describe("caller interface startup input parsing", () => { + it("reads top-level backtick Requires entries from local Contract Markdown", async () => { + const temp = mkdtempSync(join(tmpdir(), "prose-startup-inputs-")); + + try { + const path = join(temp, "demo.prose.md"); + writePromptableService(path); + + await expect(readCallerInterface(path)).resolves.toEqual([ + { name: "project", description: "project name" }, + { name: "audience", description: "who this is for" }, + { name: "constraint", description: "main constraint" }, + ]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); +}); + describe("runForwardedProseCommand", () => { it("builds the canonical prompt and streams through the selected harness", async () => { const io = memoryStreams(); @@ -164,6 +224,190 @@ describe("runForwardedProseCommand", () => { expect(seen).toEqual(["prose compile . --out dist"]); }); + it("prompts for missing startup caller inputs before harness invocation", async () => { + const temp = mkdtempSync(join(tmpdir(), "prose-startup-prompt-")); + const io = memoryStreams(); + const seen: string[] = []; + const answers = new Map([ + ["project", "OpenProse CLI"], + ["constraint", "keep it deterministic"], + ]); + + try { + writePromptableService(join(temp, "demo.prose.md")); + + const exitCode = await runForwardedProseCommand({ + command: "run", + argv: ["demo.prose.md", "--audience", "contributors", "--harness", "mock"], + cwd: temp, + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + stdin: fakeStdin(true), + startupInputReader: async ({ name }) => answers.get(name), + harnessFactory: () => ({ + name: "mock", + async run(prompt) { + seen.push(prompt); + return 0; + }, + }), + }); + + expect(exitCode).toBe(0); + expect(io.stderr).toContain("project: "); + expect(io.stderr).toContain("constraint: "); + expect(io.stderr).not.toContain("OpenProse CLI"); + expect(seen).toEqual([ + "prose run demo.prose.md --audience contributors --project 'OpenProse CLI' --constraint 'keep it deterministic'", + ]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("forwards supplied startup caller inputs without prompting", async () => { + const temp = mkdtempSync(join(tmpdir(), "prose-startup-supplied-")); + const io = memoryStreams(); + const seen: string[] = []; + + try { + writePromptableService(join(temp, "demo.prose.md")); + + const exitCode = await runForwardedProseCommand({ + command: "run", + argv: [ + "demo.prose.md", + "--project=OpenProse", + "--audience", + "contributors", + "--constraint=small", + "--no-prompt", + "--harness", + "mock", + ], + cwd: temp, + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + stdin: fakeStdin(true), + startupInputReader: async () => { + throw new Error("should not prompt"); + }, + harnessFactory: () => ({ + name: "mock", + async run(prompt) { + seen.push(prompt); + return 0; + }, + }), + }); + + expect(exitCode).toBe(0); + expect(io.stderr).toBe(""); + expect(seen).toEqual([ + "prose run demo.prose.md --project=OpenProse --audience contributors --constraint=small", + ]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("fails before harness invocation when startup caller inputs are missing in non-TTY", async () => { + const temp = mkdtempSync(join(tmpdir(), "prose-startup-non-tty-")); + const io = memoryStreams(); + let harnessCalled = false; + + try { + writePromptableService(join(temp, "demo.prose.md")); + + await expect( + runForwardedProseCommand({ + command: "run", + argv: ["demo.prose.md", "--harness", "mock"], + cwd: temp, + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + stdin: fakeStdin(false), + harnessFactory: () => ({ + name: "mock", + async run() { + harnessCalled = true; + return 0; + }, + }), + }), + ).rejects.toThrow("Missing required caller inputs: project, audience, constraint"); + + expect(harnessCalled).toBe(false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("treats no-prompt as an escape hatch for promptable runs", async () => { + const temp = mkdtempSync(join(tmpdir(), "prose-startup-no-prompt-")); + const io = memoryStreams(); + let harnessCalled = false; + + try { + writePromptableService(join(temp, "demo.prose.md")); + + await expect( + runForwardedProseCommand({ + command: "run", + argv: ["demo.prose.md", "--no-prompt", "--harness", "mock"], + cwd: temp, + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + stdin: fakeStdin(true), + startupInputReader: async () => "unused", + harnessFactory: () => ({ + name: "mock", + async run() { + harnessCalled = true; + return 0; + }, + }), + }), + ).rejects.toThrow("Missing required caller inputs: project, audience, constraint"); + + expect(harnessCalled).toBe(false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("preserves non-local handles and literal args after the separator", async () => { + const io = memoryStreams(); + const seen: string[] = []; + + const exitCode = await runForwardedProseCommand({ + command: "run", + argv: ["std/demo", "--", "--harness", "literal"], + cwd: "/repo", + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + stdin: fakeStdin(false), + startupInputReader: async () => { + throw new Error("should not prompt"); + }, + harnessFactory: () => ({ + name: "mock", + async run(prompt) { + seen.push(prompt); + return 0; + }, + }), + }); + + expect(exitCode).toBe(0); + expect(seen).toEqual(["prose run std/demo -- --harness literal"]); + }); + it("loads OpenProse skill bootstrap after preflight and passes it to the harness", async () => { const home = mkdtempSync(join(tmpdir(), "prose-home-")); const cwd = mkdtempSync(join(tmpdir(), "prose-cwd-")); From f19804e2cc7ab3361727e112bc6a98c51b3e1772 Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp Date: Wed, 6 May 2026 15:17:41 -0400 Subject: [PATCH 2/2] fix(ci): add deterministic PR preflight --- .github/workflows/cli-release-check.yml | 8 +- .github/workflows/release.yml | 2 +- scripts/pr-preflight.sh | 10 ++ tools/cli/audit-policy.json | 12 +++ tools/cli/package.json | 2 + tools/cli/scripts/audit-policy.mjs | 102 +++++++++++++++++++ tools/cli/src/prose/openprose-root.ts | 23 ++++- tools/cli/tests/prose/openprose-root.test.ts | 17 ++++ 8 files changed, 173 insertions(+), 3 deletions(-) create mode 100755 scripts/pr-preflight.sh create mode 100644 tools/cli/audit-policy.json create mode 100644 tools/cli/scripts/audit-policy.mjs diff --git a/.github/workflows/cli-release-check.yml b/.github/workflows/cli-release-check.yml index 56f0c479..189623a0 100644 --- a/.github/workflows/cli-release-check.yml +++ b/.github/workflows/cli-release-check.yml @@ -11,6 +11,7 @@ on: - ".github/workflows/release.yml" - ".github/scripts/openprose-smoke/**" - "scripts/release-preflight.sh" + - "scripts/pr-preflight.sh" - "scripts/bump-version.sh" - ".version-bump.json" push: @@ -25,6 +26,7 @@ on: - ".github/workflows/release.yml" - ".github/scripts/openprose-smoke/**" - "scripts/release-preflight.sh" + - "scripts/pr-preflight.sh" - "scripts/bump-version.sh" - ".version-bump.json" workflow_dispatch: @@ -66,9 +68,13 @@ jobs: - name: Check harness smoke helper syntax run: node --check tools/cli/scripts/smoke-harness.mjs + - name: Check audit policy helper syntax + run: node --check tools/cli/scripts/audit-policy.mjs + - name: Check release shell syntax run: | bash -n scripts/bump-version.sh + bash -n scripts/pr-preflight.sh bash -n scripts/release-preflight.sh - name: OpenProse versions in sync @@ -283,4 +289,4 @@ jobs: run: npm ci - name: Audit production dependencies - run: npm audit --omit=dev + run: npm run audit:policy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ded8a67c..593b4d3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,7 +123,7 @@ jobs: run: npm run build - name: Audit production dependencies - run: npm audit --omit=dev + run: npm run audit:policy - name: Dry-run npm publish run: npm publish --dry-run diff --git a/scripts/pr-preflight.sh b/scripts/pr-preflight.sh new file mode 100755 index 00000000..1bb60962 --- /dev/null +++ b/scripts/pr-preflight.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$repo_root" +git diff --check + +cd "$repo_root/tools/cli" +npm run ci:pr diff --git a/tools/cli/audit-policy.json b/tools/cli/audit-policy.json new file mode 100644 index 00000000..b643ee4f --- /dev/null +++ b/tools/cli/audit-policy.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "allowedAdvisories": [ + { + "id": "GHSA-v2v4-37r5-5v8g", + "package": "ip-address", + "severity": "moderate", + "reason": "Transitive dependency through @anthropic-ai/claude-agent-sdk/@modelcontextprotocol/sdk. Keep PR CI deterministic while upstream publishes a non-breaking fix.", + "expires": "2026-06-30" + } + ] +} diff --git a/tools/cli/package.json b/tools/cli/package.json index efde4a48..cb4e292b 100644 --- a/tools/cli/package.json +++ b/tools/cli/package.json @@ -51,6 +51,8 @@ "build": "npm run clean && tsc -p tsconfig.build.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest --run", + "audit:policy": "node scripts/audit-policy.mjs", + "ci:pr": "npm test && npm run typecheck && npm run build && npm run audit:policy", "smoke:harness": "node scripts/smoke-harness.mjs", "dev": "tsx src/index.ts", "clean": "rm -rf dist", diff --git a/tools/cli/scripts/audit-policy.mjs b/tools/cli/scripts/audit-policy.mjs new file mode 100644 index 00000000..5055a78d --- /dev/null +++ b/tools/cli/scripts/audit-policy.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const cliDir = dirname(scriptDir); +const policyPath = join(cliDir, "audit-policy.json"); +const policy = JSON.parse(readFileSync(policyPath, "utf8")); + +const audit = spawnSync("npm", ["audit", "--omit=dev", "--json"], { + cwd: cliDir, + encoding: "utf8", +}); + +if (audit.error) { + throw audit.error; +} + +let report; +try { + report = JSON.parse(audit.stdout); +} catch (error) { + process.stderr.write(audit.stderr); + process.stderr.write(audit.stdout); + throw new Error(`Failed to parse npm audit JSON: ${error.message}`); +} + +const today = new Date().toISOString().slice(0, 10); +const allowances = new Map( + (policy.allowedAdvisories ?? []).map((entry) => [`${entry.package}:${entry.id}`, entry]), +); + +const findings = extractFindings(report); +const failures = []; +const allowed = []; + +for (const finding of findings) { + const allowance = allowances.get(`${finding.packageName}:${finding.id}`); + if (!allowance) { + failures.push(`${finding.packageName} ${finding.id} ${finding.severity}: ${finding.title}`); + continue; + } + if (allowance.severity !== finding.severity) { + failures.push( + `${finding.packageName} ${finding.id} severity changed from ${allowance.severity} to ${finding.severity}`, + ); + continue; + } + if (allowance.expires < today) { + failures.push(`${finding.packageName} ${finding.id} allowance expired on ${allowance.expires}`); + continue; + } + allowed.push(`${finding.packageName} ${finding.id} allowed until ${allowance.expires}`); +} + +if (failures.length > 0) { + process.stderr.write("Production dependency audit failed policy:\n"); + for (const failure of failures) { + process.stderr.write(`- ${failure}\n`); + } + process.exitCode = 1; +} else { + const count = findings.length; + process.stdout.write(`Production dependency audit passed policy (${count} advisory finding${count === 1 ? "" : "s"}).\n`); + for (const entry of allowed) { + process.stdout.write(`- ${entry}\n`); + } +} + +function extractFindings(report) { + const findings = []; + const seen = new Set(); + + for (const vulnerability of Object.values(report.vulnerabilities ?? {})) { + for (const via of vulnerability.via ?? []) { + if (!via || typeof via !== "object") continue; + const id = advisoryId(via); + const packageName = via.name ?? vulnerability.name; + const key = `${packageName}:${id}`; + if (seen.has(key)) continue; + seen.add(key); + findings.push({ + id, + packageName, + severity: via.severity ?? vulnerability.severity, + title: via.title ?? "(no title)", + }); + } + } + + return findings.sort((left, right) => `${left.packageName}:${left.id}`.localeCompare(`${right.packageName}:${right.id}`)); +} + +function advisoryId(via) { + if (typeof via.url === "string") { + const match = via.url.match(/GHSA-[A-Za-z0-9-]+/); + if (match) return match[0]; + } + return String(via.source ?? via.title ?? "unknown-advisory"); +} diff --git a/tools/cli/src/prose/openprose-root.ts b/tools/cli/src/prose/openprose-root.ts index 290da69f..389496fe 100644 --- a/tools/cli/src/prose/openprose-root.ts +++ b/tools/cli/src/prose/openprose-root.ts @@ -1,9 +1,10 @@ import { stat } from "node:fs/promises"; -import { homedir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { dirname, relative, resolve, sep } from "node:path"; export const ATTACHED_OPENPROSE_ROOT_PATH = ".agents/prose"; export const USER_OPENPROSE_ROOT_PATH = "~/.agents/prose"; +const TEMP_ROOTS = uniquePaths([tmpdir(), "/tmp", "/private/tmp"]); export type OpenProseRootMode = "native" | "attached" | "user"; @@ -117,10 +118,14 @@ async function findEnclosingAttachedRoot(cwd: string): Promise { + const tempBoundary = findTempWorkspaceBoundary(cwd); for (let current = resolve(cwd); ; current = dirname(current)) { if ((await pathExists(resolve(current, "prose.lock"))) || (await pathExists(resolve(current, ".git")))) { return current; } + if (tempBoundary !== undefined && isSamePath(current, tempBoundary)) { + return undefined; + } if (dirname(current) === current) { return undefined; } @@ -135,3 +140,19 @@ function isSameOrInside(path: string, parent: string): boolean { function isSamePath(left: string, right: string): boolean { return resolve(left) === resolve(right); } + +function findTempWorkspaceBoundary(cwd: string): string | undefined { + const absoluteCwd = resolve(cwd); + for (const tempRoot of TEMP_ROOTS) { + if (!isSameOrInside(absoluteCwd, tempRoot)) { + continue; + } + const [firstSegment] = relative(tempRoot, absoluteCwd).split(sep).filter(Boolean); + return firstSegment === undefined ? tempRoot : resolve(tempRoot, firstSegment); + } + return undefined; +} + +function uniquePaths(paths: string[]): string[] { + return [...new Set(paths.map((path) => resolve(path)))]; +} diff --git a/tools/cli/tests/prose/openprose-root.test.ts b/tools/cli/tests/prose/openprose-root.test.ts index d9eddfaf..d4cbc6c4 100644 --- a/tools/cli/tests/prose/openprose-root.test.ts +++ b/tools/cli/tests/prose/openprose-root.test.ts @@ -56,6 +56,23 @@ describe("OpenProse root resolution", () => { } }); + it("does not escape a temporary workspace to an ambient parent repository marker", async () => { + const temp = mkdtempSync(join(tmpdir(), "prose-root-ambient-parent-")); + const sourceDir = join(temp, "src"); + + try { + mkdirSync(sourceDir, { recursive: true }); + + await expect(resolveOpenProseRoot({ cwd: sourceDir })).resolves.toEqual({ + mode: "native", + path: ".", + absolutePath: resolve(sourceDir), + }); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + it("uses an attached root when the cwd contains one", async () => { const temp = mkdtempSync(join(tmpdir(), "prose-root-attached-")); const attached = join(temp, ".agents/prose");