import { readFileSync } from "node:fs"; import { parseArgs } from "node:util"; import type { DiscoveryOptions, TurnOptions } from "../interpretation/argv.js"; import { parseEnvEntries } from "../interpretation/environment.js"; import { isQuestionMode, QUESTION_MODES } from "../interpretation/question.js"; import { ArgvRefusalError } from "../interpretation/refusal.js"; export type ParsedPromptSource = | { kind: "positional"; prompt: string } | { kind: "prompt-flag"; prompt: string } | { kind: "prompt-file"; prompt: string } | { kind: "none" }; export interface CliTurnOptions extends TurnOptions { // TurnOptions already has prompt, model, effort, etc. } export interface CliRunOptions extends CliTurnOptions { readonly cwd?: string; readonly env?: Record; readonly resume?: string; readonly json?: boolean; } export interface ParseCommonResult { prompt: string | undefined; promptSource: ParsedPromptSource["kind"]; turnOptions: TurnOptions; runOptions: { cwd?: string; env?: Record; resume?: string }; } const _DISCOVERY_FLAGS = { tools: "no-tools", instructionFiles: "no-instruction-files", extensions: "no-extensions", skills: "no-skills", } as const; export { parseEnvEntries } from "../interpretation/environment.js"; export const resolvePrompt = (args: { positionalPrompt?: string; promptFlag?: string; promptFile?: string; }): { prompt: string; source: ParsedPromptSource["kind"] } => { const hasPositional = args.positionalPrompt !== undefined && args.positionalPrompt !== ""; const hasPrompt = args.promptFlag !== undefined; const hasFile = args.promptFile !== undefined; const count = Number(hasPositional) + Number(hasPrompt) + Number(hasFile); if (count === 0) { throw new ArgvRefusalError({ issue: "invalid-option-value", supported: ["provide prompt via positional, --prompt, or --prompt-file"], detail: "missing prompt", }); } if (count > 1) { throw new ArgvRefusalError({ issue: "invalid-option-value", supported: ["use one of positional, --prompt, or --prompt-file"], detail: "mutual exclusion", }); } const positional = args.positionalPrompt; if (positional !== undefined && positional !== "") return { prompt: positional, source: "positional" }; const flag = args.promptFlag; if (flag !== undefined) return { prompt: flag, source: "prompt-flag" }; // --prompt-file const file = args.promptFile; if (file === undefined) throw new Error("prompt source selected but no prompt file present"); if (file === "-") { // read from stdin sync - caller may provide content differently for async path. // For sync path, read stdin fd 0. const content = readFileSync(0, "utf8"); return { prompt: content, source: "prompt-file" }; } const content = readFileSync(file, "utf8"); return { prompt: content, source: "prompt-file" }; }; export const resolvePromptAsync = async (args: { positionalPrompt?: string; promptFlag?: string; promptFile?: string; }): Promise<{ prompt: string; source: ParsedPromptSource["kind"] }> => { const hasPositional = args.positionalPrompt !== undefined && args.positionalPrompt !== ""; const hasPrompt = args.promptFlag !== undefined; const hasFile = args.promptFile !== undefined; const count = Number(hasPositional) + Number(hasPrompt) + Number(hasFile); if (count === 0) { throw new ArgvRefusalError({ issue: "invalid-option-value", supported: ["provide prompt via positional, --prompt, or --prompt-file"], detail: "missing prompt", }); } if (count > 1) { throw new ArgvRefusalError({ issue: "invalid-option-value", supported: ["use one of positional, --prompt, or --prompt-file"], detail: "mutual exclusion", }); } const positionalAsync = args.positionalPrompt; if (positionalAsync !== undefined && positionalAsync !== "") return { prompt: positionalAsync, source: "positional" }; const flagAsync = args.promptFlag; if (flagAsync !== undefined) return { prompt: flagAsync, source: "prompt-flag" }; const file = args.promptFile; if (file === undefined) throw new Error("prompt source selected but no prompt file present"); if (file === "-") { const chunks: Buffer[] = []; for await (const chunk of process.stdin) chunks.push(chunk as Buffer); return { prompt: Buffer.concat(chunks).toString("utf8"), source: "prompt-file" }; } const { readFile } = await import("node:fs/promises"); const content = await readFile(file, "utf8"); return { prompt: content, source: "prompt-file" }; }; /** Parsed turn options plus the skill names as typed, which the command * resolves against the registry before they become a turn option. */ export type ParsedTurnOptions = TurnOptions & { readonly skillNames?: readonly string[] }; /** * Build TurnOptions from parsed flag values. Shared between run and inspect --argv. */ export const parseTurnOptions = (values: Record): ParsedTurnOptions => { const opts: Record = {}; if (values.isolation !== undefined) opts.isolation = values.isolation; if (values.model !== undefined) opts.model = values.model; if (values.effort !== undefined) opts.effort = values.effort; if (values.sandbox !== undefined) opts.sandbox = values.sandbox; if (values["context-window"] !== undefined) opts.contextWindow = Number(values["context-window"]); if (values.provider !== undefined) opts.provider = values.provider; if (values.agent !== undefined) opts.agent = values.agent; if (values.tools !== undefined) { const raw = String(values.tools); opts.tools = raw.length === 0 ? [] : raw.split(",").map((s) => s.trim()); } if (values["exclude-tools"] !== undefined) { const raw = String(values["exclude-tools"]); opts.excludeTools = raw.length === 0 ? [] : raw.split(",").map((s) => s.trim()); } if (values.skills !== undefined) { // Names as typed; the command resolves them against the registry and // builds the skills turn option (picks plus known names) from them. const raw = String(values.skills); opts.skillNames = raw.length === 0 ? [] : raw.split(",").map((s) => s.trim()); } if (values.autonomy === true) opts.autonomy = true; else if (values["no-autonomy"] === true) opts.autonomy = false; else if (values.autonomy === false) opts.autonomy = false; // for completeness if (values.write === true) opts.write = true; else if (values["no-write"] === true) opts.write = false; if (values.shell === true) opts.shell = true; else if (values["no-shell"] === true) opts.shell = false; const memory = memoryFlagOf(values); if (memory !== undefined) opts.memory = memory; if (values.questions !== undefined) { const v = String(values.questions); if (!isQuestionMode(v)) { throw new ArgvRefusalError({ issue: "invalid-option-value", option: "questions", supported: [...QUESTION_MODES], detail: v, }); } opts.questions = v; } if (values["system-prompt"] !== undefined) opts.systemPrompt = String(values["system-prompt"]); if (values["append-system-prompt"] !== undefined) opts.appendSystemPrompt = String(values["append-system-prompt"]); if (values.extension !== undefined) opts.extensions = (values.extension as readonly unknown[]).map(String); if (values["max-steps"] !== undefined) { const n = Number(values["max-steps"]); if (!Number.isFinite(n)) { throw new ArgvRefusalError({ issue: "invalid-option-value", option: "maxSteps", supported: ["integer 1-10000"], detail: String(values["max-steps"]), }); } opts.maxSteps = n; } // discovery facets: --no-* means false, otherwise undefined (no-op) const discovery: Record = {}; let hasDiscovery = false; if (values["no-tools"] === true) { discovery.tools = false; hasDiscovery = true; } if (values["no-instruction-files"] === true) { discovery.instructionFiles = false; hasDiscovery = true; } if (values["no-extensions"] === true) { discovery.extensions = false; hasDiscovery = true; } if (values["no-skills"] === true) { discovery.skills = false; hasDiscovery = true; } if (hasDiscovery) (opts as Record).discovery = discovery as DiscoveryOptions; if (values.access !== undefined) opts.access = String(values.access); // prompt will be set by caller after resolvePrompt return opts as unknown as ParsedTurnOptions; }; /** `--resume` and `--session-id` are aliases for one session id; both at * once refuses. `--resume-last` is most-recent resolution without an id: * with either alias it refuses too (two answers to one question). The one * check every command calls (run, inspect, session). */ export const resumeIdOf = (values: Record): string | undefined => { if (values.resume !== undefined && values["session-id"] !== undefined) { throw new ArgvRefusalError({ issue: "mutually-exclusive-options", supported: ["--resume or --session-id, not both (--session-id is an alias for --resume)"], detail: "both --resume and --session-id given", }); } if (values["resume-last"] === true && values.resume !== undefined) { throw new ArgvRefusalError({ issue: "mutually-exclusive-options", supported: [ "--resume or --resume-last, not both (most-recent and a named id are two answers to one question)", ], detail: "both --resume and --resume-last given", }); } if (values["resume-last"] === true && values["session-id"] !== undefined) { throw new ArgvRefusalError({ issue: "mutually-exclusive-options", supported: [ "--session-id or --resume-last, not both (most-recent and a named id are two answers to one question)", ], detail: "both --session-id and --resume-last given", }); } if (values.resume !== undefined) return String(values.resume); if (values["session-id"] !== undefined) return String(values["session-id"]); return undefined; }; export const parseRunExtra = ( values: Record, ): { cwd?: string; env?: Record; resume?: string; resumeLast?: true; timeoutSeconds?: number; } => { const extra: { cwd?: string; env?: Record; resume?: string; resumeLast?: true; timeoutSeconds?: number; } = {}; if (values.timeout !== undefined) { const n = Number(values.timeout); if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) { throw new ArgvRefusalError({ issue: "invalid-option-value", option: "timeout", supported: ["whole seconds, >= 0 (0 disables)"], detail: String(values.timeout), }); } extra.timeoutSeconds = n; } if (values.cwd !== undefined) extra.cwd = String(values.cwd); const resume = resumeIdOf(values); if (resume !== undefined) extra.resume = resume; // --resume-last is most-recent resolution without an id (RFC-06); the // plan treats it as resume-semantics everywhere resume applies. if (values["resume-last"] === true) extra.resumeLast = true; if (values.env !== undefined) { // parseArgs with multiple:true gives string[] ; else string const list = values.env as string | string[]; extra.env = parseEnvEntries(list); } return extra; }; export interface CommonParseConfig { allowPrompt: boolean; allowPromptFile: boolean; } const KNOWN_FLAGS = new Set([ "--prompt", "--prompt-file", "--model", "--effort", "--sandbox", "--context-window", "--provider", "--agent", "--tools", "--exclude-tools", "--access", "--autonomy", "--no-autonomy", "--write", "--no-write", "--shell", "--no-shell", "--memory", "--no-memory", "--questions", "--system-prompt", "--append-system-prompt", "--extension", "--max-steps", "--isolation", "--no-tools", "--no-instruction-files", "--no-extensions", "--no-skills", "--cwd", "--env", "--resume", "--session-id", "--resume-last", "--json", "--argv", "--runtime", "--context", "--models", "--native-settings-fingerprint", "--capabilities", "--mode", "--stall", "--help", "-h", "--version", "-V", ]); const FLAGS_WITH_VALUE = new Set([ "--native-settings-fingerprint", "--extension", "--isolation", "--prompt", "--prompt-file", "--model", "--effort", "--sandbox", "--context-window", "--provider", "--agent", "--tools", "--exclude-tools", "--access", "--max-steps", "--cwd", "--env", "--resume", "--session-id", "--timeout", "--mode", "--stall", "--questions", ]); export const detectPositionalPromptInjection = (argv: string[]): string | null => { const hasExplicitPrompt = argv.some( (a) => a === "--prompt" || a.startsWith("--prompt=") || a === "--prompt-file" || a.startsWith("--prompt-file="), ); if (hasExplicitPrompt) return null; let expectValue = false; for (const token of argv) { if (expectValue) { expectValue = false; continue; } if (FLAGS_WITH_VALUE.has(token)) { expectValue = true; continue; } if (token.startsWith("--") && token.includes("=")) { continue; } if (KNOWN_FLAGS.has(token)) { continue; } // Everything after `--` is native passthrough, never the prompt. if (token === "--") { break; } // Only single-dash tokens are considered prompt injection candidates // Double-dash unknown flags (e.g. --unknown) are handled as unknown flag errors by parseArgs if (token.startsWith("-") && !token.startsWith("--")) { return token; } } return null; }; const preprocessPromptArgs = (argv: string[]): string[] => { const out: string[] = []; for (let i = 0; i < argv.length; i++) { const token = argv[i] as string; if (token === "--prompt" && i + 1 < argv.length) { const next = argv[i + 1] as string; // If next starts with '-' and isn't '--', convert to --prompt=-value form so parseArgs accepts it if (next.startsWith("-") && next !== "--" && !next.startsWith("--prompt")) { out.push(`--prompt=${next}`); i++; continue; } } if (token.startsWith("--prompt=")) { out.push(token); continue; } out.push(token); } return out; }; /** * Wrapper around node:util parseArgs for the common flag table. * Throws with exit code 2 on unknown flag. */ export interface SplitPassthrough { /** Tokens before the separator - hcn's normalized surface. */ readonly normalized: string[]; /** Tokens after the separator - verbatim passthrough to the harness, * empty when no separator was present. */ readonly passthrough: readonly string[]; } /** D6: split argv at the first bare `--`. Everything after it belongs to * the harness, not to hcn - wrong-harness flags there fail in the harness * itself and surface as native errors, never as hcn refusals. */ export const splitPassthrough = (argv: readonly string[]): SplitPassthrough => { const idx = argv.indexOf("--"); if (idx === -1) return { normalized: [...argv], passthrough: [] }; return { normalized: argv.slice(0, idx), passthrough: argv.slice(idx + 1), }; }; export const memoryFlagOf = (values: Record): boolean | undefined => { if (values.memory === true && values["no-memory"] === true) { throw new ArgvRefusalError({ issue: "mutually-exclusive-options", message: "--memory and --no-memory cannot be combined", option: "memory", supported: ["--memory or --no-memory"], }); } return values.memory === true ? true : values["no-memory"] === true ? false : undefined; }; export const parseCommonFlags = ( argv: string[], opts: { strict?: boolean; nativeSettings?: boolean; nativeSettingsFingerprint?: boolean; nativeApprovals?: boolean; } = {}, ): ReturnType => { // The separator itself never reaches parseArgs: passthrough tokens may // be unknown to hcn by design (that is their purpose). const { normalized: withoutPassthrough } = splitPassthrough(argv); const normalized = preprocessPromptArgs(withoutPassthrough); const config = { allowPositionals: true, strict: opts.strict ?? true, options: { prompt: { type: "string" as const }, "prompt-file": { type: "string" as const }, model: { type: "string" as const }, effort: { type: "string" as const }, sandbox: { type: "string" as const }, "context-window": { type: "string" as const }, provider: { type: "string" as const }, agent: { type: "string" as const }, tools: { type: "string" as const }, "exclude-tools": { type: "string" as const }, skills: { type: "string" as const }, autonomy: { type: "boolean" as const }, "no-autonomy": { type: "boolean" as const }, write: { type: "boolean" as const }, "no-write": { type: "boolean" as const }, shell: { type: "boolean" as const }, "no-shell": { type: "boolean" as const }, memory: { type: "boolean" as const }, "no-memory": { type: "boolean" as const }, questions: { type: "string" as const }, "system-prompt": { type: "string" as const }, "append-system-prompt": { type: "string" as const }, extension: { type: "string" as const, multiple: true }, "max-steps": { type: "string" as const }, isolation: { type: "string" as const }, access: { type: "string" as const }, "no-tools": { type: "boolean" as const }, "no-instruction-files": { type: "boolean" as const }, "no-extensions": { type: "boolean" as const }, "no-skills": { type: "boolean" as const }, cwd: { type: "string" as const }, env: { type: "string" as const, multiple: true }, resume: { type: "string" as const }, "session-id": { type: "string" as const }, "resume-last": { type: "boolean" as const }, timeout: { type: "string" as const }, json: { type: "boolean" as const }, argv: { type: "boolean" as const }, capabilities: { type: "boolean" as const }, runtime: { type: "boolean" as const }, context: { type: "boolean" as const }, models: { type: "boolean" as const }, ...(opts.nativeSettings ? { "native-settings": { type: "boolean" as const } } : {}), ...(opts.nativeApprovals ? { "native-approvals": { type: "boolean" as const } } : {}), ...(opts.nativeSettingsFingerprint ? { "native-settings-fingerprint": { type: "string" as const } } : {}), mode: { type: "string" as const }, stall: { type: "string" as const }, help: { type: "boolean" as const }, version: { type: "boolean" as const }, }, } as const; // parseArgs throws on unknown flag when strict true - we let it bubble and caller maps to exit 2 const parsed = parseArgs({ ...config, args: normalized }); memoryFlagOf(parsed.values); return parsed; };