Skip to content

feat(security): enforceable execution permissions for shell and filesystem access (#1450) - #1506

Open
B-A-M-N wants to merge 1 commit into
CodebuffAI:mainfrom
B-A-M-N:security/1450-execution-permissions
Open

B-A-M-N wants to merge 1 commit into
CodebuffAI:mainfrom
B-A-M-N:security/1450-execution-permissions

Conversation

@B-A-M-N

@B-A-M-N B-A-M-N commented Oct 12, 2026

Copy link
Copy Markdown

What this does

Adds enforceable, user-controllable execution boundaries for shell commands and filesystem access, addressing #1450. The permission decision is enforced by the runtime, not delegated to the model.

Four CLI flags are wired through both products and enforced at the SDK tool-dispatch layer:

  • --no-shell — deny shell execution entirely
  • --ask-shell — require an explicit human approval before each command
  • --project-only — confine filesystem reads/writes to the project root
  • --read-only — deny write operations

Implementation

sdk/src/permissions/execution-policy.ts (new) — a per-run, immutable policy:

  • No module-global policy state. Each run builds its own ExecutionPolicy in runOnce() and captures it in the tool-dispatch closure, so two concurrent SDK runs cannot influence each other's permissions.
  • classifyTool() maps every tool to a capability (shell / fs-read / fs-write / network / agent / inert / external). Unrecognized names — MCP tools, custom tool definitions, host overrides — classify as external and are denied while any restriction is active unless the host opts in via allowExternalTools: true.
  • authorizeToolCall() is evaluated in handleToolCall() before any dispatch path (MCP, custom tools, overrides, built-ins), so none bypasses policy.
  • noShell denies shell unconditionally. readOnly and projectOnly also deny unrestricted shell execution, because an unrestricted shell can read/write anywhere outside the SDK's control. askShell requires a callback and denies when none is configured.
  • Containment is realpath-based (symlink-aware) and platform-correct (Windows case-insensitivity + backslash separator).
  • guardFileSystem() wraps the run's filesystem adapter as defense in depth: reads/writes outside the root (or any write in read-only mode) are rejected even if a dispatcher branch missed a check.

common/src/utils/shell-approval-bridge.ts (new) — correlated approval lifecycle. The existing AskUserBridge holds a single pending request, so a second concurrent request silently replaces the first. The new bridge correlates by unique id, serializes requests FIFO, and resolves deny on timeout, abort, or UI shutdown — never approve without an explicit matching submit.

sdk/src/run.ts — thread the policy through runOnce()/handleToolCall(); fix apply_patch target resolution to use operation.path (matching applyPatchTool()), which the earlier draft checked incorrectly.

CLI — flags in cli-args.ts; approval callback bound to the bridge in codebuff-client.ts; approvals surfaced through the existing question form.

Why

Reported in #1450: the agent could run shell commands and reach files outside the project with no user-configurable gate, and destructive operations had to be recovered after the fact. Prompting the model not to do this is not enforcement.

Verification

  • 34 tests passing: 25 in sdk/src/permissions/__tests__/execution-policy.test.ts (per-run isolation, shell modes incl. background, fs modes, external-tool denial, symlink escape, fs-adapter guard, Windows containment) and 9 in common/src/utils/__tests__/shell-approval-bridge.test.ts (id matching, timeout, abort, concurrent requests, queued-id rejection, shutdown cancel, stale approval).
  • tsc --noEmit on sdk and common: no new type errors in modified files. Three pre-existing errors in untouched files (common/src/util/messages.ts, packages/agent-runtime/src/tools/stream-parser.ts, sdk/src/impl/llm.ts) are unrelated AI-SDK type mismatches and are present on main.
  • CLI flag parsing verified against parseArgs for both flag states.

Known limitations (not yet done)

  • No OS-level sandboxing. --read-only and --project-only deny unrestricted shell rather than sandboxing it. Genuinely allowing shell under those modes needs an OS-enforced boundary (the repo's existing sponsored-sandbox / sponsored-rooted-filesystem primitives are a candidate). This PR does not adopt them.
  • Windows end-to-end untested. The containment branch is unit-tested via injectable logic, but no real Windows run was performed.
  • No missing-sandbox refusal gate — depends on the sandbox direction above.
  • CLI end-to-end --no-shell test not included — the public mirror is missing the cli/test/setup-scm-loader.ts preload, so the CLI test suite does not boot here.

Scope note

I'd rather not guess at the sandbox direction. If maintainers want the OS-level containment pursued (reusing the existing sandbox primitives), say so and I'll follow that shape rather than inventing a second mechanism.


Inference for this work was provided by FreeInference.org. FreeInference did not direct, review, or sponsor this contribution; they provide inference to researchers and others who might not otherwise have access. If anything here was useful, consider sponsoring or contributing to FreeInference.org.

…tem (refs CodebuffAI#1450)

Adds user-controllable, enforced execution boundaries so the agent cannot run
arbitrary shell commands or touch files outside the project by default policy,
rather than relying on model instructions.

New per-run, immutable ExecutionPolicy (sdk/src/permissions/execution-policy.ts):
  - no module-global policy state (concurrent runs cannot cross-contaminate)
  - classifyTool() maps every tool to a capability; unknown names (MCP, custom,
    overrides) are 'external' and denied under strict policies unless the host
    opts in via allowExternalTools
  - authorizeToolCall() evaluated before ANY dispatch path in handleToolCall
  - noShell denies shell; readOnly/projectOnly deny unrestricted shell;
    askShell requires a correlated approval callback
  - realpath-based, symlink-aware containment + platform-correct (Windows)
    case/separator handling
  - guardFileSystem() wraps the run's fs adapter as defense in depth

CLI: --no-shell, --ask-shell, --project-only, --read-only (both products).
Approval lifecycle: new correlated shell-approval bridge (unique ids, FIFO,
timeout/abort/shutdown -> deny, never an implicit approve).

Fixes apply_patch target resolution (operation.path, matching applyPatchTool).

Tests: 34 passing (25 policy + 9 approval-bridge).
@codebuff-team

Copy link
Copy Markdown
Contributor

Good instincts here: moving enforcement out of the model and into the runtime is the right call, the no-global-policy-state design is correct for an embeddable SDK, the symlink-aware canonicalizePath/isPathWithinRoot and Windows-case-insensitive containment logic are well thought out, and you shipped real adversarial tests (execution-policy.test.ts, shell-approval-bridge.test.ts) instead of hand-waving. The apply_patch fix in run.ts (using operation.path) is a believable, narrow bug fix on its own.

Three things that need work before this is portable as-is:

  1. classifyTool() is a closed allowlist (execution-policy.ts). Any built-in tool not in that switch silently becomes external and gets denied the moment any restriction flag is set. That's a correctness landmine every time a new tool is added to the SDK — a maintainer has to remember to update this file or a legitimate tool quietly breaks under --read-only/--project-only/--ask-shell. This needs to be derived from (or validated against) the actual tool registry, not a hand-maintained list.

  2. --read-only/--project-only disable shell entirely, not just unrestricted writes/escapes. That's defensible as a stopgap, but it's a large behavior surprise for users who expect --read-only to still allow git diff/ls/cat via shell. Worth calling out explicitly as a known limitation or scoping shell commands instead of blanket-denying.

  3. Sub-agent spawning (spawn_agents/spawn_agent_inline) classifies as agent and is always allowed, and nothing in the diff shows the run's ExecutionPolicy being threaded into whatever spawns a sub-agent's own runOnce. If a spawned sub-agent doesn't inherit noShell/readOnly/projectOnly, this is a straightforward bypass of the entire feature. This needs to be confirmed and, if true, fixed before this can be called "enforced."

Given the size (+1337/-10) and that it rewrites a security-critical dispatch path in run.ts, I'd want this split and the sub-agent propagation question answered before porting.

@codebuff-team codebuff-team added bot:triaged Classified by the community triage bot pr:needs-work Right idea, not mergeable as written labels Oct 12, 2026

This branch has not been deployed

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

Labels

bot:triaged Classified by the community triage bot pr:needs-work Right idea, not mergeable as written

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants