Skip to content

feat(interp): add AllowedCommandPatterns argv-prefix allowlist#222

Draft
matt-dz wants to merge 4 commits intomainfrom
mdeguzman/allowed-command-patterns
Draft

feat(interp): add AllowedCommandPatterns argv-prefix allowlist#222
matt-dz wants to merge 4 commits intomainfrom
mdeguzman/allowed-command-patterns

Conversation

@matt-dz
Copy link
Copy Markdown
Collaborator

@matt-dz matt-dz commented May 4, 2026

Summary

Introduces a second command permit axis alongside AllowedCommands. Each AllowedCommandPatterns entry is a token list; an invocation whose argv begins with the same tokens is allowed. Patterns are matched after shell expansion, so command substitution cannot bypass enforcement — the matcher inspects the resolved argv at execve time.

The two axes are joined by union: a command runs if its name is in AllowedCommands OR its argv prefix-matches any pattern. This lets operators express "allow kubectl get but not kubectl delete" without having to allow kubectl wholesale.

Why

Built as a POC for an rshell permission-model RFC. The motivating example is the canonical substitution attack:

$ ./rshell --allowed-command-patterns "echo hello" -c '$(printf echo) goodbye'
rshell: echo: command not allowed   # exit 127

The literal command text is opaque to any static caller. Only enforcement that runs after expansion (i.e. on the node, not the backend) can defend against this; this PR adds that primitive in the OSS interpreter.

What changed

Library API

  • New interp.AllowedCommandPatterns([][]string) RunnerOption with validation (non-empty patterns, non-empty tokens).
  • New (*Runner).argvMatchesAllowedPattern([]string) bool — six-line linear-scan, exact-token equality, no regex.
  • Existing builtins.CallContext.CommandAllowed callback signature extended from func(name string) bool to func(name string, args []string) bool. All consumers (find, help) updated; pattern matching now flows through find -exec/-execdir at both parse-time (unsubstituted argv with {} placeholders preserved) and eval-time (fully substituted argv).
  • help passes single-element argv []string{name}; documented cosmetic limitation: pattern-only multi-token commands don't appear in the help listing.

Enforcement points (all four)

  • runner_exec.go:call (top-level dispatch).
  • runner_exec.go:runCmd (recursive builtin dispatch; reconstructs full argv from cmdName + cmdArgs before consulting matcher).
  • Both CommandAllowed callback definitions in runner_exec.go (handed to builtins).

CLI

  • New --allowed-command-patterns flag: comma-separates patterns, whitespace-separates tokens within a pattern. e.g. --allowed-command-patterns "kubectl get,ls,echo hello".
  • Existing --allowed-commands and --allow-all-commands unchanged.

Tests

  • 13 unit tests in interp/allowed_command_patterns_test.go: option validation, basic matching, length edge cases, union semantics with AllowedCommands, and the architectural test TestPatternsBlockSubstitutionEscape that proves substitution can't bypass.
  • 6 YAML scenarios under tests/scenarios/shell/allowed_command_patterns/ covering the same cases through the existing TestShellScenarios harness.
  • 9 find test stubs updated to the new CommandAllowed signature.

Docs + examples

  • README.md Security Model table updated with the new option.
  • SHELL_FEATURES.md lists AllowedCommandPatterns.
  • examples/command_patterns.sh — 8 curated invocations stepping through allow/block/substitution-defeat/find -exec/union semantics.

Semantics summary

Configuration Behaviour
AllowedCommands: ["rshell:cat"] cat allowed for any args (existing).
AllowedCommandPatterns: [["kubectl","get"]] kubectl get * allowed; kubectl delete * blocked.
Both above set Either alone is sufficient — union of permits.
Neither set, no allow-all-commands Default-deny (existing).

Test plan

  • go test ./... — 45/45 packages pass, no failures.
  • bash examples/command_patterns.sh — all 8 cases produce documented outcomes.
  • Substitution-defeat test passes both as a Go unit test and a YAML scenario.
  • find -exec flows pattern matching through both parse-time and eval-time gates.
  • Reviewer should sanity-check the CommandAllowed signature change doesn't affect any consumer outside find and help (grepped, but worth a fresh pass).

Out of scope / follow-ups

  • Operator-side pattern allowlist intersection in the agent (POC treats backend as authoritative for patterns).
  • Surfacing partially-allowed commands in help listings when only patterns are configured.
  • CLI escape syntax for pattern tokens containing literal commas or whitespace (use the library API directly until then).

🤖 Generated with Claude Code

matt-dz and others added 4 commits May 4, 2026 16:33
Introduces a second permit axis alongside AllowedCommands. Each pattern
is a token list; an invocation whose argv begins with the same tokens
is allowed. Patterns are matched after shell expansion, so command
substitution cannot bypass enforcement — the matcher inspects the
resolved argv at execve time.

The two axes are joined by union: a command runs if its name is in
AllowedCommands OR its argv prefix-matches any pattern. This lets
operators express "allow kubectl get but not kubectl delete" without
having to allow kubectl wholesale.

Threaded through the existing builtin policy callback by extending
CommandAllowed(name string, args []string), so find -exec/-execdir
also enforce patterns at both parse time (with unsubstituted argv) and
eval time (with the substituted argv). help passes a single-element
argv; multi-token-pattern-only commands therefore don't appear in the
help listing — documented cosmetic limitation.

Adds:
  - interp.AllowedCommandPatterns([][]string) RunnerOption
  - --allowed-command-patterns CLI flag (comma-separated patterns,
    space-separated tokens)
  - 13 unit tests + 6 YAML scenarios, including the architectural
    substitution-defeat case
  - examples/command_patterns.sh with 8 curated invocations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ndSpec

Replaces the original argv-prefix matcher with a spec-driven structural
matcher. A pattern is now shaped (command [, subcommand_path...]) and
matched against the LEADING STRUCTURAL TOKENS of argv[1..] (where
"structural" means non-flag tokens, classified per a registered
CommandSpec). This closes a semantic hole in the previous matcher:
positional argument values that happened to equal a pattern subcommand
token would satisfy the pattern even when the operation wasn't the one
the policy author intended.

Architectural model:
  - CommandSpec describes a command's flag conventions (BooleanFlags,
    ValueFlags). The matcher uses the spec to skip flag tokens (and
    their values) when extracting structural tokens from argv.
  - Multi-token patterns require a CommandSpec for their command;
    single-token patterns do not (they only check argv[0]).
  - Validation runs at New() after all options applied, so option
    order doesn't matter.
  - The built-in registry ships only `ip` (the only rshell builtin
    with multi-token subcommand structure). Integrators register
    additional specs via interp.CommandSpecs(...).

Behaviour vs. previous (presence-based) matcher:
  - "ip route show" matches (ip, route): SAME.
  - "ip -4 route show" matches (ip, route): SAME (flag interleaving).
  - "ip addr show" rejected by (ip, route): SAME.
  - "ip addr show route" rejected by (ip, route): NEW — previously
    matched because "route" appeared anywhere in argv. Now blocked
    because the leading structural token after argv[0] is "addr".

CLI/API surface:
  - New: interp.CommandSpec, interp.CommandSpecs(...) RunnerOption.
  - New: --allowed-command-patterns CLI flag was added in the prior
    commit on this branch; nothing to change there.
  - Examples (examples/command_patterns.sh) now use `ip` as the
    canonical multi-token-pattern target.
  - YAML scenarios rewritten to use `ip`.
  - 24 unit tests covering option validation, structural matching,
    flag classification (boolean, value, key=value forms, unknown
    flags), and the architectural substitution-defeat case.

Allowlist of strings.Contains added to analysis/symbols_interp.go for
the "--key=value" detection in extractStructuralTokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an invocation is rejected by the policy gate, print the FULL
attempted invocation (not just the command name) plus a hint listing
the patterns configured for that command name. This turns

  rshell: ip: command not allowed

into

  rshell: ip addr show: invocation not permitted by policy (command name: ip)
    hint: allowed patterns for "ip": 'ip route'

so operators can immediately see what was attempted and what would
have been admissible. The bare-command case (script "ip" with no
args) keeps the original short format for back-compat readability.

Updates the few test scenarios and unit tests that asserted the old
exact-string error to match the new format. strings.TrimRight added
to the analysis allowlist (used in runCmd's error wrapping).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a third pattern axis alongside AllowedCommands and
AllowedCommandPatterns: DeniedCommandPatterns blocks invocations whose
argv satisfies any of the given patterns, evaluated BEFORE every allow
rule. A deny match short-circuits the gate to a refusal even when
AllowAllCommands, a name allowlist, or an allow pattern would
otherwise admit the call.

Use case: an operator can allow `ip` wholesale via AllowedCommands but
carve out `ip route` specifically via DeniedCommandPatterns. ip addr
and ip link still admit; ip route is refused at the gate.

Same pattern shape and matching semantics as AllowedCommandPatterns:
each pattern is a (command [, subcommand_path...]) token list, matched
against the argv's leading structural tokens using the same
spec-driven flag classification. Multi-token deny patterns require a
registered CommandSpec; option-time validation (renamed
validatePatternList, factored to cover both axes) rejects them
otherwise.

Architectural property carries over: a substitution that resolves at
runtime to a denied argv is blocked even though the literal command
text was opaque. Denies are inspected post-expansion at the same call
sites as allows.

Error messages now distinguish "blocked by deny pattern X" from "no
allow rule matched" so operators see which side rejected. The
help-suggestion footer is suppressed for deny denials since 'help'
won't reveal why the deny fired.

Adds:
  - interp.DeniedCommandPatterns([][]string) RunnerOption
  - --denied-command-patterns CLI flag
  - 9 new unit tests covering option validation, deny-overrides-name,
    deny-overrides-allow-pattern, post-expansion enforcement, and
    structural-position correctness (deny doesn't match positional
    values like the allow side doesn't)
  - 4 YAML scenarios under tests/scenarios/shell/denied_command_patterns/
  - 3 examples script cases (cases 11-13)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant