From f2ea30fea3492094082377120e46baf9c5ea5ebc Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Fri, 9 Oct 2026 11:23:33 -0400 Subject: [PATCH] feat: enforce host read-only planning capability --- README.md | 7 ++ docs/generated/commands.md | 6 +- src/commands/chat.ts | 23 ++++-- src/commands/code.ts | 55 ++++++++++---- src/commands/command_manifest_data.ts | 4 +- src/commands/console_input.ts | 3 +- src/commands/prompt_modes.ts | 7 +- src/core/brain_protocol.ts | 3 + src/core/run_capability.ts | 23 ++++++ src/core/session_log.ts | 3 + src/core/skills/run_session.ts | 24 ++++-- src/core/skills/skill_errors.ts | 1 + src/main.ts | 13 ++++ test/code_turn_lifecycle.test.ts | 13 ++++ test/planning_command.test.ts | 88 ++++++++++++++++++++++ test/prompt_modes.test.ts | 6 +- test/skill_host_loop.test.ts | 104 ++++++++++++++++++++++++++ 17 files changed, 348 insertions(+), 35 deletions(-) create mode 100644 src/core/run_capability.ts create mode 100644 test/planning_command.test.ts diff --git a/README.md b/README.md index ba8ec8c0..8095f37c 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,13 @@ For a multiline task, save the specification in `task.md` and run coding task; see [prompt input rules](COMMANDS.md#aether-code-task--autonomous-coding-agent) for the size limit and flag conflicts. +To inspect the current checkout and receive a plan without changing it, run +`aether agent --planning "outline the migration"`, or use `/plan ` in a +local interactive session. Planning permits only file reading, directory +listing, and repository search; it skips worktree creation and verification. +Save a plan or execute a phase later through the explicit goal controls. +The cloud chat route refuses `/plan` because its tools execute on the server. + A completed check records its exit code. Changing the repository makes that verification stale until you run it again. The npm CLI is published as **v4.20.0**. Run `aether --version` to check the diff --git a/docs/generated/commands.md b/docs/generated/commands.md index 75825432..38b78d21 100644 --- a/docs/generated/commands.md +++ b/docs/generated/commands.md @@ -1,5 +1,5 @@ - + # Generated command reference This reference is generated from the validated, versioned command manifest. Availability is evaluated at runtime; a listed command may still require authentication, a hosted capability, or local tooling. @@ -24,6 +24,10 @@ run the coding agent or manage account agents and shared conversations Permission: `local-write` · Availability: `runtime-dependent` · Telemetry: `shell.agent` · Aliases: `aether code` · Requires: `aether.hosted-or-local` +Command flags: + +- `--planning` + #### `aether chat [prompt]` start chat or send one prompt diff --git a/src/commands/chat.ts b/src/commands/chat.ts index f599a2f3..e1c4cbe2 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -78,6 +78,7 @@ import { type ToolFailureOrigin, } from "../core/tool_failure_budget.js"; import type { SkillRefusal } from "../core/skills/skill_errors.js"; +import { refuseRunCapability, type RunCapability } from "../core/run_capability.js"; import { renderHud, timerLive } from "../core/hud.js"; import { createViewerState, @@ -92,7 +93,7 @@ import { } from "../ui/workflow_viewer.js"; import type { WorkflowViewerState } from "../ui/workflow_viewer.js"; import type { StreamFrame } from "../core/stream.js"; -import type { BrainEvent } from "../core/brain_protocol.js"; +import type { BrainEvent, ToolName } from "../core/brain_protocol.js"; import { ConsoleTaskContinuation, accountFingerprint, consoleWorkspaceState, observedWorkspaceChanges, type ModelTarget, type ObservedTool } from "./model_continuation.js"; // Key decoding lives in ui/keys.ts (shared with pickers/viewers); re-exported @@ -112,6 +113,7 @@ interface ChatJsonResponse { * failure to the one-shot `cmdChat` path. */ /** Session-level skill selection for REPL/one-shot chat turns (`--skill`, `--no-skills`). */ export interface TurnSkillOptions { + capability?: RunCapability; explicitSkill?: string; noSkills?: boolean; /** Local console authority, never serialized to Cloud. */ @@ -478,6 +480,9 @@ export async function runTurn( preflightPulse.start(); try { const backend = await resolveBackend(ctx); + if (skillOpts.capability === "planning" && backend === "cloud") { + throw new ChatTurnError("planning requires host-executed tools; this cloud chat route runs tools on the server. Use `aether agent --planning` or `aether agent --local --planning`.", undefined, false); + } // The same seam `aether agent` uses (commands/code.ts). Opened per turn, not // per session, because automatic skill selection reads THIS prompt — a turn // that says "the CI is failing" should pull the CI skill and the next one @@ -485,6 +490,7 @@ export async function runTurn( const opened = openRunSession({ projectRoot: ctx.flags.cwd, prompt, + ...(skillOpts.capability ? { capability: skillOpts.capability } : {}), allowIncompleteInstructionDiscovery: backend === "cloud", ...(skillOpts.explicitSkill ? { explicitSkill: skillOpts.explicitSkill } : {}), ...(skillOpts.noSkills ? { noSkills: true } : {}), @@ -529,7 +535,7 @@ export async function runTurn( getRegistry().markLocalUnmetered(); // The signal used to be dropped here, so the REPL Ctrl+C controller could // not reach a local turn at all: the abort fired and nothing observed it. - return await runLocalTurn(ctx, brief, boundedSignal.signal, { lifecycle, onPulsePaint, deadlineAt, ...(skillOpts.exec ? { exec: skillOpts.exec } : {}), ...(skillOpts.onToolResult ? { onToolResult: skillOpts.onToolResult } : {}), ...(skillOpts.steer ? { steer: skillOpts.steer } : {}) }, run.guard); + return await runLocalTurn(ctx, brief, boundedSignal.signal, { lifecycle, onPulsePaint, deadlineAt, capability: skillOpts.capability, advertisedTools: run.effectiveTools as readonly ToolName[], ...(skillOpts.exec ? { exec: skillOpts.exec } : {}), ...(skillOpts.onToolResult ? { onToolResult: skillOpts.onToolResult } : {}), ...(skillOpts.steer ? { steer: skillOpts.steer } : {}) }, run.guard); } // /agent/chat/stream exposes no control acknowledgement, so a steer typed // during this turn is kept for the next one rather than reported as live. @@ -796,6 +802,8 @@ async function runCloudTurn( * draws every event. Identical UX to cloud, just an offline brain. */ export interface LocalTurnDeps { + capability?: RunCapability; + advertisedTools?: readonly ToolName[]; brain?: Brain; exec?: { executeAsync(name: string, args: Record, options?: RunOptions): Promise; @@ -842,7 +850,7 @@ export async function runLocalTurn( const model = resolveLocalModel(ctx.flags.model, ctx.cfg.localModel ?? "", { allowBareExplicit: ctx.flags.local === true, }); - const brain = deps.brain ?? new OllamaBrain({ model }); + const brain = deps.brain ?? new OllamaBrain({ model, ...(deps.advertisedTools ? { tools: deps.advertisedTools } : {}) }); const exec = deps.exec ?? new ToolExecutor(cwd); const renderer = new HostRenderer({ poolGb: 5, json: ctx.flags.json }); const pulse = new ThinkingPulse({ @@ -869,6 +877,7 @@ export async function runLocalTurn( }; const task: TaskCommand = { type: "task", + capability: deps.capability ?? "coding", text: prompt, cwd, poolGb: 5, @@ -943,7 +952,7 @@ export async function runLocalTurn( // checked first and refuses without executing or prompting; the // operator gate then decides about whatever survived. A skill can only // subtract here — it is never consulted again after this line. - const refusal = skillGuard ? skillGuard(ev.name) : null; + const refusal = refuseRunCapability(ev.name, task.capability ?? "coding") ?? (skillGuard ? skillGuard(ev.name) : null); const prepared = refusal ? null : prepareToolApproval(ev.name, ev.args, exec.configuredTestCommand); const call = { name: ev.name, args: ev.args }; const key = operationKey(call, { policy: Boolean(refusal), ...(prepared?.ok ? { binding: prepared.binding } : {}) }); @@ -1559,7 +1568,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P viewerOpen = false; break; } - }, redrawInput, { ...skillOpts, onToolResult: tool => toolResults.push(tool), steer: steerChannel }); + }, redrawInput, { ...skillOpts, capability: input.capability ?? "coding", onToolResult: tool => toolResults.push(tool), steer: steerChannel }); const afterChanges = observedWorkspaceChanges(ctx.flags.cwd); continuation.recordTurn(text, toolResults, afterChanges.filter(change => !beforeChanges.includes(change)), outcome.state, !sharedShellResult && !authContinuation); if (ctx.flags.json) process.stdout.write(turnOutcomeJson(outcome) + "\n"); @@ -1915,10 +1924,12 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P } // ── stateless prompt-rewrite modes (/recon, /plan, /research, …) ── const mode = applyPromptMode(t); + let capability: RunCapability | undefined; if (mode.handled) { if (mode.error) { process.stdout.write(mode.error + "\n"); repaint(); return; } process.stdout.write(mode.notice + "\n"); t = mode.prompt!; + capability = mode.capability; } busy = true; if (t.startsWith("/")) { @@ -1999,7 +2010,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P repaint(); return; } - await runAndDrain(queue.allocate({ kind: "chat", text: t })); + await runAndDrain(queue.allocate({ kind: "chat", text: t, ...(capability ? { capability } : {}) })); renderHudLine(); repaint(); }; diff --git a/src/commands/code.ts b/src/commands/code.ts index 0df9a746..a88a3505 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -10,7 +10,7 @@ import type { AppContext } from "../core/context.js"; import { completeMemory, pinMemory } from "../core/project_memory/run.js"; import type { Brain, TaskCommand } from "../core/brain.js"; -import type { BrainEvent } from "../core/brain_protocol.js"; +import type { BrainEvent, ToolName } from "../core/brain_protocol.js"; import type { RunOptions, ToolResult } from "../core/tool_executor.js"; import { LocalBrain } from "../core/brain_local.js"; import { OllamaBrain } from "../core/brain_ollama.js"; @@ -89,6 +89,7 @@ import { turnOutcomeRecord } from "./chat.js"; import { openRcCodingObserver, type RcCodingObserver } from "./rc_observation.js"; import { publishCodingVerification } from "./rc_verification.js"; import { promptInputLabel, type PromptInput } from "./prompt_file.js"; +import { refuseRunCapability, type RunCapability } from "../core/run_capability.js"; import { TRANSIENT_READ_AUTO_RETRIES, checkpointDoneEvent, @@ -178,6 +179,8 @@ export interface CodeRunFinished extends CodeRunStarted { } export interface CodeOpts { + /** Authority for this one invocation. */ + capability?: RunCapability; /** Use the local Python/Ollama brain instead of the cloud API. */ local: boolean; /** Pool size in GB (sets the status-bar denominator: pool x 233M). */ @@ -341,8 +344,10 @@ export class CodeTurnLifecycle { private fatal: Extract | null = null; private final: { report: CodeRunReport; verification: VerifyOutcome | null } | null = null; private checkpoint: ToolFailureCheckpoint | null = null; + private readonly capability: RunCapability; - constructor(prompt: string, opts: TurnLifecycleOptions = {}) { + constructor(prompt: string, opts: TurnLifecycleOptions = {}, capability: RunCapability = "coding") { + this.capability = capability; this.lifecycle = new TurnLifecycle(prompt, opts); this.lifecycle.transition("submitted"); this.lifecycle.transition("connecting"); @@ -554,6 +559,11 @@ export class CodeTurnLifecycle { const described = describeStreamFailure({ message: this.brainError }); return byTurn("failed", { message: this.brainError, hint: described.hint, retryable: described.retryable }); } + if (this.capability === "planning" && !verification) { + return this.done?.ok + ? byTurn("succeeded", { message: "plan returned; verification intentionally skipped" }) + : byTurn("failed", { message: "planning brain did not complete the plan" }); + } if (!verification) return byTurn("failed", { message: "host final verification did not complete" }); if (verification.check.state === "launch_failed") { return byCheck("failed", { @@ -579,6 +589,7 @@ export class CodeTurnLifecycle { /** Why no check ran, for a run that settled without one. */ private notRunReason(): string { + if (this.capability === "planning") return "planning does not run host verification"; if (this.fatal) return "the coding transport was refused before local execution"; if (isAbortError(this.thrown)) return "the coding turn was cancelled before host verification"; if (this.thrown instanceof StreamTimeoutError) return "the model stream timed out before host verification"; @@ -776,6 +787,10 @@ export async function cmdCode( opts: CodeOpts, workspaceRun: Runner = defaultRunner(), ): Promise { + if (opts.capability === "planning" && (opts.repo || opts.worktree || opts.resume)) { + process.stderr.write("✗ planning uses the current workspace; --repo, --worktree, and --resume are incompatible\n"); + return 2; + } // --resume carries the prior session's context forward, so it is also a task // of its own: with no new instruction the run continues the ORIGINAL task. // Resolved ONCE — the handoff the brain reads and the lines the human sees @@ -884,7 +899,7 @@ export async function cmdCode( return 1; } cwd = worktree.dir; - } else if (opts.workspaceMode === "current") { + } else if (opts.workspaceMode === "current" || opts.capability === "planning") { cwd = ctx.flags.cwd; } else { const ws = await prepareWorkspace(ctx, label, io, workspaceRun); @@ -906,6 +921,7 @@ export async function cmdCode( const opened = openRunSession({ projectRoot: cwd, prompt: task || label, + ...(opts.capability ? { capability: opts.capability } : {}), ...(opts.skill ? { explicitSkill: opts.skill } : {}), ...(opts.noSkills ? { noSkills: true } : {}), }); @@ -972,14 +988,14 @@ export async function cmdCode( const brain: Brain = goLocal ? chooseLocalBrain(process.env["AETHER_LOCAL_BRAIN"]) === "python" ? new LocalBrain() - : new OllamaBrain() + : new OllamaBrain({ tools: run.effectiveTools as readonly ToolName[] }) : // `aether agent` is a coding session over THIS checkout, so it may not // silently accept the one-way chat transport, whose tools run // server-side against the cloud vault (brain_cloud CloudBrainOptions). - new CloudBrain(ctx.api, undefined, { requireLocalAuthority: true }); + new CloudBrain(ctx.api, undefined, { requireLocalAuthority: true, localToolCapabilities: run.effectiveTools }); // A worktree gets its own fresh shell; launch-project state never follows it. const shellSession = process.platform === "linux" || process.platform === "darwin" ? new ShellSession(cwd) : undefined; - const exec = new ToolExecutor(cwd, opts.testCmd, { mode: "coding", ...(shellSession ? { shellSession } : {}) }); + const exec = new ToolExecutor(cwd, opts.capability === "planning" ? undefined : opts.testCmd, { mode: "coding", ...(shellSession ? { shellSession } : {}) }); // Scope the session manifest to the ORIGINAL launch directory (ctx.flags.cwd), // not the possibly-substituted `cwd` (an auto-created worktree, or a manually // redirected directory from the repo gate) — resume always compares against @@ -990,6 +1006,7 @@ export async function cmdCode( : new SessionLog( { task: label, + capability: opts.capability ?? "coding", ...(opts.promptInput ? { promptInput: opts.promptInput } : {}), model: resolvedModel, poolGb, @@ -1001,7 +1018,7 @@ export async function cmdCode( // is in, and so the branch it reports is the branch the commits // landed on rather than the launch directory's. (Lane AA-CONT-04.) ...(cwd && ctx.flags.cwd && !isCurrentWorkspace(cwd, ctx.flags.cwd) ? { worktree: cwd } : {}), - ...(opts.testCmd ? { testCmd: opts.testCmd } : {}), + ...(opts.testCmd && opts.capability !== "planning" ? { testCmd: opts.testCmd } : {}), // Digests and paths, never content: enough for the next run (or the // next machine) to tell that the rules moved, and nothing more. context: { @@ -1061,6 +1078,7 @@ export async function cmdCode( // task unchanged, so an unskilled run is byte-identical to one without this // seam at all. text: run.brief(handoff ? continuationTask(handoff, task) : task), + capability: opts.capability ?? "coding", // The typed channel, for a brain that reads the NDJSON command frame. // Additive and optional (brain_protocol.AgentContextPacket): a brain that // predates it sees no key. The brief above is what reaches the Ollama and @@ -1072,18 +1090,18 @@ export async function cmdCode( // (same backend: TaskCommand.effort reaches the cloud brain unchanged). effort: opts.effort ?? (ctx.cfg.defaultEffort || undefined), model: localSelection?.tag ?? (resolvedHostedModel || undefined), - testCmd: opts.testCmd, + testCmd: opts.capability === "planning" ? undefined : opts.testCmd, }; // One correlation identity owns the production run. A brain `done` event is // advisory; the lifecycle remains completing until host verification below. - const turn = new CodeTurnLifecycle(task || label); + const turn = new CodeTurnLifecycle(task || label, {}, opts.capability ?? "coding"); const correlation: CodeRunStarted = { sessionId: log?.sessionId ?? "", turnId: turn.turnId, workspace: cwd, model: resolvedModel, - checkCommand: opts.testCmd ?? null, + checkCommand: opts.capability === "planning" ? null : opts.testCmd ?? null, }; await opts.runObserver?.started(correlation); const progressTimeoutMs = codeMeaningfulProgressTimeoutMs(); @@ -1220,7 +1238,7 @@ export async function cmdCode( // " : write_file …" line; the animated kaomoji status line keeps pulsing // below, so the diff and the live state stay in sync. const diff = - ev.type === "tool_call" && ev.name === "write_file" ? writeDiffLines(exec, ev.args, true) : null; + opts.capability !== "planning" && ev.type === "tool_call" && ev.name === "write_file" ? writeDiffLines(exec, ev.args, true) : null; if (diff && diff.length) { for (const line of diff) sr.log(line); } else { @@ -1259,7 +1277,7 @@ export async function cmdCode( // --quiet). Suppressed under --json so machine consumers still receive the // raw tool_call event, never the rendered diff. const diff = - !ctx.flags.json && ev.type === "tool_call" && ev.name === "write_file" + !ctx.flags.json && opts.capability !== "planning" && ev.type === "tool_call" && ev.name === "write_file" ? writeDiffLines(exec, ev.args, false) : null; if (diff && diff.length) renderer.writeLines(diff); @@ -1308,6 +1326,17 @@ export async function cmdCode( return EXIT_ROUTING_REFUSED; } + if (opts.capability === "planning") { + turn.settle(null); + const report = turn.report!; + log?.close(report.outcome.state === "succeeded" ? "ok" : "error", nowIso(), 0, report.check, undefined, hostRefusals); + await opts.runObserver?.finished({ ...correlation, report, verification: null, recordedCheck: null, touchedFiles: [...touched], hostRefusals }); + emitCodeTurnOutcome(report, ctx.flags.json); + if (!ctx.flags.json) process.stderr.write(`\n planning · ${report.outcome.state} · verification not run\n`); + if (log) process.stderr.write(` ⤷ log: ${log.dir}\n`); + return report.outcome.exitCode; + } + // ── Final verification gate: ground truth, never the brain's self-report ── // The host re-runs the test command ITSELF and derives the result from the real // exit code (verify_gate.ts). The brain's `done` is advisory — it only enriches a @@ -1535,7 +1564,7 @@ export async function hostLoop( // before a byte of it runs. The brain gets a structured refusal as a // normal failed tool result, so the loop continues and the model // learns why instead of silently retrying. - const refusal = skillGuard ? skillGuard(ev.name) : null; + const refusal = refuseRunCapability(ev.name, task.capability ?? "coding") ?? (skillGuard ? skillGuard(ev.name) : null); const prepared = refusal ? null : prepareToolApproval(ev.name, ev.args, exec.configuredTestCommand); const call = { name: ev.name, args: ev.args }; const key = operationKey(call, { policy: Boolean(refusal), ...(prepared?.ok ? { binding: prepared.binding } : {}) }); diff --git a/src/commands/command_manifest_data.ts b/src/commands/command_manifest_data.ts index 59efa413..c2a24699 100644 --- a/src/commands/command_manifest_data.ts +++ b/src/commands/command_manifest_data.ts @@ -92,7 +92,7 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [ "deprecatedAliases": [], "args": "[task | list | create | show | configure | chat | activate | pause | resume]", "summary": "run the coding agent or manage account agents and shared conversations", - "detailedHelp": "aether agent list | create [ATS] | show | configure | chat [id] | activate|pause|resume \nCoding tasks: aether agent | aether agent --prompt-file task.md | cat task.md | aether agent --prompt-file -\n--prompt-file reads one literal UTF-8 task (maximum 256 KiB); it conflicts with positional tasks, --resume, --interactive, and --with-token. Managed agents sync with your account and use the shared Online DM. aether code accepts the same coding flags.", + "detailedHelp": "aether agent list | create [ATS] | show | configure | chat [id] | activate|pause|resume \nCoding tasks: aether agent | aether agent --prompt-file task.md | cat task.md | aether agent --prompt-file -\nPlanning: aether agent --planning \"outline the migration\" (or /plan in a local REPL). The host permits read_file, list_directory, and repo_search only; it skips workspace preparation and verification. --planning conflicts with --repo, --worktree, and --resume. Cloud chat /plan is refused because tools run server-side. Saving and executing a plan are later explicit actions.\n--prompt-file reads one literal UTF-8 task (maximum 256 KiB); it conflicts with positional tasks, --resume, --interactive, and --with-token. Managed agents sync with your account and use the shared Online DM. aether code accepts the same coding flags.", "section": "Start", "hidden": false, "permissionClass": "local-write", @@ -140,7 +140,7 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [ "worktree", "yes" ], - "ownedFlags": {}, + "ownedFlags": { "planning": { "type": "boolean", "default": false } }, "handler": { "id": "handler:shell:agent", "kind": "host", diff --git a/src/commands/console_input.ts b/src/commands/console_input.ts index ba609834..7f4f4bbc 100644 --- a/src/commands/console_input.ts +++ b/src/commands/console_input.ts @@ -6,6 +6,7 @@ import { BoundedOutput } from "../core/bounded_output.js"; import { sanitizeServerText } from "../core/transport.js"; import { redactForBundle, scanForSecrets } from "../core/redaction.js"; import { stripAnsi } from "../ui/text.js"; +import type { RunCapability } from "../core/run_capability.js"; import { discoverShellProfiles, type ShellProfile } from "../core/shell_profiles.js"; type ShareAction = "preview" | "lines" | "drop" | "replace" | "mask" | "redact" | "send" | "cancel"; @@ -49,7 +50,7 @@ export type ConsoleInput = | { kind: "profile"; action: "list" | "status" | "use"; profile?: "cmd" | "powershell" } | ShareInput | { kind: "error"; message: string } - | { kind: "chat"; text: string } + | { kind: "chat"; text: string; capability?: RunCapability } | { kind: "empty" }; export function classifyConsoleInput(raw: string): ConsoleInput { diff --git a/src/commands/prompt_modes.ts b/src/commands/prompt_modes.ts index 16206b5b..5d13762f 100644 --- a/src/commands/prompt_modes.ts +++ b/src/commands/prompt_modes.ts @@ -5,6 +5,7 @@ export interface PromptModeResult { handled: boolean; + capability?: "planning"; /** The rewritten prompt to send as a normal turn. */ prompt?: string; /** A one-line notice to print before the turn starts. */ @@ -20,6 +21,7 @@ interface PromptMode { usage?: string; notice: (arg: string) => string; build: (arg: string) => string; + capability?: "planning"; } const slugify = (topic: string): string => @@ -71,12 +73,13 @@ const MODES: PromptMode[] = [ }, { cmd: "/plan", + capability: "planning", takesArg: true, usage: "usage: /plan ", notice: (topic) => `📋 Planning: "${topic}"`, build: (topic) => `PLANNING MODE. Write a detailed, actionable implementation plan for: ${topic}. ` + - `Save to .hermes/plans/${slugify(topic)}.md. Include: phases with numbered tasks, ` + + `Return the plan in your response without writing files or running commands. Include: phases with numbered tasks, ` + "file manifest (new + modified files), testing strategy, risk assessment, " + "and out-of-scope items. Be specific — no hand-waving.", }, @@ -157,7 +160,7 @@ export function applyPromptMode(line: string): PromptModeResult { if (line !== m.cmd && !line.startsWith(m.cmd + " ")) continue; const arg = line.slice(m.cmd.length).trim(); if (!arg) return { handled: true, error: m.usage! }; - return { handled: true, prompt: m.build(arg), notice: m.notice(arg) }; + return { handled: true, prompt: m.build(arg), notice: m.notice(arg), ...(m.capability ? { capability: m.capability } : {}) }; } if (!line.startsWith(m.cmd)) continue; return { handled: true, prompt: m.build(""), notice: m.notice("") }; diff --git a/src/core/brain_protocol.ts b/src/core/brain_protocol.ts index 1a023ee7..1377b9fe 100644 --- a/src/core/brain_protocol.ts +++ b/src/core/brain_protocol.ts @@ -38,6 +38,7 @@ export interface AgentContextPacket { // which imports TOOLS from here). import type { SkillContextPacket } from "./skills/context_packet.js"; import type { InstructionContextPacket } from "./instructions/instruction_resolver.js"; +import type { RunCapability } from "./run_capability.js"; // --- workflow swarm frame interfaces --------------------------------------- export interface WorkflowStartFrame { @@ -207,6 +208,8 @@ export type HostCommand = effort?: string; model?: string; testCmd?: string; + /** Host-only invocation authority; never inferred from task text. */ + capability?: RunCapability; /** Skill + instruction context for this turn (see AgentContextPacket). */ context?: AgentContextPacket; } diff --git a/src/core/run_capability.ts b/src/core/run_capability.ts new file mode 100644 index 00000000..b59d529d --- /dev/null +++ b/src/core/run_capability.ts @@ -0,0 +1,23 @@ +import { TOOL_DEFINITIONS } from "./tool_registry.js"; +import type { SkillRefusal } from "./skills/skill_errors.js"; +import type { PermissionEnvelope } from "./skills/skill_policy.js"; + +/** Invocation authority, never inferred from the task text or model arguments. */ +export type RunCapability = "coding" | "planning"; + +export const PLANNING_TOOLS = ["read_file", "list_directory", "repo_search"] as const; +const planningTools = new Set(PLANNING_TOOLS); + +export function planningEnvelope(): PermissionEnvelope { + return new Set(["workspace.read"]); +} + +export function refuseRunCapability(tool: string, capability: RunCapability): SkillRefusal | null { + if (capability !== "planning") return null; + if (planningTools.has(tool) && TOOL_DEFINITIONS[tool as keyof typeof TOOL_DEFINITIONS]?.sideEffect === "read") return null; + return { + code: "run.capability_denied", + detail: `planning permits only workspace inspection; ${tool} cannot run`, + context: { capability, tool, effective_allowed_tools: PLANNING_TOOLS }, + }; +} diff --git a/src/core/session_log.ts b/src/core/session_log.ts index 0476949b..933b06b4 100644 --- a/src/core/session_log.ts +++ b/src/core/session_log.ts @@ -160,6 +160,8 @@ export type FinalStatus = export interface SessionMeta { task: string; + /** Host-enforced authority for this invocation. */ + capability?: "coding" | "planning"; /** Source and UTF-8 byte size only; never the prompt body. */ promptInput?: PromptInput; /** Resolved model provenance. Local runs store `ollama/`; cloud auto @@ -381,6 +383,7 @@ export class SessionLog { return { sessionId: this.sessionId, task: redactInline(m.task), + ...(m.capability ? { capability: m.capability } : {}), ...(m.promptInput ? { promptInput: { kind: m.promptInput.kind, bytes: m.promptInput.bytes, diff --git a/src/core/skills/run_session.ts b/src/core/skills/run_session.ts index 7cd2b799..e478f83d 100644 --- a/src/core/skills/run_session.ts +++ b/src/core/skills/run_session.ts @@ -39,8 +39,11 @@ import type { SkillPolicy } from "./skill_types.js"; import { INSTRUCTION_CONTEXT_CONTRACT_VERSION } from "../instructions/instruction_resolver.js"; import { SKILL_CONTEXT_CONTRACT_VERSION } from "./context_packet.js"; import { recordWhy } from "../why_log.js"; +import { planningEnvelope, refuseRunCapability, type RunCapability } from "../run_capability.js"; export interface RunSessionOptions { + /** Typed host authority for this invocation. */ + capability?: RunCapability; /** Root the rules and project skills are discovered from — the tree the run works in. */ projectRoot: string; /** The user's instruction; automatic skill selection reads it. */ @@ -304,13 +307,14 @@ function composeSkills(session: SkillSession): string { * nothing above it can widen the list. A header that advertised a policy the * host did not enforce would be worse than no header; so would a brief. */ -function composeHostPolicy(effective: readonly string[], narrowed: boolean): string { +function composeHostPolicy(effective: readonly string[], narrowed: boolean, capability: RunCapability): string { if (!narrowed) return ""; const body = effective.length ? "Tools you may call this run: " + effective.join(", ") + "." : "You may call NO tools this run — the loaded skills have no tool in common. Answer without tools, or ask the user to run with --no-skills."; return ( "\n" + + (capability === "planning" ? "Planning capability: inspect this workspace and return an actionable plan in the response. Do not save it or execute a phase.\n" : "") + body + "\nThe host executes every tool call and checks this list itself, before running anything. " + "A call to any other tool is refused by the host and never runs. " + @@ -323,8 +327,9 @@ function composeHostPolicy(effective: readonly string[], narrowed: boolean): str export function effectiveToolsFor( policies: readonly SkillPolicy[], envelope: PermissionEnvelope, + capability: RunCapability = "coding", ): readonly string[] { - return TOOLS.filter((tool) => refuseUndeclaredToolCall(tool, policies, envelope) === null); + return TOOLS.filter((tool) => refuseRunCapability(tool, capability) === null && refuseUndeclaredToolCall(tool, policies, envelope) === null); } /** Human guidance for one refusal — the code stays the machine contract. */ @@ -382,7 +387,8 @@ function refused(refusal: SkillRefusal): OpenRunSession { * never degrades into a quiet skill-free run. */ export function openRunSession(options: RunSessionOptions): OpenRunSession { - const envelope = options.envelope ?? defaultPermissionEnvelope(); + const capability = options.capability ?? "coding"; + const envelope = capability === "planning" ? planningEnvelope() : options.envelope ?? defaultPermissionEnvelope(); let session: SkillSession; let contextPacket: AgentContextPacket | null; const unmet: string[] = []; @@ -409,7 +415,7 @@ export function openRunSession(options: RunSessionOptions): OpenRunSession { // not fatal — a trigger phrase must not be able to abort a run. for (const [index, policy] of session.policies.entries()) { const explicit = session.loaded[index]?.invocation === "explicit"; - if (explicit) { + if (explicit || capability === "planning") { assertRequiredPermissions(policy, envelope); continue; } @@ -445,10 +451,10 @@ export function openRunSession(options: RunSessionOptions): OpenRunSession { const policies = session.policies.filter((_, index) => session.loaded[index]?.invocation === "explicit"); const automaticOnly = policies.length === 0 && session.loaded.length > 0; const narrowed = policies.length > 0; - const effective = effectiveToolsFor(policies, envelope); + const effective = effectiveToolsFor(policies, envelope, capability); const rules = composeRules(session); const skills = composeSkills(session); - const hostPolicy = composeHostPolicy(effective, narrowed); + const hostPolicy = composeHostPolicy(effective, narrowed || capability === "planning", capability); const contextText = [rules.text, skills, hostPolicy].filter((part) => part.length > 0).join("\n"); const contextTokens = approximateTokens(Buffer.byteLength(contextText, "utf8")); @@ -472,7 +478,7 @@ export function openRunSession(options: RunSessionOptions): OpenRunSession { return contextText + "\n\n" + fenceSafe(task) + "\n"; }, guard(tool: string): SkillRefusal | null { - const refusal = refuseUndeclaredToolCall(tool, policies, envelope); + const refusal = refuseRunCapability(tool, capability) ?? refuseUndeclaredToolCall(tool, policies, envelope); if (refusal) recordWhy("permission-denial", refusal.code + ": " + refusal.detail); return refusal; }, @@ -528,7 +534,9 @@ function buildHeader( lines.push(row("Context", formatTokens(contextTokens) + " tokens (measured, not estimated from a manifest)")); - if (automaticOnly) { + if (options.capability === "planning") { + lines.push(row("Policy", `planning · ${effective.join(" · ") || "NO TOOLS"} — enforced by this host`)); + } else if (automaticOnly) { lines.push( row( "Policy", diff --git a/src/core/skills/skill_errors.ts b/src/core/skills/skill_errors.ts index e87cbf35..d567daae 100644 --- a/src/core/skills/skill_errors.ts +++ b/src/core/skills/skill_errors.ts @@ -3,6 +3,7 @@ // renamed once released (CONTRACTS.md discipline applies). export const SKILL_ERROR_CODES = [ + "run.capability_denied", "skill.untrusted", "skill.changed", "skill.disabled", diff --git a/src/main.ts b/src/main.ts index 12e984ab..c9d23dcb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -119,6 +119,10 @@ export async function main(argv: string[]): Promise { } const promptFile = sf(values["prompt-file"]); + if (values["planning"] && cmd !== "agent" && cmd !== "code") { + process.stderr.write(`${errTheme.red("✗")} --planning is available only with aether agent or aether code\n`); + return 2; + } if (promptFile !== undefined) { if (cmd !== "agent" && cmd !== "code") { process.stderr.write(`${errTheme.red("✗")} --prompt-file is available only with aether agent or aether code\n`); @@ -199,6 +203,10 @@ export async function main(argv: string[]): Promise { return 1; } if (cmd === "agent" && rest[0] && MANAGED_AGENT_VERBS.has(rest[0])) { + if (values["planning"]) { + process.stderr.write(`${errTheme.red("✗")} --planning applies to coding tasks, not managed agent commands\n`); + return 2; + } return cmdManagedAgents(ctx, rest, { hooks: createAtsHooks() }); } @@ -309,9 +317,14 @@ export async function main(argv: string[]): Promise { } // No task and not resuming → open the persistent interactive agent REPL // (chat bar ready for the first question), Claude Code style. + if (values["planning"] && !task) { + process.stderr.write(`${errTheme.red("✗")} --planning requires a task or --prompt-file\n`); + return 2; + } if (!task && !sf(values["resume"])) return cmdChat(ctx, "", skillOpts); return cmdCode(ctx, task, { local: Boolean(values["local"]), + capability: values["planning"] ? "planning" : "coding", pool: Number(sf(values["pool"]) ?? "5") || 5, effort: sf(values["effort"]), testCmd: sf(values["test-cmd"]), diff --git a/test/code_turn_lifecycle.test.ts b/test/code_turn_lifecycle.test.ts index 7967aa2b..6c8b00b9 100644 --- a/test/code_turn_lifecycle.test.ts +++ b/test/code_turn_lifecycle.test.ts @@ -23,6 +23,19 @@ const task: TaskCommand = { poolGb: 5, }; +test("planning settles a completed answer without verification, while a normal turn still needs it", () => { + const plan = new CodeTurnLifecycle("outline", {}, "planning"); + plan.observe({ type: "done", ok: true, result: "steps", remaining: 0, reason: "" }); + assert.equal(plan.settle(null).state, "succeeded"); + assert.match(plan.report?.check.reason ?? "", /planning does not run host verification/); + const coding = new CodeTurnLifecycle("implement"); + coding.observe({ type: "done", ok: true, result: "done", remaining: 0, reason: "" }); + assert.equal(coding.settle(null).state, "failed"); + const denied = new CodeTurnLifecycle("outline", {}, "planning"); + denied.observe({ type: "done", ok: false, result: "refused", remaining: 0, reason: "" }); + assert.equal(denied.settle(null).state, "failed"); +}); + const GREEN_CHECK: VerifyOutcome = { status: "ok", remaining: 0, diff --git a/test/planning_command.test.ts b/test/planning_command.test.ts new file mode 100644 index 00000000..06ca3c5b --- /dev/null +++ b/test/planning_command.test.ts @@ -0,0 +1,88 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { cmdCode } from "../src/commands/code.js"; +import { runTurn } from "../src/commands/chat.js"; +import { renderManifestHelp } from "../src/commands/command_manifest.js"; +import type { AppContext } from "../src/core/context.js"; +import { tmpWorkspace } from "./tmp_workspace.js"; + +function git(root: string, ...args: string[]): string { + const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 5_000 }); + assert.equal(result.status, 0, String(result.stderr)); + return String(result.stdout); +} + +test("planning admits the current checkout without workspace preparation or final tests", async () => { + const root = tmpWorkspace("aether-planning-"); + const config = join(root, "config"); + mkdirSync(config); + const tracked = join(root, "tracked.txt"); + writeFileSync(tracked, "original\n"); + git(root, "init", "-q"); + git(root, "add", "tracked.txt"); + git(root, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "baseline"); + const beforeIndex = git(root, "ls-files", "--stage"); + const beforeRefs = git(root, "show-ref", "--head"); + const oldConfig = process.env["AETHER_CONFIG_DIR"]; + const oldMemory = process.env["AETHER_PROJECT_MEMORY_RECEIPTS_ENABLED"]; + process.env["AETHER_CONFIG_DIR"] = config; + process.env["AETHER_PROJECT_MEMORY_RECEIPTS_ENABLED"] = "0"; + const abort = new AbortController(); + abort.abort(new DOMException("test cancellation", "AbortError")); + const ctx = { + cfg: { backend: "local", localModel: "llama3", permissionMode: "skip", autoApply: true, defaultModel: "", defaultEffort: "", baseUrl: "" }, + flags: { cwd: root, local: true, json: true, yes: true }, + confirm: async () => true, + } as unknown as AppContext; + let workspaceRunnerCalls = 0; + try { + const result = await cmdCode(ctx, "outline the change", { + local: true, pool: 5, quiet: true, capability: "planning", noSkills: true, noLog: true, + testCmd: "node -e \"require('fs').writeFileSync('verification-ran', 'bad')\"", + signal: abort.signal, + }, () => { workspaceRunnerCalls += 1; throw new Error("workspace preparation or verification ran"); }); + assert.notEqual(result, 0, "the cancelled model turn is not reported as a completed plan"); + assert.equal(workspaceRunnerCalls, 0); + assert.equal(git(root, "ls-files", "--stage"), beforeIndex); + assert.equal(git(root, "show-ref", "--head"), beforeRefs); + assert.equal(readFileSync(tracked, "utf8"), "original\n"); + assert.equal(existsSync(join(root, "verification-ran")), false); + } finally { + if (oldConfig === undefined) delete process.env["AETHER_CONFIG_DIR"]; + else process.env["AETHER_CONFIG_DIR"] = oldConfig; + if (oldMemory === undefined) delete process.env["AETHER_PROJECT_MEMORY_RECEIPTS_ENABLED"]; + else process.env["AETHER_PROJECT_MEMORY_RECEIPTS_ENABLED"] = oldMemory; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("planning rejects mutating workspace options before repository preparation", () => { + const root = tmpWorkspace("aether-planning-flags-"); + const cli = resolve("dist", "src", "main.js"); + assert.match(renderManifestHelp("shell", "agent"), /aether agent --planning/); + try { + for (const flags of [["--worktree"], ["--repo", "owner/repo"], ["--resume", "old-session"]]) { + const result = spawnSync(process.execPath, [cli, "agent", "--planning", "--cwd", root, ...flags, "outline"], { encoding: "utf8", timeout: 5_000 }); + assert.equal(result.status, 2, String(result.stderr)); + assert.match(String(result.stderr), /planning uses the current workspace/); + assert.equal(existsSync(join(root, ".git")), false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("server-executed cloud chat refuses planning before sending a model request", async () => { + let requests = 0; + const ctx = { + cfg: { backend: "cloud", baseUrl: "https://example.invalid" }, + flags: { cwd: process.cwd(), json: true, local: false }, + tokens: { get: async () => "aek_fixture" }, + api: { stream: () => { requests += 1; throw new Error("unrestricted request"); } }, + } as unknown as AppContext; + await assert.rejects(runTurn(ctx, "return a plan", undefined, undefined, undefined, { capability: "planning", noSkills: true }), /requires host-executed tools/); + assert.equal(requests, 0); +}); diff --git a/test/prompt_modes.test.ts b/test/prompt_modes.test.ts index 2f27315b..9c4b6312 100644 --- a/test/prompt_modes.test.ts +++ b/test/prompt_modes.test.ts @@ -31,10 +31,12 @@ test("/recon without topic returns usage error", () => { assert.equal(r.error, "usage: /recon "); }); -test("/plan slugs the topic into the save path", () => { +test("/plan carries planning capability and returns the plan without a write", () => { const r = applyPromptMode("/plan Add OAuth2 Support!"); assert.equal(r.handled, true); - assert.ok(r.prompt!.includes(".hermes/plans/add-oauth2-support.md")); + assert.equal(r.capability, "planning"); + assert.match(r.prompt!, /Return the plan in your response without writing files/); + assert.doesNotMatch(r.prompt!, /Save to/); }); test("/self-review and /code-review need no arg", () => { diff --git a/test/skill_host_loop.test.ts b/test/skill_host_loop.test.ts index 89ee71c7..29e475d6 100644 --- a/test/skill_host_loop.test.ts +++ b/test/skill_host_loop.test.ts @@ -34,6 +34,7 @@ import { CloudBrain } from "../src/core/brain_cloud.js"; import { ApiClient } from "../src/core/transport.js"; import { ToolExecutor, type ToolResult } from "../src/core/tool_executor.js"; import { TOOLS, type BrainEvent } from "../src/core/brain_protocol.js"; +import { PLANNING_TOOLS } from "../src/core/run_capability.js"; import type { Brain, TaskCommand } from "../src/core/brain.js"; import type { AppContext } from "../src/core/context.js"; import type { TokenStore } from "../src/core/auth.js"; @@ -353,6 +354,109 @@ test("runLocalTurn enforces the same policy on the REPL's local path", async () assert.match(brain.results[0]?.result.output ?? "", /skill\.tool_not_declared/); }); +test("unskilled planning advertises and enforces only the three inspection tools", async () => { + const fixture = makeFixture(); + const opened = openRunSession({ projectRoot: fixture.root, prompt: "outline a change", builtinRoot: fixture.builtinRoot, noSkills: true, capability: "planning" }); + assert.equal(opened.ok, true, opened.ok ? "" : opened.lines.join("\n")); + if (!opened.ok) return; + const run = opened.run; + assert.deepEqual(run.effectiveTools, PLANNING_TOOLS); + for (const tool of PLANNING_TOOLS) assert.equal(run.guard(tool), null, tool); + assert.match(run.headerLines.join("\n"), /planning · read_file/); + assert.match(run.brief("outline a change"), /Do not save it or execute a phase/); + for (const tool of TOOLS.filter((name) => !PLANNING_TOOLS.includes(name as typeof PLANNING_TOOLS[number]))) { + assert.equal(run.guard(tool)?.code, "run.capability_denied", tool); + } + const brain = new ScriptedBrain([ + { name: "read_file", args: { path: "AGENTS.md" } }, + { name: "write_file", args: { path: "victim.txt", content: "bad" } }, + { name: "run_shell", args: { command: "echo bad" } }, + { name: "run_tests", args: { command: "echo bad" } }, + { name: "git_commit", args: { message: "bad" } }, + { name: "web_fetch", args: { url: "https://example.invalid" } }, + ]); + const spy = new SpyExecutor(); + await hostLoop(brain, spy as unknown as ToolExecutor, noopEvent, { ...task(run.brief("outline a change")), capability: "planning", cwd: fixture.root }, undefined, async () => true, run.guard, { failureBudget: false }); + assert.deepEqual(spy.executed, ["read_file"]); + assert.equal(existsSync(join(fixture.root, "victim.txt")), false); + assert.ok(brain.results.slice(1).every((entry) => entry.result.output.includes("run.capability_denied"))); +}); + +test("hosted dev-session planning advertises the narrow set and refuses a forged write call", async () => { + const fixture = makeFixture(); + const opened = openRunSession({ projectRoot: fixture.root, prompt: "outline", builtinRoot: fixture.builtinRoot, noSkills: true, capability: "planning" }); + assert.equal(opened.ok, true); + if (!opened.ok) return; + let createBody: Record | null = null; + const posts: Record[] = []; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/agent/dev/sessions")) { + createBody = JSON.parse(String(init?.body)) as Record; + return new Response(JSON.stringify({ session_id: "s1", protocol_version: 1 }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url.endsWith("/tool-results")) { + posts.push(JSON.parse(String(init?.body)) as Record); + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + } + if (url.includes("/stream")) { + const frames = [ + { type: "tool_call", seq: 1, tool_call_id: "w1", name: "write_file", args: { path: "victim.txt", content: "bad" } }, + { type: "done", seq: 2, ok: true }, + ].map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join(""); + return new Response(frames, { status: 200, headers: { "content-type": "text/event-stream" } }); + } + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + const tokens = { get: async () => "aek_t" } as unknown as TokenStore; + const api = new ApiClient("https://example.invalid", tokens); + (api as unknown as { fetchImpl: typeof fetch }).fetchImpl = fetchImpl; + const original = globalThis.fetch; + globalThis.fetch = fetchImpl; + const spy = new SpyExecutor(); + try { + const brain = new CloudBrain(api, undefined, { requireLocalAuthority: true, localToolCapabilities: opened.run.effectiveTools }); + await hostLoop(brain, spy as unknown as ToolExecutor, noopEvent, { ...task(opened.run.brief("outline")), cwd: fixture.root, capability: "planning" }, undefined, async () => true, opened.run.guard, { failureBudget: false }); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + globalThis.fetch = original; + } + assert.deepEqual((createBody as unknown as Record)["capabilities"], PLANNING_TOOLS); + assert.deepEqual(spy.executed, []); + assert.equal(existsSync(join(fixture.root, "victim.txt")), false); + assert.match(String(posts[0]?.["output"] ?? ""), /run.capability_denied/); +}); + +test("planning refuses a skill whose required authority is unavailable", () => { + const fixture = makeFixture(); + const { id } = installSkill(fixture.root, { requires: ["shell.execute"], allowed: ["read_file", "run_shell"] }); + const opened = openRunSession({ projectRoot: fixture.root, prompt: "outline a change", builtinRoot: fixture.builtinRoot, explicitSkill: id, capability: "planning" }); + assert.equal(opened.ok, false); + if (!opened.ok) assert.equal(opened.refusal.code, "skill.permission_unavailable"); +}); + +test("planning and an explicit skill intersect without treating required permissions as tool grants", () => { + const fixture = makeFixture(); + const { id } = installSkill(fixture.root, { allowed: ["read_file", "run_shell"] }); + const opened = openRunSession({ projectRoot: fixture.root, prompt: "outline", builtinRoot: fixture.builtinRoot, explicitSkill: id, capability: "planning" }); + assert.equal(opened.ok, true, opened.ok ? "" : opened.lines.join("\n")); + if (!opened.ok) return; + assert.deepEqual(opened.run.effectiveTools, ["read_file"]); + assert.equal(opened.run.guard("repo_search")?.code, "skill.tool_not_declared"); + assert.equal(opened.run.guard("run_shell")?.code, "run.capability_denied"); +}); + +test("local planning refuses mutating calls without an active skill or permission prompt", async () => { + const fixture = makeFixture(); + const brain = new ScriptedBrain([{ name: "write_file", args: { path: "victim.txt", content: "bad" } }]); + const spy = new SpyExecutor(); + const ctx = { cfg: { permissionMode: "skip", autoApply: true, baseUrl: "" }, flags: { cwd: fixture.root, json: true, yes: true }, confirm: async () => true } as unknown as AppContext; + await runLocalTurn(ctx, "plan", undefined, { brain, exec: spy, capability: "planning" }); + assert.deepEqual(spy.executed, []); + assert.match(brain.results[0]?.result.output ?? "", /run.capability_denied/); + assert.equal(existsSync(join(fixture.root, "victim.txt")), false); +}); + // ── 3. never widen ────────────────────────────────────────────────────────── test("a skill cannot grant a tool the host does not have", () => {