Skip to content

feat: per-spawn permission evaluator (execve-time gating via bashlex decomposition) - #1242

Open
samidarko wants to merge 3 commits into
anthropics:mainfrom
senara-solutions:feat/per-spawn-permission-evaluator
Open

feat: per-spawn permission evaluator (execve-time gating via bashlex decomposition)#1242
samidarko wants to merge 3 commits into
anthropics:mainfrom
senara-solutions:feat/per-spawn-permission-evaluator

Conversation

@samidarko

Copy link
Copy Markdown

Problem

The Bash tool runs a full shell command, which can be compositional: pipes,
&&/|| sequences, subshells, command substitution, and cd changing the
working directory mid-command. Classifying such a command by matching the raw
text with a regex or a substring blocklist produces both false positives (a
safe grep ... | awk '$1 > N' denied because it "looks" conditional) and false
negatives (a dangerous binary hidden past a pipe or a cd). A robust gate needs
to decompose the command into the individual processes it would actually spawn
and reason about each one.

Design

New opt-in module claude_agent_sdk.shell_permissions:

  • decompose(command, initial_cwd=None) parses the command with
    bashlex and walks the AST into a list
    of Spawn(binary, argv, cwd) objects, one per execve-level process
    invocation. A cwd stack tracks working-directory changes across the
    command (cd /etc && cat passwd records cat with cwd=/etc), and honors
    bash scoping — a cd inside a pipe segment or ( … ) subshell does not
    escape. Pipes, sequences (;, &, newline), logical operators, simple
    redirects, group commands { … }/( … ), and bounded command substitution
    are supported.
  • Fail-safe DENY. Any construct it cannot prove safe to decompose returns a
    named reason instead of a spawn list: heredocs/here-strings, process
    substitution, backticks, arithmetic expansion, control flow
    (if/for/while/case/select/until/functions), the dynamic-execution
    builtins (eval, source, ., exec), command substitution nested past
    MAX_SUBSTITUTION_DEPTH, a cd that needs runtime state (cd, cd -,
    cd ~, cd $VAR), a bashlex parse error, and any unrecognized node kind. It
    never silently allows.
  • evaluate(command, initial_cwd=None, policy=None) runs each spawn through
    a caller-supplied per-binary safety function fn(argv, cwd) -> bool. A binary
    with no registered function denies (all-must-pass, fail closed). The engine
    ships an empty DEFAULT_POLICY, so an unconfigured evaluator denies
    everything.
  • create_bash_permission_evaluator(policy, *, tool_name="Bash", command_key="command", env_var="CLAUDE_AGENT_SDK_SHELL_PERMISSIONS", initial_cwd=None, fallback=None) adapts the engine into a can_use_tool
    callback that returns PermissionResultAllow when every spawn is approved and
    PermissionResultDeny(message=reason) otherwise.

Backwards compatibility

Fully opt-in. The feature is a new module that changes nothing unless you wire
can_use_tool yourself, and even then the callback only evaluates when the
CLAUDE_AGENT_SDK_SHELL_PERMISSIONS environment variable is truthy; when unset
it defers to the provided fallback (or allows), so existing behavior is
preserved exactly. Pass env_var=None to opt out of the environment gate.
bashlex is an optional dependency (pip install "claude-agent-sdk[shell-permissions]"), imported lazily.

Test coverage

44 unit tests (tests/test_shell_permissions.py; the async ones run under both
asyncio and trio). Categories:

  • Decomposition of supported constructs: simple commands, pipelines, ;
    sequences, &&/||, redirects, pipe-to-conditional-awk, cd-then-command.
  • Fail-safe deny: heredoc, here-string, process substitution, backticks,
    arithmetic expansion, eval/source/./exec, control flow, malformed
    input; plus a single-quote literal-backtick negative case.
  • Command substitution: inner-spawn refusal and the depth bound.
  • State-tracking builtins / cwd tracking: absolute and relative cd, pipe
    isolation, rejected cd forms, non-spawning export.
  • Per-binary safety functions: empty-policy deny, allow, reject, all-must-pass,
    argv/cwd delivery, a synthetic read-only policy, empty command.
  • Plugin loader: bad ref, dict/callable load, wrong return type.
  • can_use_tool adapter: opt-in env gate (on/off), fallback deferral,
    allow/deny mapping, fail-safe deny surfaced to the caller, non-shell-tool and
    missing-command deferral, custom tool/command key.

Example policies in tests and in examples/shell_permission_evaluator.py are
synthetic and illustrative; the engine ships no policy of its own.

Add an opt-in permission evaluator that gates the Bash tool by decomposing
the shell command into the individual processes it would spawn, rather than
matching the raw command text with a regex or substring blocklist (which
misclassifies compositional shell: pipes, &&/||, subshells, command
substitution, and cd changing the working directory).

The new claude_agent_sdk.shell_permissions module:

- decompose(): parses a command with bashlex and walks it into a list of
  Spawn objects, tracking working-directory changes across the command with
  a cwd stack so path-relative checks stay correct. Any construct it cannot
  prove safe to decompose (heredocs, process substitution, backticks,
  arithmetic expansion, control flow, eval/source/exec) is denied fail-safe
  with a named reason.
- evaluate(): runs each spawn through a caller-supplied per-binary safety
  function; a missing entry denies (all-must-pass, fail closed).
- create_bash_permission_evaluator(): adapts the engine into a can_use_tool
  callback. It is opt-in via the CLAUDE_AGENT_SDK_SHELL_PERMISSIONS
  environment variable and defers to a fallback (or allows) when unset, so
  existing behavior is unchanged until explicitly enabled.

bashlex is an optional dependency, declared under the new
[shell-permissions] extra and imported lazily with a helpful error.
Add unit tests for both layers: the pure engine (decomposition of pipes,
sequences, logical operators, redirects; fail-safe deny on unsupported
constructs; cwd tracking across cd; per-binary safety functions and the
all-must-pass rule) and the can_use_tool adapter (the opt-in env gate,
allow/deny mapping onto the permission results, fail-safe deny surfaced to
the caller, and deferral for non-shell tools). Example policies are
synthetic and illustrative.
Add a README section and a runnable example showing how to build the
per-spawn Bash permission evaluator, register per-binary safety functions,
and enable it via the CLAUDE_AGENT_SDK_SHELL_PERMISSIONS environment
variable.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can fail open through shell mechanisms that never reach the policy. Redirects are skipped entirely, so cat x > sensitive or even > sensitive can be auto-approved, and assignments/state changes are also ignored, so PATH=/tmp/evil:$PATH grep ... is evaluated as the allowed binary grep even though Bash may execute /tmp/evil/grep. Because this is an auto-approval gate, could redirects and environment changes that affect executable resolution be modelled or fail closed?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants