Skip to content

[Proposal] Distinguish tool origin (built-in vs third-party MCP) in permission evaluations #327

Open

Description

@li-xiu-qi

What do you want to change?

We propose introducing explicit tool origin metadata (e.g., origin: "core" | "builtin_extension" | "user_extension" | "mcp") or capability manifests into tool definitions and runtime events, preserving this provenance across the execution pipeline into decideStepToolCall().

This enables Step-Code to establish clear trust tiers:

  1. Core & Built-in Sandbox Tools (e.g., workflow, plan, subagent): Evaluated against their known, fixed runtime characteristics (e.g. workflow runs exclusively in an in-memory QuickJS WebAssembly sandbox without host shell access; it skips shell AST heuristics completely).
  2. Host Shell Execution Tools (e.g., bash, powershell, run_command): Route to the AST-based analyzeCommandPolicy() to enforce dangerous-command safety policies.
  3. External & Third-Party Extensions / MCP Tools: Evaluated under conservative, zero-trust defensive heuristics (inspecting command, cmd, script, etc.) to prevent malicious tools from wrapping shell commands to bypass auditing.

Why?

1. Background & Context

Step-Code adopts a microkernel architecture where even first-party capabilities are packaged as extensions. In packages/coding-agent/src/features/step-capabilities.ts, key features such as workflow, step-plan, step-tasks, subagent, and clarify_user are registered dynamically via InlineExtension (createStepCapabilitiesExtension) calling pi.registerTool().

2. The Architectural Paradox: Lost Provenance

While registering first-party capabilities through extensions provides clean modularity, tool provenance is completely erased once tools enter the runtime pool:

  • In packages/coding-agent/src/core/extensions/types.ts, ToolCallEvent statically distinguishes events (BashToolCallEvent, ReadToolCallEvent, etc., while dynamic tools collapse into CustomToolCallEvent).
  • However, when entering the security gate in packages/coding-agent/src/step/permissions.ts, decideStepToolCall(toolName: string, ...) only accepts a flat toolName: string.
  • As a result, the permission engine cannot determine whether a tool called workflow was registered by the trusted internal QuickJS runtime or an unvetted third-party MCP server.

3. The Irony of Defensive Heuristics

Because decideStepToolCall has zero provenance context, it applies a blanket heuristic via extractCommand(): any parameter named command, cmd, or script across all tools is assumed to carry a host shell command and fed into the Bash AST parser (analyzeCommandPolicy).

  • The Irony: The workflow tool was engineered with a pure-JavaScript QuickJS WebAssembly engine (src/features/workflow/vm.ts) precisely so agents can run multi-step algorithms in an isolated memory sandbox without host shell access. Yet the permission layer treats JavaScript statements inside workflow.script as unanalyzable or hazardous Bash commands under Approve for Me.
  • The Point-fix Limitation: PR fix(coding-agent): stop analyzing workflow scripts as shell commands #317 resolved the immediate false-positive by introducing NON_SHELL_SCRIPT_TOOLS = new Set(["workflow"]). While this fixed the bug, it introduced a new anti-pattern: the core permission layer now hardcodes the names of specific extensions, defeating the decoupled spirit of the microkernel.

4. Lessons from Modern Extensible Architectures

Modern extensible architectures (such as VS Code's distinction between Built-in Extensions and Marketplace Extensions, or Deno/Browser permission origins) separate trust boundaries by provenance:

  • Trusted first-party extensions have defined, audited execution models.
  • Third-party MCP servers are strictly zero-trust.
  • Merging both into an indistinguishable namespace forces defensive heuristics onto tools whose safety profile is already known to the core engine.

How?

We propose a progressive, backward-compatible migration path:

Step 1: Extend Tool Definitions & Event Contracts

Add an optional origin property to ToolDefinitionBase and ToolCallEventBase:

export type ToolOrigin = "core" | "builtin" | "extension" | "mcp";

export interface ToolDefinitionBase {
  name: string;
  description: string;
  origin?: ToolOrigin;
  // Optional capability manifest for future extensibility:
  capabilities?: {
    shellExecution?: boolean;
    filesystemAccess?: "none" | "read" | "write";
  };
}
  • In step-capabilities.ts, internal extensions automatically register with origin: "builtin".
  • In MCP catalog loaders (packages/coding-agent/src/core/mcp), tools loaded from MCP servers automatically register with origin: "mcp".
  • User extensions discovered in ~/.stepcode/agent/extensions/ receive origin: "extension".

Step 2: Flow Provenance into decideStepToolCall

Update StepPermissionController.handleToolCall:

const decision = decideStepToolCall(
  event.toolName,
  input,
  state,
  this.toolOverrides,
  this.shellContext(),
  event.origin // or pass full tool metadata object
);

Step 3: Branch Policy Enforcement by Trust Boundary

In permissions.ts, replace hardcoded sets with origin-aware policy routing:

function extractCommand(toolName: string, input: Record<string, unknown>, origin?: ToolOrigin): string | undefined {
  // Built-in tools only extract commands if they are dedicated shell tools
  if (origin === "builtin" || origin === "core") {
    if (toolName === "run_command" || toolName === "bash" || toolName === "powershell") {
      return typeof input.command === "string" ? input.command : undefined;
    }
    return undefined; // Sandbox tools like workflow bypass shell heuristics entirely
  }

  // External extensions & third-party MCP tools remain under strict defensive heuristics
  for (const key of ["command", "cmd", "script"]) {
    if (typeof input[key] === "string") return input[key] as string;
  }
  return undefined;
}

Step 4: Backward Compatibility Guarantee

  • If origin is omitted (legacy custom tools or standalone test fixtures), it defaults to "extension" (conservative / fail-closed mode).
  • Existing toolOverrides and user-configured whitelist policies remain 100% compatible.
  • Zero breaking changes to the external extension API.

Activity

  1. self-assigned this
    on Oct 29, 2026
  2. li-xiu-qi commented on Oct 30, 2026

    @li-xiu-qi
    CollaboratorAuthor

    Draft implementation: #328.

    This implements the provenance-based design proposed here and also covers the original workflow false positive tracked in #239. It supersedes the closed, unmerged point fix in #317.

    • Add optional core / builtin / extension / mcp provenance to tool definitions and tool_call events, and propagate it into permission evaluation.
    • Run shell analysis for trusted core and built-in tools only when they are dedicated shell tools (run_command, bash, or powershell). Other structured inputs are not reinterpreted as shell commands.
    • Retain conservative scanning for MCP, extension, and unknown-origin calls. Disk extensions and ordinary SDK custom tools cannot self-assign a trusted tier.

    Validation: the origin regression suite passed 17 / 17 tests, pnpm check passed, and full GitHub CI passed on final commit f7386db5. The PR remains a draft pending code and description review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions