Skip to content

feat(permission): contextual approval with token-aware prefix matching#3076

Open
GroovyCarrot wants to merge 1 commit into
charmbracelet:mainfrom
GroovyCarrot:feature/contextual-tool-permissions
Open

feat(permission): contextual approval with token-aware prefix matching#3076
GroovyCarrot wants to merge 1 commit into
charmbracelet:mainfrom
GroovyCarrot:feature/contextual-tool-permissions

Conversation

@GroovyCarrot

@GroovyCarrot GroovyCarrot commented Jun 4, 2026

Copy link
Copy Markdown

#3077

What This Does

It replaces the old blanket "Allow for session" permission model with granular, context-aware approvals for bash commands.

How it works

When you run a command like cd /tmp && ls ./src && pwd , the system now:

  1. Parses the command into individual context tokens:
    command:cd , command:ls , command:pwd
    path:/tmp , path:/repo/src
  2. Records per-token grants — when you click "Allow for session", it stores each token independently, not as a single blanket approval
  3. Auto-approves future requests where all tokens are already granted:
    • Running ls /repo/src alone → ✅ auto-approved (both command:ls and path:/repo/src were approved)
    • Running pwd alone → ✅ auto-approved ( command:pwd was approved)
    • Running ls /other → ❌ still prompts ( path:/other was never approved)
  4. Composes conservatively — a command auto-approves only when both its command tokens AND its path tokens are all approved. Approving rm /tmp/x does not auto-approve rm /repo .
  5. Supports config-level allowlists — allowed_commands and allowed_paths in crush.json work the same way, using prefix matching ( command:go satisfies command:go test ).

Unsafe constructs (command substitution $() , backticks, redirects, sh -c , eval , loops, conditionals) are fail-closed — they emit an opaque command!:... token that can only be approved by exact match, never auto-approved. I will probably try to improve on this if I find it occurs frequently enough and without good reason but these scenarios only usually trigger for me when doing some analysis / data collection exercise, or a refactor that affects many files, and in these cases I don't mind just hitting approve.

Changes to the Permission Package

The crux of the changes to the permission system is effectively just adding optional context to the tool permission request and recording that context when it is approved for the duration of the session.

What Changed

Request model — CreatePermissionRequest and PermissionRequest now carry a Contexts []string field. This is opaque to the service — it just checks whether every context token
is satisfied. Tools that don't use it leave it empty.

Session grants — PermissionKey gained a Context string field. When a context-aware tool
is approved for the session, one key is stored per context token ( PermissionKey{SessionID, ToolName, Action, Context} ). Legacy tools still use the old key shape (
PermissionKey{SessionID, ToolName, Action, Path} ).

Resolution flow — Request() now has two auto-approval paths:

• If Contexts is non-empty: every token must be satisfied (by config tokens or prior
session grants)
• If Contexts is empty: falls back to legacy {ToolName, Action, Path} key lookup

Grant recording — GrantPersistent records one key per context token when Contexts is populated, otherwise records the legacy key. The Path field is intentionally omitted from contextual keys because location semantics are captured by path: tokens.

Config matching — allowed_commands and allowed_paths from crush.json are translated at startup into command: and path: tokens and checked first in contextSatisfied() .

Impact on bash tool

This is the primary beneficiary. Before, "Allow for session" on cd /tmp && ls ./src && pwd granted just any use of the bash tool.

Now, each constituent command and path is independently reusable. Approve the chain once, and ls /repo/src runs alone later with no prompt. This is the intended behaviour.

Impact on Other Tools

For edit , write , view , multiedit , etc. — no change at all. They never populate Contexts , so they fall through to the legacy {ToolName, Action, Path} lookup. The old "Allow for session" behaviour is preserved because the legacy lookup is still checked in step 6 of Request() .

What It Enables

This design is tool-agnostic. Any tool can start using Contexts without changing the permission service. If a future tool wants to grant file operations by directory, or Git operations by repository, it just emits the appropriate tokens. The service doesn't need to know what the tokens mean.


crush.json example

This is the relevant parts of the config I use

{
  "$schema": "https://charm.land/crush.json",
  "permissions": {
    "allowed_tools": [
      "multiedit",
      "view",
      "ls",
      "grep",
      "write",
      "edit"
    ],
    "allowed_commands": [
      "cd",
      "ls",
      "wc",
      "mkdir",
      "sed",
      "awk",
      "grep",
      "head",
      "tail",
      "find",
      "xargs",
      "tee",
      "touch",
      "go fmt",
      "go build",
      "go test",
      "go run",
      "go doc",
      "go list",
      "go vet",
      "go tool",
      "make",
      "yarn run",
      "yarn build",
      "yarn test",
      "yarn tsc",
      "git status",
      "git log",
      "git diff",
      "git show"
    ],
   "allowed_paths": [
     "/tmp"
   ]
}

Implements Phases 1–3 of the contextual permission redesign:

Phase 1 — Permission service (token-aware prefix matching):
- Add Contexts []string to CreatePermissionRequest / PermissionRequest
- Add PermissionKey.Context for per-token session grants
- GrantPersistent records one key per context token (Path omitted from
  contextual keys; location semantics live in path: tokens)
- Request() step 5: all contexts must be satisfied for auto-approval;
  step 6: legacy path-based key fallback for context-less tools
- Replace regex-based contextSatisfied with clean iteration over stored
  grants using tokenSatisfies helper

Phase 2 — context.go: tokenSatisfies helper:
- command:<A> satisfies command:<B> iff B == A or B starts with A+space
  (word boundary: command:go satisfies command:go test, not command:golang;
  command:py does not satisfy command:python3)
- path:<A> satisfies path:<B> iff B == A, or B starts with A+separator,
  or A is / (root satisfies all absolute paths)

Phase 3 — Bash tool produces contexts (bashctx.go):
- AnalyzeCommand parses command strings via mvdan.cc/sh AST walker
- Emits command:<name> and command:<name> <subcommand> tokens for every
  SimpleCommand in chains (&&, ||, ;) and pipelines (|)
- Emits path:<abs> tokens for every path argument, resolved against
  working dir (abs, relative, .., ~, quoted paths all handled)
- Fail-closed: command substitution, backticks, redirects, sh -c, eval,
  loops, conditionals, grouping, process substitution → single opaque
- Wired as Contexts: AnalyzeCommand(params.Command, execWorkingDir) in
  the permissions.Request call in bash.go

Phase 4 — config allowed_commands/allowed_paths to context tokens
- Add AllowedCommands []string and AllowedPaths []string to
  config.Permissions (json: allowed_commands / allowed_paths)

  allowed_commands: shell command names/subcommands (e.g. "go test",
  "git diff") — auto-approve requests for matching command: tokens;
  prefix matching applies ("go" also approves "go test", "go build")

  allowed_paths: filesystem paths (e.g. "/tmp", "/home/user/projects")
  — auto-approve requests for matching path: tokens; subpaths are also
  approved (path:/tmp approves path:/tmp/subdir). Relative paths are
  resolved against the working directory at startup.

- Add MakeCommandToken / MakePathToken to internal/agent/tools/bashctx.go;
  these are the canonical token constructors for the bash tool domain —
  both the AST walker in AnalyzeCommand and app.go config translation use
  them to ensure token format can never diverge

- Add allowedContexts []string to permissionService; contextSatisfied
  checks configured tokens via tokenSatisfies before session grants

- Update NewPermissionService to accept allowedContexts; all call sites
  updated (app.go, testing.go, agent and permission test files)

- app.go imports internal/agent/tools and constructs context tokens from
  AllowedCommands (MakeCommandToken) and AllowedPaths (MakePathToken +
  filepath resolution for relative paths)

- Add TestMakeCommandToken / TestMakePathToken to bashctx_test.go; add
  TestPermissionService_AllowedContexts to permission_test.go

Phase 5 — UI transparency — show only pending context tokens in dialog

When the permissions dialog is displayed for a bash tool request, context
tokens that are already approved by config or session grants are now omitted.
Only the truly new (pending) tokens are shown. A faint note shows how many
tokens were already approved when at least one is being omitted.

- Add PendingContexts []string to permission.PermissionRequest and
  proto.PermissionRequest (serialised for client-server mode)
- Populate PendingContexts in Request() by reusing the contextSatisfied
  check that already runs to decide auto-approval
- Add renderPendingContexts / parseContextToken helpers to the permissions
  dialog; update renderBashContent and renderDefaultContent to use them
- Update client_workspace.go to map PendingContexts across the wire boundary

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@charmcli

charmcli commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@GroovyCarrot

Copy link
Copy Markdown
Author

I have read the Contributor License Agreement (CLA) and hereby sign the CLA.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants