Skip to main content
Glama
pkravella

@mandate-dev/proxy

by pkravella

LeastPilot

LeastPilot gives an AI coding agent only the access its current task needs, and enforces that while the agent runs.

Tell it the task, say "Fix issue #42 and open a PR", and it writes down exactly what that task is allowed to touch: which repository, which branches and files, which actions, where data may go, and when it all expires. That written-down permission slip is called a mandate. A plain rule-based checker (no AI involved) proves the mandate doesn't go beyond what the person asking and the organization already allow. Then LeastPilot hands the agent a GitHub token limited to that one repository, puts a proxy between the agent and GitHub that checks every call against the mandate, and records everything it decided.


Status: 0.2.0 is on npm

It's on npm as @leastpilot/cli, published from CI with provenance that traces each package to the commit it was built from. The whole loop runs from one command: write a mandate, check it, get it approved, mint a scoped token, run the agent under it in a locked-down container, and keep a signed log.

It's been measured on six synthetic tasks, not the fifty the plan called for, and it misses three of its targets: task completion, how long writing a mandate takes, and how much it cuts a token's access when the token was already limited to one repository. See Benchmark, and the benchmark report, which lists its own shortfalls before any number.

The project was called Mandate until 2026-10-12. The product, the leastpilot command, the @leastpilot/* packages and the LEASTPILOT_* variables are new names. The thing it writes is still called a mandate.


Why this exists

Coding agents usually run with far more access than any one task needs. Today's ways of limiting them each fall short:

  1. Broad credentials. Agents tend to run on a developer's personal token or a wide GitHub App install. An agent fixing one issue can often merge pull requests, edit CI workflows, read secrets or touch other repositories.

  2. Policies written once. Tools like GitHub's MCP toolsets or GitHub Agentic Workflows let you write limits per agent or per workflow. Those are either too loose for most tasks or too tight for some.

  3. Asking a model to judge. Some products ask a language model, during the run, whether an action fits the task. A model can be talked out of its answer, and its decisions are hard to audit.

LeastPilot writes limits per task, checks them with ordinary code, and enforces them with controls GitHub already has (scoped App tokens) plus a proxy in front of GitHub's MCP server.

It does not try to detect prompt injection. It assumes an agent can be tricked, and limits what a tricked agent can do.

Related MCP server: telekey-mcp

How it works

Here's the mandate for "Fix issue #42 and open a PR":

mandate: fix-issue-42
task: "Fix issue #42 and open a PR"
requestedBy: user:alice        # the person asking, never the agent
expiresInMinutes: 60
ceiling: org-policy@v12        # the org policy it must fit inside
grants:
  - action: repo.read
    enforcedBy: token
    resources: ["acme/api"]
  - action: contents.write
    enforcedBy: proxy
    resources: ["acme/api"]
    branches: ["agent/42-*"]
    paths: ["src/**"]
    denyPaths: [".github/workflows/**", "**.env**"]
  - action: pull_request.create
    enforcedBy: proxy
    resources: ["acme/api"]
    branches: ["agent/42-*"]
    base: main
    max: 1
destinations:
  allow: ["github.com/acme/api"]

Anything not granted is denied. So this agent can read acme/api, write under src/ on its own agent/42-* branch (never a CI workflow or a .env file), and open one pull request into main. It can't merge, can't touch another repository, and can't send repository data anywhere but that repository.

A task goes through these steps:

  • Write. A model reads the task and proposes a mandate. It's treated as untrusted: its output is a proposal, and only the validator can turn a proposal into something the rest of the system will accept. You can also write a mandate by hand.

  • Validate. Plain code checks the proposal against two limits: the organization's ceiling (a policy file the operator writes, in Cedar) and the requester's real permission on the repository, read from GitHub. It also rejects risky shapes outright, like writing to the default branch or touching workflows and secrets. If a grant is too wide, the error names the ceiling rule it broke and gives an example it would have allowed.

  • Approve. The requester reads a permission diff of what's being granted. If you ask for it (--approval), nothing is minted until they approve that exact mandate by posting /leastpilot approve <digest> on the issue.

  • Mint. LeastPilot creates a GitHub App token limited to the one repository and the permissions the grants need, expiring within the hour.

  • Run. The agent's only route to GitHub is leastpilot serve, an MCP server that hides the tools the mandate doesn't grant and checks every call's repository, branch, base, file paths, destinations and count. A refused call names the rule it hit.

  • Widen. If the mandate turns out too narrow, a refused call can be turned into a request for more scope. That request goes back through the validator and the requester, never the agent.

  • Expire and log. The token is revoked at the end, and every call and decision is recorded, optionally in a signed log.

Two things about the checks matter more than they look:

  • The validator checks whole patterns, not examples. A grant like branches: ["agent/42-*"] stands for every branch name that matches it. Checking a few sample names would prove nothing about the rest, so the validator turns each pattern into a small state machine and proves one pattern's matches are a subset of the other's. That's why a refusal can hand you a specific counterexample.

  • A GitHub token can't limit branches or files. An App token can be limited to a set of repositories and permissions, and nothing finer. So every grant records what actually enforces it (token, proxy or sandbox), and docs/enforced-where.md lists what each layer covers and what gets around it.

System design

Two halves: everything that happens before the agent starts decides what it may do, and everything during the run holds it to that.

flowchart TB
    subgraph limits["What the validator checks against"]
        direction LR
        ceiling[("Org ceiling<br/>a Cedar policy")]
        level[("Requester's permission<br/>read from GitHub")]
    end

    subgraph before["Before the run: decide what the agent may do"]
        direction LR
        task["Task text"] --> writer["Writer<br/>a model, untrusted"]
        writer -- "proposal" --> validator{"Validator<br/>plain code"}
        validator -- "validated<br/>mandate" --> approve["Requester<br/>approves"]
    end

    subgraph compiled["What the compiler produces"]
        direction LR
        token["GitHub App token<br/>one repo, needed permissions"]
        rules["Proxy rules<br/>branches, paths, counts"]
        egress["Egress allowlist<br/>for the sandbox"]
        token ~~~ rules ~~~ egress
    end

    limits --> before
    before --> compiled

The line that matters is the validator. Everything above it is untrusted: task text, issue bodies, the model's proposal, an agent's plan. Everything below it only accepts a validated mandate, and the only code that can create one is the validator. That's enforced by the type system and by a lint rule, not by convention.

Here's a run in the sandbox. The agent's container holds no GitHub credential at all, and its only way out to the internet is an allowlist:

flowchart TB
    subgraph box["Sandbox container: no GitHub credential"]
        direction LR
        agent["Agent<br/>e.g. Claude Code"] -- "MCP" --> relay["Relay"]
        agent -- "everything else" --> squid["Egress proxy<br/>allowlist only"]
    end

    subgraph host["Your machine"]
        direction LR
        key[("App private key")] --> serve["leastpilot serve<br/>holds the token,<br/>checks every call"]
        serve --> log[("Signed<br/>audit log")]
        serve -- "allowed calls only" --> mcp["github-mcp-server<br/>pinned by digest"]
    end

    box -- "relay, with a secret handshake" --> host
    host --> github["GitHub API"]
    box -. "the mandate's destinations and the model's API;<br/>GitHub only through serve" .-> internet["Internet"]

Why the sandbox matters: the proxy only sees MCP calls. An agent with a shell could run git push or curl directly, or find a token somewhere on disk, and never touch the proxy. In the sandbox the agent works in a clean clone of your repository (no .git/config, no ignored .env), gets only the environment variables you name, and can only reach hosts on the allowlist. iptables rejects everything else, over IPv4 and IPv6, and if those rules can't be loaded the container exits instead of starting the agent. Without --sandbox, the agent runs as your user and could read the App's private key, so destination limits are advisory. docs/threat-model.md §3.1 goes through this.

The code is split into small packages, with the validator in the middle:

Package

What it does

@leastpilot/catalog

46 GitHub operations. For each: the REST route, the MCP tool, the App permission it needs, and how risky it is. Just data.

@leastpilot/schema

The mandate format, hashing, and the "validated" type only the validator can create.

@leastpilot/validator

The subset proofs, the Cedar ceiling, and the lints for risky mandates.

@leastpilot/writer

The model that proposes a mandate. Untrusted by design.

@leastpilot/compiler

Mints and revokes the scoped token, and turns a mandate into proxy rules, the egress allowlist and the gh-aw export.

@leastpilot/proxy

The MCP proxy: hides ungranted tools, checks every call, records decisions, and writes the signed audit log.

@leastpilot/replay

Replays a recorded run against a mandate and reports what it over- or under-granted.

@leastpilot/cli

The leastpilot command, and the sandbox's source.

@leastpilot/bench

Private. The benchmark.

More detail on how they fit together: docs/architecture.md.

Install

You'll need Node 22.12 or later:

npm install -g @leastpilot/cli

Where it runs unattended, install an exact version into a project and check the registry's signatures and provenance there:

npm install @leastpilot/cli@0.2.0 && npm audit signatures

Running an agent in the sandbox also needs Docker Desktop on macOS.

The examples below use the sample ceilings in this repository's fixtures/, so run them from a clone:

git clone https://github.com/pkravella/mandate && cd mandate

To build from source instead, run pnpm install && pnpm build in the clone and use node packages/cli/dist/index.js wherever the examples say leastpilot.

Try it

Check a mandate, offline and free. Save the example above as mandate.yaml, then:

leastpilot validate mandate.yaml \
  --ceiling fixtures/ceilings/org-policy-v12.cedar \
  --schema  fixtures/ceilings/schema.cedarschema \
  --ceiling-destinations fixtures/ceilings/org-policy-v12.destinations \
  --as alice --level push

It prints a permission diff and exits 0 if the mandate fits. If it doesn't, you get exit 1 and something like this:

Mandate rejected.

  [not-contained] in grant #0 (contents.write) grant contents.write is wider
    than ceiling clause allow-agent-branch-writes: it would permit "feature/42-"
      ceiling clause: allow-agent-branch-writes
      counterexample: feature/42-

Nothing was minted. Narrow the mandate, or request a ceiling change.

--level push asserts Alice's permission, and the output says so every time. To check it for real, use --repo acme/api instead, which reads it from GitHub using your App's credentials (LEASTPILOT_APP_ID, LEASTPILOT_INSTALLATION_ID, LEASTPILOT_APP_KEY_PATH). Only that version counts as a record of who was allowed to do what. There's no default, so you always pick one.

Run an agent under a mandate. leastpilot run checks the mandate, writes an MCP config pointing at leastpilot serve, and starts whatever agent you give it. {mcpConfig} is replaced with that config's path:

leastpilot run \
  --mandate mandate.yaml \
  --ceiling fixtures/ceilings/org-policy-v12.cedar \
  --schema  fixtures/ceilings/schema.cedarschema \
  --ceiling-destinations fixtures/ceilings/org-policy-v12.destinations \
  --as alice --repo acme/api --trace run.jsonl --diff \
  -- claude --mcp-config '{mcpConfig}' -p "Fix issue #42 and open a PR"

LeastPilot doesn't drive any particular agent. Every MCP client already knows how to start a server from a config, so being that server is the whole integration.

Run it in the sandbox. Build the base image, then an image with your agent in it, then add --sandbox:

leastpilot sandbox build
docker build -t mandate-agent-claude packages/cli/sandbox/examples/claude-code
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1

leastpilot run --mandate mandate.yaml \
  --ceiling fixtures/ceilings/sandbox-v1.cedar \
  --schema  fixtures/ceilings/schema.cedarschema \
  --ceiling-destinations fixtures/ceilings/sandbox-v1.destinations \
  --as pkravella --repo pkravella/mandate-sandbox \
  --sandbox --image mandate-agent-claude --agent-egress api.anthropic.com \
  --pass-env ANTHROPIC_API_KEY --pass-env CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC \
  -- claude -p "read src/retry.js and say what it does" \
     --mcp-config '{mcpConfig}' --strict-mcp-config --allowedTools mcp__github

--agent-egress opens the agent's own model API, and --pass-env passes its key by name. Both are your choice and are printed in the output; a mandate can never ask for them. Ctrl-C stops the container, then revokes the token, then removes the workspace. This was verified live with Claude Code: the allowed read worked, a read of another repository was refused, and the only traffic out was to the model's API.

The quickstart walks through all of this step by step.

The rest of the loop

Approvals. --approval owner/repo#42 on run or serve refuses to mint a token until the requester has posted /leastpilot approve <digest> on that issue or pull request. The digest covers the mandate, the ceiling it was checked against, and who asked, so a mandate edited after review needs a new approval. Only the requester's own, unedited comment counts.

Asking for a mandate in a comment. leastpilot review writes a mandate for a task and posts the permission diff on the issue, with the exact line that approves it. Because a model call costs money and anyone can comment, it checks the requester's permission, an hourly limit per repository and the task's length before it calls the model. examples/workflows/leastpilot-review.yml wires this up so /leastpilot <task> posts a review and the requester's approval runs the agent.

Widening. Run serve or run with --pauses <dir> and every refused call is saved there, along with the command to ask for more scope. leastpilot widen applies that request and re-checks the whole mandate from scratch. Some refusals can't be widened on purpose: a denied path, an expired mandate, an operation that wasn't granted at all, or a disallowed destination. A one-click "allow .github/workflows/**" would make the deny list pointless.

Audit log. --audit-log <path> writes a signed, append-only record of the session: the mandate, every call sent to GitHub, every decision, and a final seal. Each line is signed with its own Ed25519 key (leastpilot audit keygen) and chained to the one before. leastpilot audit verify checks it with only the public key. The signature only means something against the agent when the agent runs in the sandbox, because otherwise it could read the key. docs/audit-log.md has the details.

Exporting to GitHub Agentic Workflows. leastpilot export gh-aw turns a checked mandate into a gh-aw workflow, where the agent runs read-only and gh-aw's own job does the writing within the mandate's limits. It refuses anything gh-aw can't hold exactly, rather than quietly loosening it. docs/gh-aw.md has the mapping.

The GitHub Action. There's an Action and an example workflow. It installs the published CLI from a committed lockfile (action-install/) with npm ci, so every dependency is pinned by hash, and checks the registry's signatures and provenance before it runs anything. That lockfile is updated in a pull request after each release, so the Action at a release's own commit still runs the previous CLI: pin the Action to a commit after that update, and check which version action-install/package.json names. The workflow sets permissions: {} and the Action refuses to run if it finds a GITHUB_TOKEN, since a second credential is access no mandate limits. It takes the agent command one argument per line, so no workflow input is ever run through a shell.

Benchmark

Six synthetic tasks against a test repository, each run under its generated mandate. One run per task per mode, so the numbers are noisy. The full report is generated from committed results, and a test re-renders it to make sure they still match.

Metric

Result

Target

Tasks completed

83.3% (5 of 6)

at least 90%

Access cut at the token, vs. a token for 1 repository

32.3%

at least 80%

Access cut at the proxy, vs. a token for 1 repository

87.1%

at least 80%

Access cut at the token, vs. a developer's token (63 repositories)

98.9%

at least 80%

Access cut at the proxy, vs. a developer's token (63 repositories)

99.8%

at least 80%

Granted but never needed

0.0%

under 10%

Runs paused for something in scope, tools hidden

0.0%, but see below

under 5%

Runs paused for something in scope, every tool offered

66.7% (4 of 6, 3 of which still finished)

under 5%

Time to write a mandate

~10.2 s at effort medium (13.0 s at high)

under 10 s

Grants outside the ceiling, including 42 attack cases

0

0

How to read these:

  • The 63-repository numbers flatter it. Nearly all of that cut comes from limiting the token to one repository, which any scoped token gets without LeastPilot. Per repository, LeastPilot takes a task from 31 operations down to 21 at the token and 4 at the proxy.

  • Zero pauses doesn't mean the mandates were the right size. The proxy hides tools the mandate doesn't grant, so the agent never tries them and nothing gets refused. Replay of unconstrained runs predicted seven calls the mandates were missing, yet the proxy refused nothing. So the benchmark also runs each mandate with every tool visible. That turned up five refusals. Four were redundant reads of an issue the agent already had. The fifth was real: the triage task's mandate didn't grant commenting, so the agent couldn't report what it found, and that task failed.

  • Writing a mandate costs about $0.03 and takes two model calls. Effort medium gave the same mandates as high on all six tasks, and on the five tasks written at both settings every medium write was faster, a median of 10.4 seconds against 11.7 (10.2 across all six), so medium is the default. It still misses the ten-second target (writer-effort-results.json).

The attack suite is separate from the benchmark: 42 cases where injected issue text, hostile tool descriptions, over-asking agent plans or tricky arguments try to widen what the agent can do. Each case assumes the model writing the mandate was fully fooled and proposed exactly what the attacker wanted. The question is only whether the validator and the proxy refused it anyway. They did in every case.

Commands

Command

What it does

leastpilot validate

Check a mandate against the ceiling and the requester's permission, and print the permission diff

leastpilot run

Check a mandate, then start an agent whose only route to GitHub is the enforcing proxy (--sandbox to run it in a container)

leastpilot serve

The enforcing MCP server for one mandate, which is what an agent's MCP config starts

leastpilot review

Write a mandate for a task and post its permission diff on an issue or pull request

leastpilot reviewed

Recover the mandate an approval digest names, from the review comment

leastpilot widen

Apply a request for more scope and re-check the whole mandate

leastpilot export gh-aw

Turn a checked mandate into a GitHub Agentic Workflows workflow

leastpilot audit keygen / verify

Make an audit-log signing key, and check a log

leastpilot sandbox build

Build the sandbox's base image from the source in the package

What it doesn't do

  • Detect prompt injection. It limits what a tricked agent can do instead.

  • Protect you from another credential. If the agent can find a personal token, a logged-in gh, or an SSH key, it doesn't need the proxy. That's what the sandbox is for.

  • Ask a model whether an action is OK. A model proposes; plain code decides.

  • Be an identity provider, a secrets vault, or another MCP gateway.

  • Work outside GitHub, for now.

Known gaps

Stated here so nobody has to discover them:

  • No auto-approval. Every mandate needs a person to approve it. There's no library of task templates yet for an org to mark as safe to auto-approve.

  • Requests from automation accounts are refused. GitHub only tells an App what another App may do, so LeastPilot has no way to check an app: requester's access.

  • One export backend out of four. gh-aw works. Docker MCP Gateway, AWS AgentCore (Cedar) and OPA exports don't exist yet.

  • Thirteen catalog operations have no MCP tool, so the proxy never sees them. Most are classed as forbidden and rejected by the validator; branch.delete is the one to watch.

  • The sandbox is macOS with Docker Desktop only. The Linux path has never been run, so --sandbox refuses there, including on GitHub's runners.

  • max counts per session, not per task. A new session starts from zero. With --sandbox only you can start one; without it, the agent could.

  • The audit log has no outside witness. A log with no seal was cut off, and nothing in it can say how.

  • Six tasks, not fifty. A fifty-task run would cost about $14 plus recording reference runs, and the project's budget was $10.

The full list of what breaks it is in docs/threat-model.md §3.

Prior work

Project

What it does

What LeastPilot adds

IntentCap

Model-written capability leases, a checker that only narrows, MCP and eBPF enforcement

The closest design. LeastPilot is a maintained tool with GitHub's semantics and replay measurement

Progent

A policy language and a model-written policy per task

An org-level ceiling, expiry, and GitHub-native enforcement

Conseca

Per-task policy from trusted context, checked deterministically

Released code

AWS AgentCore Policy

Plain English to Cedar, formally checked, enforced at AgentCore Gateway

Policy per task rather than per agent, and it works outside AWS

GitHub Agentic Workflows

A read-only agent that writes through limited safe outputs

Limits per task instead of per workflow; LeastPilot can export to it

Invariant Guardrails, FIDES

Deterministic data-flow rules

Derives the rules from the task instead of writing them by hand

The npm scope is @leastpilot. @mandate and mandate-dev belong to unrelated projects, which is part of why the name changed.

Security

The threat model says what holds, what's out of scope and what breaks it. Short version for operators: run agents with --sandbox, use --repo rather than --level, require an approval, give the job no GITHUB_TOKEN, and never paste issue text into the agent's prompt. Pass the issue number and let the agent fetch it through its granted tools.

To report a security problem in LeastPilot itself, see SECURITY.md.

Documentation

Quickstart

Check a mandate, then run an agent under it

Mandate format

Every field, and what gets rejected

Writing a ceiling

The org policy file and the subset of Cedar it uses

Enforced where

Which control enforces what, and what gets around each one

Threat model

What holds, what's out of scope, what breaks it

Audit log

Turning it on, checking it, and what the signature proves

gh-aw export

What maps to GitHub Agentic Workflows and what's refused

Architecture

How the packages fit together, and why

Benchmark report

The full measurements, generated from the results

Releasing

How the packages get to npm

Development

pnpm install
pnpm typecheck   # builds, then typechecks the tests
pnpm lint        # includes the rules that guard the validator boundary
pnpm test
pnpm build

About 1,450 tests run offline with no credentials, and that's what CI runs. Use the root pnpm build: the packages have no build script of their own, so pnpm --filter <pkg> build does nothing and still exits 0.

Fifty-two more tests are switched off by default, each behind its own variable, because they spend money, use real credentials, write to a real repository, or need Docker. CI never runs them and needs no secrets.

Variable

What it runs

Cost

LEASTPILOT_LIVE=1

The writer against the real model, real token minting, and the whole loop end to end

~$0.03 to ~$0.30

LEASTPILOT_SANDBOX=1

Builds the sandbox and checks its egress, the relay, and run --sandbox end to end, each with a control that shows the check can fail

free, needs Docker

LEASTPILOT_SEED=1

Seeds the benchmark tasks into the test repository: writes to its default branch and opens issues

free

LEASTPILOT_GROUND_TRUTH=1

Re-records the reference runs, overwriting the committed ones

~$3

LEASTPILOT_BENCH=1

The benchmark sweep and the report

~$1.80 for six tasks

LEASTPILOT_WRITER_SWEEP=1

Rewrites each task's mandate at several effort settings and compares

~$0.03 a write

The ones that use credentials also need ANTHROPIC_API_KEY, LEASTPILOT_APP_ID, LEASTPILOT_INSTALLATION_ID, LEASTPILOT_APP_KEY_PATH and LEASTPILOT_TEST_REPO. See CONTRIBUTING.md, in particular the one rule that isn't negotiable.

License

Apache-2.0. See NOTICE for attribution.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables MCP agents to be issued limited-access capability passports that can only be narrowed when delegated, enforcing least privilege and preventing permissions creep across tool calls and sub-agents.
    1
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables MCP clients to govern downstream tool servers by enforcing default-deny authority and attestation on every tool call, with live monitoring, approval, and mid-session revocation.
    3
    Apache 2.0
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables MCP-based AI agents to route tool calls through governance without code changes, checking each call against a mandate and allowing, refusing, or holding for approval while keeping tool API keys out of the AI app.
    192 npm
    MIT