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
19 changes: 19 additions & 0 deletions docs/command-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,25 @@ command execution must supply the corresponding `shellContext` to the controller
Manual `!` input and RPC `bash` use the separate `user_bash` event and are outside
this agent-tool policy.

### Tool-origin routing

Tool registrations carry optional `origin` provenance: `core`, `builtin`,
`extension`, or `mcp`. Core tools and built-in capabilities run command analysis
only for the dedicated `run_command`, `bash`, and `powershell` tools. Their other
structured inputs, including workflow source text, are not reinterpreted as shell
commands. MCP tools, extension tools, and calls without provenance retain the
conservative heuristic scan of command-bearing fields.

The host assigns this provenance at registration. Disk-loaded extensions and
ordinary SDK `customTools` always use `extension`. Definitions emitted by
`createStepToolProfile` retain `core` through an object-identity marker when the
host injects that profile through `customTools`; cloning a definition loses this
exception. Inline extension factories are host-composed code and may identify
first-party capabilities as `builtin` and MCP adapters as `mcp`. Core provenance
is otherwise reserved for the base tool registry. An `origin` value is not an
authorization grant: explicit tool overrides still run first, and trusted shell
tools retain dangerous-command and incomplete-analysis checks.

## Syntax, command semantics, and rules

`shell-analysis.ts` uses the pinned `unbash` parser and explicitly traverses its
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2045,6 +2045,8 @@ pi.registerTool({
});
```

**Tool origin:** `origin?: "core" | "builtin" | "extension" | "mcp"` is optional host provenance used by command-permission routing and propagated to `tool_call` handlers. Ordinary extension tools should omit it. Disk-loaded extensions and ordinary SDK `customTools` are always registered as `extension`; the host-injected `createStepToolProfile` definitions retain `core` through an object-identity marker. Inline factories are host-composed code and may mark first-party capabilities as `builtin` and MCP adapters as `mcp`; `core` is otherwise reserved for the base tool registry. See [Command permissions](../../../docs/command-permissions.md#tool-origin-routing).

**Usage accounting:** If a tool makes nested LLM calls, return their combined `Usage` as `usage`. Step persists it on the tool result and includes it in footer, `/session`, and RPC session totals. `tool_result` handlers can inspect or replace this value.

**Signaling errors:** To mark a tool execution as failed (sets `isError: true` on the result and reports it to the LLM), throw an error from `execute`. Returning a value never sets the error flag regardless of what properties you include in the return object.
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ const { session } = await createAgentSession({

Use `defineTool()` for standalone definitions and arrays like `customTools: [myTool]`. Inline `pi.registerTool({ ... })` already infers parameter types correctly.

Custom tools passed via `customTools` are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via `pi.registerTool()`.
Custom tools passed via `customTools` are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via `pi.registerTool()`. Ordinary SDK custom tools always use the `extension` tool origin, so a self-declared `origin` cannot switch them to a trusted command-permission tier. Definitions emitted by `createStepToolProfile` retain their registered `core` provenance when the host injects that profile through `customTools`; cloning a definition removes this exception.

If you pass `tools`, include each custom or extension tool name you want enabled, for example `tools: ["read", "bash", "my_tool"]`.

Expand Down
10 changes: 8 additions & 2 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ import type { SettingsManager } from "./settings-manager.ts";
import type { SlashCommandInfo } from "./slash-commands.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
import { type BuildSystemPromptOptions, buildSystemPrompt, type SystemPromptProduct } from "./system-prompt.ts";
import { isMarkedCoreToolDefinition } from "./tool-origin.ts";
import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts";
import { createAllToolDefinitions } from "./tools/index.ts";
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts";
Expand Down Expand Up @@ -576,6 +577,7 @@ export class AgentSession {
type: "tool_call",
toolName: toolCall.name,
toolCallId: toolCall.id,
origin: this._toolDefinitions.get(toolCall.name)?.definition.origin,
input: args as Record<string, unknown>,
});
} catch (err) {
Expand Down Expand Up @@ -1520,6 +1522,7 @@ export class AgentSession {
description: definition.description,
parameters: definition.parameters,
promptGuidelines: definition.promptGuidelines,
origin: definition.origin,
sourceInfo,
}));
}
Expand Down Expand Up @@ -3432,7 +3435,10 @@ export class AgentSession {
const allCustomTools = [
...registeredTools,
...this._customTools.map((definition) => ({
definition,
definition: {
...definition,
origin: isMarkedCoreToolDefinition(definition) ? ("core" as const) : ("extension" as const),
},
sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),
})),
].filter((tool) => isAllowedTool(tool.definition.name));
Expand All @@ -3442,7 +3448,7 @@ export class AgentSession {
.map(([name, definition]) => [
name,
{
definition,
definition: { ...definition, origin: definition.origin ?? "core" },
sourceInfo: createSyntheticSourceInfo(`<builtin:${name}>`, { source: "builtin" }),
},
]),
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export type {
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
ToolInfo,
ToolOrigin,
ToolRenderResultOptions,
ToolResultEvent,
ToolResultEventResult,
Expand Down
15 changes: 13 additions & 2 deletions packages/coding-agent/src/core/extensions/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,17 @@ export function createExtensionRuntime(): ExtensionRuntime {
return runtime;
}

/**
* Disk-loaded extensions cannot self-assign a trusted tier. Inline factories
* come directly from the host and may tag first-party capabilities and MCP
* adapters; core provenance is reserved for the base tool registry.
*/
function withExtensionToolOrigin(tool: ToolDefinition, extension: Extension): ToolDefinition {
const isInline = extension.path.startsWith("<") && extension.path.endsWith(">");
const origin = isInline && (tool.origin === "builtin" || tool.origin === "mcp") ? tool.origin : "extension";
return { ...tool, origin };
}

/**
* Create the ExtensionAPI for an extension.
* Registration methods write to the extension object.
Expand Down Expand Up @@ -305,7 +316,7 @@ function createExtensionAPI(
registerTool(tool: ToolDefinition): void {
assertActive();
extension.tools.set(tool.name, {
definition: tool,
definition: withExtensionToolOrigin(tool, extension),
sourceInfo: extension.sourceInfo,
});
runtime.refreshTools();
Expand All @@ -319,7 +330,7 @@ function createExtensionAPI(
}
for (const tool of tools) {
extension.tools.set(tool.name, {
definition: tool,
definition: withExtensionToolOrigin(tool, extension),
sourceInfo: extension.sourceInfo,
});
changed = true;
Expand Down
9 changes: 8 additions & 1 deletion packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,9 @@ export interface ToolRenderContext<TState = any, TArgs = any> {
isError: boolean;
}

/** Registration provenance used to select the appropriate permission policy. */
export type ToolOrigin = "core" | "builtin" | "extension" | "mcp";

/**
* Tool definition for registerTool().
*/
Expand All @@ -524,6 +527,8 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
label: string;
/** Description for LLM */
description: string;
/** Registration provenance used by permission policy. */
origin?: ToolOrigin;
/** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */
promptSnippet?: string;
/** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */
Expand Down Expand Up @@ -965,6 +970,8 @@ export type InputEventResult =
interface ToolCallEventBase {
type: "tool_call";
toolCallId: string;
/** Provenance of the registered tool, when known. Missing origins remain conservative. */
origin?: ToolOrigin;
}

export interface BashToolCallEvent extends ToolCallEventBase {
Expand Down Expand Up @@ -1733,7 +1740,7 @@ export type GetSessionNameHandler = () => string | undefined;
export type GetActiveToolsHandler = () => string[];

/** Tool info with name, description, parameter schema, prompt guidelines, and source metadata. */
export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters" | "promptGuidelines"> & {
export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "origin" | "parameters" | "promptGuidelines"> & {
sourceInfo: SourceInfo;
};

Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export {
type ToolCallEvent,
type ToolCallEventResult,
type ToolDefinition,
type ToolOrigin,
type ToolRenderResultOptions,
type ToolResultEvent,
type TurnEndEvent,
Expand Down
15 changes: 15 additions & 0 deletions packages/coding-agent/src/core/tool-origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ToolDefinition } from "./extensions/types.ts";

const coreToolDefinitions = new WeakSet<object>();

/** Mark a freshly created first-party profile definition without making provenance cloneable. */
export function markCoreToolDefinition<T extends ToolDefinition>(tool: T): T {
tool.origin = "core";
coreToolDefinitions.add(tool);
return tool;
}

/** Object identity prevents arbitrary SDK definitions from opting into the core tier. */
export function isMarkedCoreToolDefinition(tool: ToolDefinition): boolean {
return coreToolDefinitions.has(tool);
}
28 changes: 21 additions & 7 deletions packages/coding-agent/src/features/step-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ import { registerWorkflowChildAcl } from "./workflow/acl-extension.ts";
import { createStepWorkflowExtension, type StepWorkflowExtensionOptions } from "./workflow/step-workflow.ts";
import { createUltraloopOptInExtension, type UltraloopTurnState } from "./workflow/ultraloop-opt-in.ts";

/** Tag every tool registered by the first-party capability bundle without naming individual tools in policy code. */
function withBuiltinToolOrigin(pi: ExtensionAPI): ExtensionAPI {
return {
...pi,
registerTool: (tool) => pi.registerTool({ ...tool, origin: "builtin" }),
registerTools: (tools, options) =>
pi.registerTools(
tools.map((tool) => ({ ...tool, origin: "builtin" })),
options,
),
};
}

export interface StepCapabilitiesExtensionOptions {
subagent?: StepSubagentExtensionOptions;
/** Optional process reporter; capability adapters never own delivery. */
Expand Down Expand Up @@ -38,13 +51,14 @@ export function createStepCapabilitiesExtension(options: StepCapabilitiesExtensi
turnState: workflowTurnState,
});
return (pi: ExtensionAPI): void => {
registerWorkflowChildAcl(pi, options.telemetry);
registerStepClarifyUserExtension(pi, options.telemetry);
plan(pi);
tasks(pi);
subagent(pi);
workflow(pi);
ultraloopOptIn(pi);
const builtinPi = withBuiltinToolOrigin(pi);
registerWorkflowChildAcl(builtinPi, options.telemetry);
registerStepClarifyUserExtension(builtinPi, options.telemetry);
plan(builtinPi);
tasks(builtinPi);
subagent(builtinPi);
workflow(builtinPi);
ultraloopOptIn(builtinPi);
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export type {
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
ToolInfo,
ToolOrigin,
ToolRenderResultOptions,
ToolResultEvent,
TurnEndEvent,
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/step/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,7 @@ function createRemoteTool(server: ConnectedServer, remote: McpTool) {
name,
label: remote.title?.trim() || remote.name,
description: remote.description?.trim() || `MCP tool '${remote.name}' from server '${server.name}'.`,
origin: "mcp" as const,
// The provider boundary accepts JSON Schema directly. Reconstructing it
// as TypeBox types loses constraints, local references and combinators.
parameters: remote.inputSchema,
Expand Down
45 changes: 38 additions & 7 deletions packages/coding-agent/src/step/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
ExtensionUIContext,
ToolCallEvent,
ToolCallEventResult,
ToolOrigin,
} from "../core/extensions/types.ts";
import { getShellConfig } from "../utils/shell.ts";

Expand Down Expand Up @@ -414,16 +415,24 @@ export function decideStepToolCall(
state: StepPermissionState,
overrides: Readonly<Record<string, StepToolPermissionMode>> | undefined = state.toolOverrides,
shellContext?: ShellExecutionContext,
origin?: ToolOrigin,
): 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);
const mutation = decideStepToolCall(toolName, mutationInput, state, overrides, shellContext, origin);
const verification = decideStepToolCall(
"run_command",
{ command: thenRun },
state,
overrides,
shellContext,
"core",
);
return combineThenRunDecisions(mutation, verification, thenRun);
}
const normalizedName = toolName.trim().toLowerCase();
const command = extractCommand(input);
const command = extractCommand(normalizedName, input, origin);
let analysis: CommandPolicyAnalysis | undefined;
if (command !== undefined) {
try {
Expand Down Expand Up @@ -560,9 +569,16 @@ export class StepPermissionController {
* differs without a terminal (see unattendedCause). A caller that omits it
* asks for the interactive policy.
*/
decide(toolName: string, input: Record<string, unknown>, hasUI = true): StepToolDecision {
decide(toolName: string, input: Record<string, unknown>, hasUI = true, origin?: ToolOrigin): StepToolDecision {
const cause = this.unattendedCause(hasUI);
return decideStepToolCall(toolName, input, this.effectiveState(cause), this.toolOverrides, this.shellContext());
return decideStepToolCall(
toolName,
input,
this.effectiveState(cause),
this.toolOverrides,
this.shellContext(),
origin,
);
}

getOverrides(): Record<string, StepToolPermissionMode> {
Expand Down Expand Up @@ -618,7 +634,14 @@ export class StepPermissionController {
const input = event.input;
const cause = this.unattendedCause(context.hasUI);
const state = this.effectiveState(cause);
const decision = decideStepToolCall(event.toolName, input, state, this.toolOverrides, this.shellContext());
const decision = decideStepToolCall(
event.toolName,
input,
state,
this.toolOverrides,
this.shellContext(),
event.origin,
);
if (decision.action === "allow") return undefined;

if (decision.action === "deny") {
Expand Down Expand Up @@ -761,7 +784,15 @@ function findToolOverride(
return undefined;
}

function extractCommand(input: Record<string, unknown>): string | undefined {
function extractCommand(
toolName: string,
input: Record<string, unknown>,
origin: ToolOrigin | undefined,
): string | undefined {
if (origin === "core" || origin === "builtin") {
if (toolName !== "run_command" && toolName !== "bash" && toolName !== "powershell") return undefined;
return typeof input.command === "string" ? input.command : undefined;
}
for (const key of ["command", "cmd", "script"]) {
const value = input[key];
if (typeof value === "string") return value;
Expand Down
5 changes: 3 additions & 2 deletions packages/coding-agent/src/step/tool-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
ToolRenderContext,
ToolRenderResultOptions,
} from "../core/extensions/types.ts";
import { markCoreToolDefinition } from "../core/tool-origin.ts";
import { ShellToolError } from "../core/tools/bash.ts";
import type { EditToolOptions } from "../core/tools/edit.ts";
import { generateDiffString, generateUnifiedPatch, normalizeToLF } from "../core/tools/edit-diff.ts";
Expand Down Expand Up @@ -1771,8 +1772,8 @@ export function createStepToolProfile(cwd: string, options: StepToolProfileOptio
editFile,
runCommand,
];
const findTools = createFindToolsDefinition(profileWithoutFindTools);
return [...profileWithoutFindTools, findTools];
const findTools: AnyToolDefinition = createFindToolsDefinition(profileWithoutFindTools);
return [...profileWithoutFindTools, findTools].map(markCoreToolDefinition);
}

/** True for the Step model-facing names. */
Expand Down
4 changes: 3 additions & 1 deletion packages/coding-agent/test/suite/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { convertToLlm } from "../../src/core/messages.ts";
import { SessionManager } from "../../src/core/session-manager.ts";
import type { Settings } from "../../src/core/settings-manager.ts";
import { SettingsManager } from "../../src/core/settings-manager.ts";
import type { InlineExtension, ResourceLoader } from "../../src/index.ts";
import type { InlineExtension, ResourceLoader, ToolDefinition } from "../../src/index.ts";
import {
type CreateTestExtensionsResultInput,
createTestExtensionsResult,
Expand Down Expand Up @@ -65,6 +65,7 @@ export interface HarnessOptions {
settings?: Partial<Settings>;
systemPrompt?: string;
tools?: AgentTool[];
customTools?: ToolDefinition[];
initialActiveToolNames?: string[];
allowedToolNames?: string[];
excludedToolNames?: string[];
Expand Down Expand Up @@ -187,6 +188,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader,
baseToolsOverride: toolMap,
customTools: options.customTools,
initialActiveToolNames: options.initialActiveToolNames,
allowedToolNames: options.allowedToolNames,
excludedToolNames: options.excludedToolNames,
Expand Down
Loading
Loading