Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/coding-agent/docs/step-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,27 @@ editor refocus/disposal callback is cancelled before mounting. This prevents
an orphaned promise, overlay, or timeout. Ordinary dismissal still allows the
refocused editor to open the next dialog.

### Fused verification (`then_run`)

`edit_file` and `write_file` accept an optional `then_run` shell command. It
runs only after the mutation succeeds (including a no-op), from the initial
working directory like `run_command` without `cwd`, with the same timeout and
output cap. Its output and exit status are appended to the mutation receipt
and recorded in `details.thenRun`. A failing check does not mark the call as
an error, because the file change has already been applied. Empty or
whitespace-only values are ignored.

The Step extension gates `then_run` as an embedded `run_command` call: the
permission decision is the stricter of the mutation and the command
(including `run_command` overrides and dangerous-command analysis), the
confirmation reason names the command, workflow path ACLs check it, and the
call is blocked when `run_command` is not active in the session. SDK
`acceptEdits` does not auto-approve an edit carrying `then_run`. SDK
`PreToolUse` hooks and third-party `tool_call` handlers see it as
`input.then_run`; embedders that skip the Step extension must gate it
themselves. In plan mode a `then_run` on the plan file behaves like
`run_command`, which keeps normal permissions there.

## Plans and tasks

Plans and tasks serve different purposes. A **plan** is the Markdown proposal:
Expand Down
10 changes: 10 additions & 0 deletions packages/coding-agent/src/features/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { StepSettingsManager } from "../step/settings-manager.ts";
import { recordStepSlashCommand, registerStepPiCommandAdapters } from "../step/slash-commands.ts";
import { type StepTelemetryReporter, trackStepTelemetry } from "../step/telemetry.ts";
import type { TraceHeaderPolicy } from "../step/telemetry-contract.ts";
import { getThenRunCommand } from "../step/then-run.ts";
import { applyStepTraceHeaders } from "../step/trace-headers.ts";
import {
fetchStepModelEfforts,
Expand Down Expand Up @@ -243,6 +244,15 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi
});

pi.on("tool_call", async (event, ctx) => {
if (
getThenRunCommand(event.toolName, event.input) !== undefined &&
!pi.getActiveTools().includes("run_command")
) {
return {
block: true,
reason: "then_run requires run_command, which is not available in this session; retry without then_run",
};
}
return await permissions.handleToolCall(event, ctx);
});

Expand Down
7 changes: 6 additions & 1 deletion packages/coding-agent/src/features/workflow/tool-profile.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, realpathSync } from "node:fs";
import path from "node:path";
import { getThenRunCommand } from "../../step/then-run.ts";

const READ_ONLY_TOOLS = ["read_file", "search_files", "find_files", "list_directory", "find_tools"];
const DEVELOPER_TOOLS = [...READ_ONLY_TOOLS, "write_file", "edit_file", "run_command"];
Expand Down Expand Up @@ -156,7 +157,11 @@ export function checkWorkflowToolCall(
const target = ["path", "filePath", "target", "filename"]
.map((key) => value[key])
.find((item) => typeof item === "string");
return checkWorkflowPathAccess(cwd, typeof target === "string" ? target : "", "write", acl);
const writeDecision = checkWorkflowPathAccess(cwd, typeof target === "string" ? target : "", "write", acl);
const thenRun = getThenRunCommand(toolName, value);
if (!writeDecision.allowed || thenRun === undefined) return writeDecision;
const runDecision = checkWorkflowToolCall(cwd, "run_command", { command: thenRun }, acl);
return runDecision.allowed ? writeDecision : runDecision;
}
if (EXECUTE_TOOL_NAMES.has(toolName)) {
const commandCwd = typeof value.cwd === "string" ? value.cwd : ".";
Expand Down
36 changes: 36 additions & 0 deletions packages/coding-agent/src/step/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
import { getShellConfig } from "../utils/shell.ts";

import { analyzeCommandPolicy, type CommandPolicyAnalysis } from "./command-policy.ts";
import { getThenRunCommand } from "./then-run.ts";

export { containsDangerousLifecycleCommand, isDangerousCommand } from "./command-policy.ts";

Expand Down Expand Up @@ -333,6 +334,34 @@ export function resolveInitialStepPermissionState(options: StepPermissionControl
return resolved;
}

const DECISION_RANK = { allow: 0, confirm: 1, deny: 2 } as const;

/**
* A then_run command is an embedded run_command call: the fused call takes the
* stricter of the two decisions, and a confirmation names the command because
* the dialog's input summary may clip it behind a large file payload.
*/
function combineThenRunDecisions(
mutation: StepToolDecision,
verification: StepToolDecision,
command: string,
): StepToolDecision {
const flagged = (decision: StepToolDecision) => decision.hazardous || decision.analysisIncomplete === true;
const verificationRank = DECISION_RANK[verification.action];
const mutationRank = DECISION_RANK[mutation.action];
const verificationLeads =
verificationRank > mutationRank ||
(verificationRank === mutationRank && flagged(verification) && !flagged(mutation));
const lead = verificationLeads ? verification : mutation;
const baseReason = verificationLeads ? `then_run (run_command): ${verification.reason}` : mutation.reason;
return {
action: lead.action,
hazardous: mutation.hazardous || verification.hazardous,
...(mutation.analysisIncomplete || verification.analysisIncomplete ? { analysisIncomplete: true as const } : {}),
reason: lead.action === "confirm" ? `${baseReason}\nthen_run: ${command}` : baseReason,
};
}

/**
* Decide a tool call without involving the terminal. This is intentionally
* conservative for unknown tools: ask mode confirms them, read-only blocks
Expand All @@ -345,6 +374,13 @@ export function decideStepToolCall(
overrides: Readonly<Record<string, StepToolPermissionMode>> | undefined = state.toolOverrides,
shellContext?: ShellExecutionContext,
): StepToolDecision {
const thenRun = getThenRunCommand(toolName, input);
if (thenRun !== undefined) {
const { then_run: _thenRun, ...mutationInput } = input;
const mutation = decideStepToolCall(toolName, mutationInput, state, overrides, shellContext);
const verification = decideStepToolCall("run_command", { command: thenRun }, state, overrides, shellContext);
return combineThenRunDecisions(mutation, verification, thenRun);
}
const normalizedName = toolName.trim().toLowerCase();
const command = extractCommand(input);
let analysis: CommandPolicyAnalysis | undefined;
Expand Down
5 changes: 4 additions & 1 deletion packages/coding-agent/src/step/stdio-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
StepStdioFrameDecoder,
StepStdioProtocolViolation,
} from "./stdio.ts";
import { getThenRunCommand } from "./then-run.ts";

type FrameWriter = (chunk: Buffer) => boolean | undefined;

Expand Down Expand Up @@ -798,7 +799,9 @@ export class StepStdioHost {
if (mode === "plan" || mode === "dontAsk") {
return { block: true, reason: `tool ${toolName} is not allowed in permission mode ${mode}`, terminate: true };
}
if (mode === "acceptEdits" && isEditTool(toolName)) return undefined;
if (mode === "acceptEdits" && isEditTool(toolName) && getThenRunCommand(toolName, context.args) === undefined) {
return undefined;
}
if (query.options.hasPermissionCallback !== true) {
return {
block: true,
Expand Down
6 changes: 6 additions & 0 deletions packages/coding-agent/src/step/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,12 @@ export function buildStepSystemPromptAppendix(
"- Keep tool calls narrow and independently verifiable. Do not use interactive commands or shell chains when a structured argument (such as cwd) is available.",
];

if ((active.has("edit_file") || active.has("write_file")) && active.has("run_command")) {
sections.push(
"- To verify a change immediately, pass the check as then_run on edit_file or write_file (for example a focused test or typecheck) instead of a separate run_command call; it runs only after the change succeeds and needs the same approval as run_command.",
);
}

if (hasWrite || hasExecute) {
sections.push(
"For large source files and reports, create a small initial section, then grow it with focused edits across separate responses. Keep generated code or text in tool arguments to roughly 100 lines or a few kilobytes per response when practical; this is a planning guideline, not permission to truncate content. Do not combine many large writes in one response or embed the same large payload in a shell command. Complete all sections before final validation and report any unfinished work.",
Expand Down
17 changes: 17 additions & 0 deletions packages/coding-agent/src/step/then-run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Action fusion: edit_file/write_file accept an optional `then_run` command
* that runs after the mutation succeeds. Gates (permissions, workflow ACL,
* active tools, SDK acceptEdits) treat it as an embedded run_command call.
*/

export const THEN_RUN_TOOL_NAMES: ReadonlySet<string> = new Set(["edit_file", "write_file"]);

export function readThenRunCommand(input: unknown): string | undefined {
if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
const value = (input as Record<string, unknown>).then_run;
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}

export function getThenRunCommand(toolName: string, input: unknown): string | undefined {
return THEN_RUN_TOOL_NAMES.has(toolName.trim().toLowerCase()) ? readThenRunCommand(input) : undefined;
}
Loading
Loading