Skip to content
Permissions

Permissions

Permissions

The permission gate is the central chokepoint consulted before every tool call. It enforces three things in order:

  1. A built-in bash denylist that’s non-overridable (even yolo mode can’t run rm -rf /).
  2. A path scope check for file tools — out-of-scope reads/writes either prompt the user or fail.
  3. The mode + allow/deny patterns from .agents/config.json.

Modes

ModeBehavior
ask (default)Allowlisted calls pass automatically; everything else prompts the user via the configured Prompter. With no Prompter, prompts fail closed with a clear error.
allowOnly allowlisted calls pass. Everything else is rejected without prompting — useful for headless / automated runs.
yoloAll calls pass except those caught by the bash denylist or a deny-pattern. Use with care; intended for trusted local dev.

Set via .agents/config.json:

{
  "permissions": {
    "mode": "ask",
    "allow": ["bash:git status", "bash:git log*"],
    "deny":  ["bash:sudo *"]
  }
}

Or programmatically when constructing the gate:

gate := permissions.New(permissions.Options{
    Mode:     permissions.ModeAllow,
    Policy:   policy,
    Scope:    scope,
    Prompter: nil, // headless
})

Pattern grammar

Patterns appear in permissions.allow and permissions.deny. Two forms:

<tool>:<glob>     applies only when the request is for <tool>
<glob>            applies to any tool (matched against the request key)

The <glob> uses path/filepath.Match semantics, so it understands *, ?, and character classes. Two convenience extensions:

  • Exact match comes first: a pattern with no wildcards matches the literal key only (so bash:git status matches the literal command, not git statusabc).
  • Open prefix for trailing *: bash:git diff* matches git diff, git diff main..HEAD, etc.

Examples:

PatternMatches
bash:git statusexactly git status
bash:git *any bash command starting with git
read_file:internal/**any read_file call with a key starting with internal/
mcp:filesystem_read_filethe namespaced MCP filesystem read tool
skill:jira-triageinvocation of the jira-triage skill
*foo*anything (any tool) whose key contains foo

Deny always wins. A deny pattern matched anywhere kills the call, even if an allow pattern also matches.

The “key” of a request is tool-specific:

  • For bash: the trimmed command string.
  • For file tools (read_file, write_file, edit_file, list_dir): the resolved absolute path.
  • For MCP / skill calls: <tool_name> <json-args> (truncated at 200 chars).

The bash, read_file, write_file, edit_file, list_dir, and todo tool names refer to the built-in tools that ship with core-agent and are enabled by default in the bundled CLI. Use the same names in allow/deny patterns whether you keep the defaults or supply your own implementations under those names.


Path scope

File tools may only touch paths inside the project root, the user-home root, or any explicit pattern in path_scope.allow. Out-of-scope access either prompts (in ask mode with a Prompter) or fails (everywhere else).

{
  "path_scope": {
    "allow": [
      "/etc/myapp/...",
      "/var/log/myapp.log",
      "~/scratch/*.json"
    ]
  }
}

Pattern syntax:

FormMeaning
Exact absolute pathOnly that file.
Directory tree ending /...Anything at or under that root.
Standard path/filepath.Match globGlob match against absolute paths.
Leading ~ or ~/Expanded to os.UserHomeDir().

Symlinks are not followed — the input path is trusted as-is.


Bash denylist

A small set of patterns are rejected for any bash call, in any mode, regardless of allow/deny config. These cover the most reliably destructive shell forms:

  • rm -r -f (in any flag-order combination) targeting /, ~, $HOME, etc.
  • dd if=… of=/dev/…
  • mkfs.*, shred …, wipefs …
  • chmod -R <mode> / and chown -R <user> /
  • curl|wget … | sh|bash|zsh|ash|dash (download-and-execute)
  • The classic fork bomb :(){ :|: & };:

This list is intentionally conservative — it’s not a complete bash sandbox, just a refusal list for the patterns most likely to brick a system by accident.


In-session decisions

When ask mode prompts the user, the Prompter returns one of:

DecisionEffect
DecisionDenyReject this call.
DecisionAllowOnceAllow this call; prompt again next time the same call is made.
DecisionAllowSessionAllow this exact request for the rest of the session — same (tool, key) pair won’t re-prompt.
DecisionAllowSessionToolTrust the entire tool for the rest of the session — every call to it passes regardless of args.
DecisionAllowAlwaysAllow + caller persists a permanent allowlist entry. The gate also remembers it for the rest of the session so persistence latency doesn’t cause a re-prompt.

DecisionAllowSessionTool short-circuits the path-scope check too — once you trust read_file for the session, even out-of-scope reads pass without re-prompting. This is the affordance that prevents the “modal-soup” anti-pattern from wide-ranging tool use.


Recommendations

After a session in ask mode, the gate exposes an audit log of every approval. permissions.Recommend(approvals) turns that log into a prioritized list of suggested permanent allowlist entries:

recs := permissions.Recommend(gate.Approvals())
permissions.SortRecommendations(recs)
for _, r := range recs {
    fmt.Printf("%-40s  %s\n", r.Pattern, r.Reason)
}

Heuristics built in:

  • A single approval becomes an exact pattern (bash:git status).
  • Multiple bash approvals sharing a leading verb collapse to a verb-glob (bash:git *).
  • Multiple file approvals sharing a directory prefix collapse to a directory glob (read_file:internal/tui/**).
  • Otherwise, a tool-wide suggestion (bash:*) is offered as a fallback the user can opt out of.

SortRecommendations puts non-wildcard patterns above wildcards so the safer recommendations surface first.


Implementing a Prompter

Hosts that can interact with the user implement the Prompter interface:

type Prompter interface {
    AskApproval(ctx context.Context, req PromptRequest) (Decision, error)
}

PromptRequest carries everything needed to render a prompt — kind (bash / file write / path scope / generic), tool name, detail string, and the persistence keys to write back if the user picks DecisionAllowAlways.

The bundled cmd/core-agent does not currently ship a Prompter — ask mode in the REPL fails closed. To use ask mode interactively, embed the library in your own host and supply a Prompter. See Library API → Prompter.


Headless / CI use

For non-interactive runs (CI, batch jobs), use:

{
  "permissions": {
    "mode": "allow",
    "allow": [
      "bash:go test ./...",
      "bash:go vet ./...",
      "read_file:**"
    ]
  }
}

mode: allow rejects anything not on the allowlist, which is what you want when there’s no human in the loop.


Bridging to ADK toolsets

Permission gating is bridged to ADK via the tools.GateToolset wrapper. It wraps any adktool.Toolset (an MCP server, a skills bundle, your own custom toolset) so each tool call goes through the gate before execution:

import (
    coretools "github.com/go-steer/core-agent/tools"
    "github.com/go-steer/core-agent/permissions"
)

gated := coretools.GateToolset(myToolset, gate, "my-namespace")

The namespace argument is the policy bucket — it’s what the allow/deny patterns use as the tool name (e.g. mcp:, skill:, or your own).


Auditing

Every non-deny approval is recorded in the gate’s session log:

for _, a := range gate.Approvals() {
    fmt.Printf("%s  %s  %s  %s\n", a.At.Format(time.RFC3339), a.Tool, a.Decision, a.Key)
}

This is the data source for Recommend(). It’s also useful for post-hoc auditing of what tool calls were approved during a run.