Skip to content

Permission model

Seal’s threat model is “the agent might do something you don’t want.” Every tool call the agent issues passes through a capability check against seal.toml before it executes. If the call isn’t covered by an existing grant, you see a prompt — and your answer becomes part of the manifest from then on.

When a tool call needs a permission you haven’t granted, Seal shows a prompt with four choices:

ChoiceWhat happensWrites to seal.toml?
AllowThis call runs. The next matching call prompts again.Yes — adds the pattern with prompt = true.
Allow alwaysThis call runs. Future matching calls run silently.Yes — adds the pattern as a bare allow.
DenyThis call fails. Future matching calls prompt again.No.
Deny alwaysThis call fails. Future matching calls fail silently.Yes — adds the pattern under [capabilities.deny.*].

Three of the four write the pattern (and the choice) into your manifest and re-sign it. Only Deny is ephemeral.

Keyboard shortcuts: 1 / 2 / 3 / 4 for the four options, or up/down + Enter.

The prompt suggests a pattern to persist — not just the literal command you ran. For cargo nextest run -p seal-policy, the daemon’s default suggestion is cargo nextest:* (the recommended granularity). The pattern shows at the top of the prompt; you can adjust it before committing your choice.

Left / Right arrows walk the pattern across “scale levels” from broadest to narrowest:

cargo:*
cargo nextest:*
cargo nextest run:*
cargo nextest run -p seal-policy
  • Right narrows: more literal tokens, fewer matches.
  • Left broadens: fewer literal tokens, more matches.
  • Home / Cmd+Left jumps to the broadest pattern.
  • End / Cmd+Right jumps to the narrowest (the exact command).
  • Tab snaps back to the recommended level (the daemon’s suggestion).

Whichever level is showing when you press Allow always is what lands in seal.toml. The everyday flow has two distinct moves:

  • Narrow + Allow always — for a subcommand you’ve decided is safe (cargo nextest:*, git status), pick the precise level and Allow always. That lands the silent allow for the chosen scope.
  • Broad + Allow — to set up a “prompt by default” floor for a whole tool family, broaden to cargo:* (or git:*, etc.) and pick Allow (not Allow always). That writes the broad pattern with prompt = true, runs this call once now, and re-prompts every future subcommand at its own specific level.

Don’t broaden + Allow always. A broad silent allow like cargo:* covers cargo install, cargo publish, cargo yank, and every future subcommand the agent might reach for — none of which you’ve seen yet. The two moves combine into the layered shape below: one broad prompt = true per tool family with narrow silent allows on top.

For filesystem and env-var prompts the pattern toggle is simpler — Tab flips between the narrow form (SEAL_BACKEND, src/main.rs) and the broad form (SEAL_*, src/**/*.rs).

Command patterns are matched per whitespace-separated token. Two wildcard mechanisms compose:

  • Trailing :* allows any additional arguments after the named tokens. cargo test:* matches cargo test, cargo test --lib, cargo test -p seal-policy, etc.
  • * and ? inside a token match any sequence (or any single character) within that token. * is bounded to a single /-segment, so REST-style paths Just Work: gh api repos/me/zireael/pulls/*/comments matches …/pulls/54/comments and …/pulls/12345/comments but not …/pulls/54/files (different last segment) or …/pulls/54/extra/comments (extra segment).
  • ** is the deep-wildcard escape hatch. Unlike *, ** crosses / and matches arbitrarily many path segments. gh api repos/**/comments matches repos/owner/repo/pulls/54/comments as well as repos/owner/repo/comments. Use it when you genuinely want a recursive match; reach for * instead when you mean “exactly one segment.”
  • [ and ] are literal, not character-class syntax. A pattern like cmd arr[0] matches the literal token arr[0] only — Seal short-circuits glob’s bracket-class semantics inside command grants so agent-emitted tokens with brackets don’t get reinterpreted as regex-style ranges.

Per-token globs are intended for hand-written grants where you want to authorize a family of REST-shaped calls without enumerating them. The “Allow always” prompt never auto-generates them — its suggestions stay literal, and you broaden by hand-editing seal.toml when the literal pattern is wrong.

Two-way (“Allow / Deny”) is the obvious shape, but it conflates two independent questions:

  1. Do you want this call to run right now?
  2. Do you want future matching calls to run without asking?

A user who’s exploring an unfamiliar codebase usually wants “yes, this call, but ask me again next time” — that’s Allow (the once variant). A user who’s done auditing a particular pattern wants “yes, and stop asking” — that’s Allow always. The same split applies on the deny side: Deny for “no, but I might still permit it later,” Deny always for “never let this happen again in this project.”

Both run the current call. Both write to seal.toml. The difference is the prompt flag on the entry:

[capabilities.allow.commands]
patterns = [
"cargo fmt:*", # Allow always — silent
{ command = "git push:*", prompt = true }, # Allow once — asks again
]

The pattern still lands in the manifest after Allow — the agent can see it on the next call — but because prompt = true, the daemon fires the prompt every time. Picking Allow always later flips that flag to false (the bare-string form) instead of adding a duplicate entry.

Every write to seal.toml is followed by a re-sign. Seal stores a separate signed-manifest hash in your seal data dir; the next time the agent starts, that hash is compared against the on-disk manifest. If they match, the session proceeds without asking you to re-approve. If they don’t — because you hand-edited seal.toml, or the file got reverted — the next session opens with a manifest-approval prompt before any agent traffic.

The same check fires mid-session. When a permission prompt comes up and your choice would normally write a new grant into seal.toml, the daemon first compares the on-disk manifest against the last signed hash. If you hand-edited the file since the last sign, you get a manifest-approval prompt with the diff before the new grant lands. This prevents the new grant from quietly riding along with whatever other changes you made — every batch of mutations to seal.toml goes through an explicit re-sign with the diff visible.

This means the manifest you end up with after a few sessions is a record of decisions you’ve made, not a static checklist. Prompts keep firing on the patterns you decided should prompt — for most users that’s destructive operations, anything that mutates remote state, and any subcommand you haven’t pre-cleared in the narrow silent-allow set. How much friction this creates depends on where you draw the line; see Getting started → Daily-use posture for the tradeoff.

The daemon reads seal.toml once when a session starts and caches that snapshot for the duration of the session. Edits you make in your own editor mid-session don’t hot-reload — they take effect on the next seal launch (or via /reload from inside the TUI, which forces a re-read + re-approve). On the next launch, if the on-disk content has changed since the last signed hash, the startup flow shows you the diff and asks you to re-sign before any agent traffic.

Writes to the project’s own seal.toml are structurally blocked even when an explicit allow grant would cover them. The agent cannot grant itself broader access by editing the policy file; the only paths that mutate seal.toml are:

  • A permission prompt where you pick Allow always / Deny always — those edits go through the daemon, with the new entry signed in the same step.
  • You editing it yourself in your editor — which only takes effect on the next session (or /reload), and shows you the diff before re-signing.

The block applies to both the daemon’s file_edit / file_write paths and the OS sandbox: even if a manifest grant erroneously covered seal.toml, the daemon refuses the write before it reaches the kernel.

The same four-way decision applies to several non-command capabilities:

  • File reads: when the agent reads a file outside the existing read grants, you see a read prompt with a suggested pattern (e.g. src/**/*.rs).
  • File writes: same shape, against [capabilities.allow.write].
  • Network egress: when a command tries to reach a host not on its allowlist, you see a network prompt scoped to the command pattern that’s making the request — so cargo build reaching crates.io prompts separately from git push reaching github.com.
  • Environment variables: when a command_run invocation includes a NAME=VALUE prefix (e.g. RUST_LOG=debug cargo test), you see an env-var prompt suggesting an entry under default_env_vars.
  • .envrc files: when a command_run enters a directory with an .envrc file the project hasn’t approved yet, you see a trust prompt for the file’s content — three options (allow this body, trust all, disable wrapping).

Each prompt knows what section of seal.toml its decision belongs in; you don’t have to know the schema to answer correctly.

When more than one grant matches a call, the daemon picks the one with the most literal tokens in its pattern. The common shape is a broad prompt = true with narrow silent allows for the specific subcommands you’ve decided are safe:

[capabilities.allow.commands]
patterns = [
{ command = "cargo:*", prompt = true }, # every cargo call prompts by default
"cargo build:*", # except these three
"cargo test:*",
"cargo fmt:*",
]

cargo check matches only the broad pattern → prompts. cargo build matches both, the narrower pattern wins, and the build runs silently. New cargo subcommands the agent hasn’t reached for yet — cargo install, cargo publish, cargo yank — fall through to the broad prompt and surface for approval before they run; that’s the point of having the broad prompt = true as the floor.

The deny side is symmetric: a narrow prompt = true can sit alongside a broad allow without being shadowed, but the same trade-off applies — broad silent allows are almost never the right default because they hand new subcommands a free pass.

See Specificity scores for the exact scoring rules and tie-breakers.

By default — and for the foreseeable future — Seal runs with runtime grant expansion on: the agent can request any capability at runtime, and you decide via the prompt.

You can turn this off with [security] allow_runtime_expansion = false. With it off, every call the agent issues either matches an existing grant or fails immediately — no prompts. That’s the right mode for unattended runs (CI pipelines, batch workloads) where there’s nobody to answer prompts; for interactive use it’s almost always the wrong choice.