Specificity scores
When more than one allow or prompt entry in seal.toml matches the call the agent is making, the daemon picks the most specific one and follows that entry’s rule.
-
The pattern with the most literal tokens (everything before the trailing
*) wins. -
The recommended shape is a broad
prompt = truefor a whole tool family, with narrow silent allows for the specific commands you’ve decided are safe to run without asking:[capabilities.allow.commands]patterns = [{ command = "cargo:*", prompt = true },"cargo build:*","cargo test:*","cargo fmt:*",]Every cargo command prompts by default;
cargo build,cargo test,cargo fmtrun silently. Everything else undercargo:*—cargo add,cargo fetch,cargo install, anything the agent might reach for that you haven’t seen yet — falls through to the broad prompt and asks for approval. -
Order in
patterns = [...]doesn’t matter — the matcher always picks the most specific entry, not the first one it sees. -
You don’t have to write these layered grants by hand. The everyday workflow is: when a prompt fires for a subcommand you don’t want to see again, press Right in the prompt to narrow the suggested pattern to that subcommand, then pick
Allow always. The narrow silent allow lands inseal.tomlalongside whatever broader prompt was already there. See Permission model → Narrowing the suggested pattern. -
For a worked real-world example, see the seal project’s own
seal.toml— broadprompt = trueplus narrow silent allows acrossgit,cargo,gh,just,jj.
That’s the whole model in practice. The rest of this page is the exact rules, in case you need to predict an edge case.
Worked examples
Section titled “Worked examples”Broad prompt + narrow silent allows (recommended)
Section titled “Broad prompt + narrow silent allows (recommended)”[capabilities.allow.commands]patterns = [ { command = "cargo:*", prompt = true }, "cargo build:*", "cargo test:*", "cargo fmt:*",]cargo build,cargo test,cargo fmt→ silent allow (narrower match wins).- Everything else under
cargo:*→ prompts.
This is the safer default. New subcommands the agent might reach for don’t get a free pass — you see them on the way in and decide whether to add them to the silent set.
Broad allow + narrow prompts (not recommended)
Section titled “Broad allow + narrow prompts (not recommended)”[capabilities.allow.commands]patterns = [ "git:*", { command = "git reset --hard:*", prompt = true }, { command = "git push --force:*", prompt = true },]git status,git diff, etc. → silent.git reset --hard,git push --force→ prompt.
This shape works — the narrow prompts shadow the broad allow on the dangerous subcommands. But it’s brittle: every git subcommand the agent reaches for that you didn’t enumerate runs silently, including future subcommands you’ve never seen. The recommended shape (above) inverts this — silent is the exception, not the default.
Catch-all is weak
Section titled “Catch-all is weak”[capabilities.allow.commands]patterns = ["*"]The catch-all * matches everything but loses to any literal-prefix pattern, even a one-token one like cargo:*. It’s rarely the right call on the allow side; on the deny side it’s almost never what you want either.
The scoring rules
Section titled “The scoring rules”Every grant pattern is scored against the command body the daemon is dispatching:
| Score | When |
|---|---|
1000 | The pattern is an exact match for the command body. |
N × 100 | The pattern’s literal tokens form a prefix of the command body. Score = number of literal tokens. |
1 | The pattern is the catch-all "*". |
0 | The pattern doesn’t match this call. |
“Literal tokens” = everything in the pattern except the trailing *. cargo nextest:* has two literal tokens (cargo, nextest); cargo:* has one.
The daemon scores every matching grant, picks the highest, and follows that entry’s rule (silent allow, prompt, or — on the deny side — refuse).
Tie-breaking
Section titled “Tie-breaking”Two patterns can score the same — e.g. cargo build:* (silent) and cargo build:* (prompt = true) both scoring 200 against cargo build --release. When that happens:
- Higher score wins (the normal case).
- At an equal score, the
prompt = trueside wins. If you wrote a prompt at the same precision as a silent allow, the daemon assumes you meant the prompt — that’s the conservative choice. - At equal score AND equal prompt-class, source order wins. Earlier entries in
patterns = [...]beat later ones. Deterministic on otherwise-identical grants.
The prompt-wins-on-tie rule means a prompt = true entry can’t be silenced by an accidental duplicate silent allow at the same precision.
When nothing matches
Section titled “When nothing matches”If no allow grant matches and no deny grant matches, the call is “unknown”:
- With runtime expansion on (the default), you see a prompt for a new grant.
- With runtime expansion off (
[security] allow_runtime_expansion = false), the call fails immediately.
Deny rules don’t go through the picker — a matching deny always fails, no matter how high any allow scored.
File-grant resolution uses the same shape
Section titled “File-grant resolution uses the same shape”Filesystem allow grants under [capabilities.allow.read] and [capabilities.allow.write] resolve the same way: the daemon collects every allow grant whose pattern matches the path, scores each one, and picks the highest with the same tiebreaker chain (prompt wins on equal score, source order is the last resort).
A worked example — the layout from the issue body that motivated the rewrite:
[capabilities.allow.write]paths = [ { path = "~/.config/**", prompt = true }, # broad fence "~/.config/seal/**", # narrow exception]Writing ~/.config/seal/config.toml matches both entries; the narrower ~/.config/seal/** outscores the broader ~/.config/** so the write silently allows. Swap their order in the list — the narrow one still wins because the picker is order-independent.
The fs scoring formula weights leading literal /-segments (multiplied by 1,000,000) plus the total literal-byte count anywhere in the pattern, so an additional leading segment always dominates any byte-count difference. Trailing-literal patterns like **/*.md are still rewarded for the .md suffix even though it appears after a glob — <dir>/**/*.md outscores <dir>/**/* for files matching both — but a single extra leading segment beats any volume of trailing literal bytes.
Deny on the fs side stays first-match-wins — a deny.read / deny.write hit is a hard stop and scoring it adds nothing.
Why most-specific-wins, not first-match-wins
Section titled “Why most-specific-wins, not first-match-wins”First-match scanning looks simpler but breaks once a manifest grows. You can’t remember whether you put the broad prompt = true above or below the narrow silent allow; reordering the file changes behaviour silently.
Most-specific-wins is order-independent in the common case. The narrow silent allow always wins against the broad tool:* prompt regardless of where you put it. Source order only matters as a tie-breaker for otherwise-identical grants — and at that point the file is clearly the source of truth.
Where this lives in code
Section titled “Where this lives in code”- Picker (exec):
crates/seal-authorization/src/command.rs→pick_highest_specificity_allow. - Picker (fs):
crates/seal-authorization/src/grants.rs→pick_highest_specificity_fs_allow. - Scorer (exec):
crates/seal-policy/src/pattern.rs→specificity_score. - Scorer (fs):
crates/seal-policy/src/pattern.rs→fs_specificity_score. - Tests:
crates/seal-authorization/src/tests/grant_priority.rspins every branch.