Skip to main content

Lab 4 — Autonomy levels and permission rules

Watch this labNarrated walkthrough, recorded after the pilot runRecording coming soon
Time 40 minBuilds on Lab 1, Lab 2Centers on permission rules

Authority is configuration. A rule that blocks a command is a control; a sentence asking Droid not to run it is a hope.

Why this matters​

During the March pilot, nobody could say what the agent was allowed to do. The honest answer was "whatever the engineer who started it could do". That included git push, a psql client with a connection string in shell history, and npm run db:migrate. The incident was an edit, not a command. But when Ruth's memo asked what the agent cannot do, the platform team had no list to hand her, because no list existed.

Dana has been asked this question in three meetings since:

Everyone asks me what the agent is allowed to run. I want to answer with a file path, not a paragraph. If the answer lives in the repo, Ruth can read it, CI can test it, and it is the same for every engineer on every laptop.

Dana Okafor Staff platform engineer · your pair for the day

Droid gives you two separate controls. The autonomy level decides how much runs without asking you first. Permission rules override that for specific commands: always allow this, always ask about that, never run the other thing. The first is a dial you turn per session. The second is policy, in JSON, in the repository, with tests attached.

This lab writes both. It also shows you the three ways a permission rule can be wrong without anything telling you, which is the part most teams skip.

Try this

Turn the dial before you touch a config file

Twelve actions from a Larkspur session. Move the autonomy level, switch between droid and droid exec, and toggle the Larkspur rules on and off. Watch which actions run, ask, fail or get blocked.

Surface
Autonomy (Ctrl+L)
ActionTierOutcomeWhy
Read src/fees/calc.tsoffrunsWithin medium autonomy.
git statusoffrunsAllowed by project/git-inspect. Hooks and the sandbox still apply.
Edit src/fees/calc.tslowrunsWithin medium autonomy.
Create test/fees/foreign.test.tslowrunsWithin medium autonomy.
rg roundHalfEven srclowrunsWithin medium autonomy.
npm testmediumrunsAllowed by project/test. Hooks and the sandbox still apply.
npm install date-fnsmediumrunsWithin medium autonomy.
git commit -m "fix flaky test"mediumrunsWithin medium autonomy.
git push origin fix/flaky-overdrafthighasks youproject/git-push says ask: approval is required even at High.
npm run db:migratehighasks youproject/migrations-need-a-human says ask: approval is required even at High.
docker compose uphighasks youNeeds high. Droid asks you first.
psql -h ledger-prod.internalhighblockedBlocked by project/no-database-shells. No approval path, at any level.

8 run on their own · 3 ask · 0 fail the run · 1 blocked.

Tiers follow the examples in Factory’s autonomy documentation. Droid classifies the real risk of each command at run time, so treat the tier column as the teaching model, not a guarantee.

Open the full playground →

Objectives​

  • Cycle autonomy levels with Ctrl+L and predict which actions will need your approval at each one
  • Set a project default autonomy level in .factory/settings.json and explain why Larkspur chose Low
  • Write permission rules with allow, ask and block decisions, each with passing tests
  • Check rules with droid rules check, and read the decision rather than the exit code
  • Break a rule on purpose and see which mistakes are skipped silently, and which one a second rule catches

The idea in one paragraph​

Autonomy controls automatic approval, not which tools exist. At Off, Droid runs read tools, and commands your policy allows, and asks before anything else. Low adds file edits and low-risk commands. Medium adds reversible workspace changes such as npm install and git commit. High adds high-risk actions like git push and migrations, unless a built-in safety check requires approval. Permission rules sit on top of that. Each rule matches a command by an exact prefix of tokens and returns allow (skip the prompt), ask (always prompt, even at High) or block (reject, no approval path). When several rules match, block beats ask and ask beats allow. When none match, normal approval handling decides: any approval you already gave in the session, then the autonomy level.

Here is the path one Execute command takes. Look at where droid and droid exec split: the same ask that prompts you interactively ends a headless run.

What you add in this lab
.factory/
settings.jsonnew# autonomy default + permissionRules

Step 1 — feel the levels​

Start an interactive session in the repo. You haven't configured anything yet, so the autonomy level is the default, Off.

larkspur-payments
droid

Give Droid a small task that touches every tier: a read, an edit, a test run and a commit.

in droid
Add a one-line comment above dailyLimitFor in src/transfers/limits.ts pointing at PAY-231, run the tests, and commit with a clear message.

Approve each prompt as it comes, and count them. Then undo Droid's commit. Run git log -1 first: if the top commit is Droid's, revert it with git reset --hard HEAD~1. If you rejected Droid's commit, the top commit is your own work (often your Lab 2 AGENTS.md), so don't reset; use git checkout -- . to discard the uncommitted changes instead. Then press Ctrl+L to move to Low, and run the same prompt again. Ctrl+L cycles Off, Low, Medium, High and back to Off. Your organization can cap the top level, so if High is missing, that is why.

Step 1 · second runNormal · Autonomy: LowIllustrative
  1. Readsrc/transfers/limits.ts
  2. Editsrc/transfers/limits.ts — add // PAY-231: business limit is 150% of retail, not enforced yet
  3. approval requestedapproved

    Execute: npm test. This command needs Medium autonomy. Run it?

  4. Executenpm test
    all tests passed
  5. approval requestedapproved

    Execute: git commit -m "limits: point dailyLimitFor at PAY-231". This command needs Medium autonomy. Run it?

  6. Executegit commit -m "limits: point dailyLimitFor at PAY-231"
  7. Two approvals. You will approve the first one every time you ever see it. The second is the one worth reading.

At Off you approved the edit, the test run and the commit. At Low the edit ran on its own and two prompts remained. That is the whole job of the dial: it moves the line between "just do it" and "ask me".

One trap before you move on. If an approval prompt offers an "always allow" option and you choose it, Droid raises the session's autonomy level to the level that prompt required. Choose "always allow" on git commit and the session is now at Medium, which also lets npm install run unprompted. Approve once, not always, until you have decided what you actually want.

Q1

Of the approvals you gave at Off, which did you read, and which did you approve on reflex? What does that tell you about an approval prompt as a control, compared with a rule that decides the same way every time?

Step 2 — set the project default​

A level you choose with Ctrl+L lasts for your session. Dana wants the same starting point for every engineer, so it goes in the project's settings file, which is committed with the code.

.factory/settings.json
{
"sessionDefaultSettings": {
"autonomyLevel": "low"
}
}

sessionDefaultSettings.autonomyLevel takes off, low, medium or high, and defaults to off. (You may see autonomyMode in older examples. It is deprecated.)

Why Low and not Medium? Low lets Droid edit files and run low-risk commands without interrupting you, which is most of a working session. Medium adds package installs and commits. At a bank, a new dependency is a supply-chain decision and a commit is the start of the audit trail, and Theo wants to see both. So Larkspur starts at Low and uses permission rules to remove the prompts that carry no information, like npm test.

Start a new session and check that it opens at Low. You can still press Ctrl+L to go higher for one session. That's intended. The default is the floor everyone starts from, and it isn't a ceiling. The ceiling for an organization is the enterprise setting maxAutonomyLevel.

Q2

Name two things that Medium lets run without asking that you would want Theo to see first at Larkspur. Then name one thing Low already allows that surprises you.

Step 3 — write the rules​

Permission rules live under the permissionRules key in the same settings file. The container needs version: 1 and a rules array. JSON is the only documented format, and there is no YAML form. A standalone rules file exists only as input to droid rules check --file (Extension 2). Droid doesn't load one as policy.

Each rule needs an id, a decision, a match.prefix, and both tests.match and tests.noMatch. The tests are not optional decoration. Every enabled rule must pass its own examples to become active, and a rule that fails them is skipped. The examples are only matched against the prefix. They are never executed.

Here is Larkspur's policy. It is the companion repo's harness-reference/.factory/settings.json, exactly. The playground above uses the same six rules.

.factory/settings.json
{
"sessionDefaultSettings": {
"autonomyLevel": "low"
},
"permissionRules": {
"version": 1,
"rules": [
{
"id": "project/git-inspect",
"decision": "allow",
"reason": "Reading git state is always safe.",
"match": { "prefix": ["git", ["status", "diff", "log", "show"]] },
"tests": {
"match": ["git status", "git status --short", "git diff --name-only HEAD", "git log --oneline -5", "git show HEAD~1"],
"noMatch": ["git push origin main", "git -C /repo status", "/usr/bin/git status", "git statusx", "GIT status"]
}
},
{
"id": "project/test",
"decision": "allow",
"reason": "Tests and package scripts run locally. Scripts that need a human have their own ask rule, and ask beats allow.",
"match": { "prefix": ["npm", ["test", "run"]] },
"tests": {
"match": ["npm test", "npm test -- test/fees/overdraft.test.ts", "npm run typecheck", "npm run lint"],
"noMatch": ["npm install", "npm publish", "npx vitest run", "pnpm test"]
}
},
{
"id": "project/git-push",
"decision": "ask",
"reason": "A human reviews anything that leaves this machine.",
"match": { "prefix": ["git", "push"] },
"tests": {
"match": ["git push", "git push origin main", "git push -u origin feature/memo"],
"noMatch": ["git pull", "git status", "git log --oneline"]
}
},
{
"id": "project/no-force-push",
"decision": "block",
"reason": "Force pushes rewrite shared history. Prefix matching only sees flags right after `push`; CI branch protection catches the rest.",
"match": { "prefix": ["git", "push", ["--force", "-f", "--force-with-lease"]] },
"tests": {
"match": ["git push --force", "git push -f origin main", "git push --force-with-lease origin feature/memo"],
"noMatch": ["git push origin main", "git push --dry-run", "git push -u origin main"]
}
},
{
"id": "project/no-database-shells",
"decision": "block",
"reason": "Agents never open a database shell. Ledger and customer data are off limits.",
"match": { "prefix": [["psql", "mysql", "mongosh"]] },
"tests": {
"match": ["psql", "psql -h prod-ledger.larkspur.internal payments", "mysql -u root", "mongosh mongodb://localhost:27017"],
"noMatch": ["npm test", "psqlx", "echo psql", "which mongosh"]
}
},
{
"id": "project/migrations-need-a-human",
"decision": "ask",
"reason": "Schema changes need an approved CHG ticket and a human at the keyboard.",
"match": { "prefix": ["npm", "run", ["db:migrate", "db:rollback"]] },
"tests": {
"match": ["npm run db:migrate", "npm run db:rollback", "npm run db:rollback -- --steps 1"],
"noMatch": ["npm run db:migrate:check", "npm test", "npm run typecheck"]
}
}
]
}
}

Read the prefixes closely, because that is where the semantics live:

  • Prefixes are exact, case-sensitive tokens, in order. ["git", "push"] matches git push origin main (more arguments may follow). It does not match GIT push, /usr/bin/git push or git -C /repo push, because the first or second token is different.
  • A nested array is a set of alternatives at one position. ["git", ["status", "diff", "log", "show"]] is four rules in one.
  • * is a literal token. It is not a wildcard, and neither is anything else. These are not regular expressions or globs.
  • Allow needs a command Droid can parse with confidence. Expansions, redirects and unsupported shell forms can prevent an allow even when the visible prefix looks right. The docs' own example lists git status > status.txt as a noMatch for an inspect rule like this one. It isn't blocked. It falls back to normal approval.
  • db:migrate:check is not db:migrate. Tokens match whole. So the migrations rule asks about npm run db:migrate and ignores the read-only check script.

Now look at npm run db:migrate. Two rules match it: project/test says allow, and project/migrations-need-a-human says ask. Block beats ask, and ask beats allow, so the answer is ask. Precedence is by decision, not by order in the file.

Check the file. With no arguments, droid rules check validates every rule and runs its test examples. With --command, it previews the decision for one command.

larkspur-payments
droid rules check
droid rules check --command 'npm run db:migrate'
droid rules check --command 'git push --force origin main'
droid rules check --command 'git push --force origin main' --json; echo "exit $?"

The last line matters most. The preview for a force push says block, and the command still exits 0. Exit 0 means the check ran, not "this command is allowed". Only invalid rules or failed test examples produce exit 1. If you script against droid rules check, and in Lab 9 you will, read the decision from the --json output. Don't read it from the exit code. The field names are in the output. Check them on your version before you write a script against them.

Two more limits worth knowing now. The preview covers command policy only. It doesn't simulate hooks, the sandbox, your autonomy level or approvals you have given in a session. And project and folder rules require workspace trust: untrusted project and folder rules are excluded. If a session behaves as if rules that droid rules check accepts aren't there, check trust first. The permission rules page states the requirement but not how trust is granted, so check how your version asks for it.

Edit the rules below and try your own commands. The editor starts with the same six rules.

Decisionask

“A human reviews anything that leaves this machine.”

  • Matched: project/git-push (ask)
  • Why: Prefixes are positional. The force-push block expects the flag third, so this slips past it. The ask rule on git push still catches it, which is why you layer rules instead of trusting one.

A teaching simulator built from the documented rules: exact, case-sensitive token prefixes; block beats ask beats allow; an allow has to cover every segment of a chain; a rule whose tests fail is skipped, not turned into block-all. Confirm real behavior with droid rules check --command '…' --json.

Q3

project/test allows every npm run script. Open package.json and list every script that rule now lets run without a prompt. Which of them would you not want Droid to start unasked, and why? (One costs money. One never exits.)

Step 4 — break it on purpose​

A rule you haven't tried to break is a rule you are guessing about. Make two changes and watch what happens.

First, the typo. Change the force-push rule's decision from block to deny. It is an easy slip, because hooks do use the word deny: a PreToolUse hook can return JSON with "permissionDecision": "deny" (see the hooks reference). Permission rules don't. Their decisions are allow, ask and block, and nothing else.

.factory/settings.json (the broken rule)
{
"id": "project/no-force-push",
"decision": "deny",
"reason": "History on shared branches is evidence.",
"match": { "prefix": ["git", "push", ["--force", "-f", "--force-with-lease"]] },
"tests": {
"match": ["git push --force origin main", "git push -f"],
"noMatch": ["git push origin main"]
}
}

Start a new session and ask Droid to force-push a branch. Answer no at the prompt.

Step 4 · with the typoNormal · Autonomy: HighIllustrative
  1. you

    My branch history is messy. Rebase fix/pay-231 onto main and force-push it.

  2. Executegit rebase main
  3. approval requestedrejected

    Execute: git push --force origin fix/pay-231. Matched project/git-push (ask): A human reviews anything that leaves this machine.

  4. You were asked, not blocked. project/no-force-push is invalid, so Droid skipped it without a word. The ask rule underneath caught the command.

An invalid rule doesn't turn into a block-all policy. It is skipped, silently, and the rest of the rules carry on. Here you were lucky: the broader ask rule on git push still caught the command. Remove that rule too, and at High a force push could run without a prompt. Droid has built-in safeguards that can require approval for recognized destructive operations, but that's a backstop you didn't write and can't test.

The only place the typo is loud is droid rules check, which exits 1 on an invalid rule. Run it now and see. Then fix the typo back to block.

Second, the positional prefix. With the rule fixed, preview the same force push with the flag at the end:

larkspur-payments
droid rules check --command 'git push --force origin main'
droid rules check --command 'git push origin main --force'

The first previews block. The second previews ask. (If a built-in rule also matches on your version, the --json report lists it among the competing matches.) Prefixes are positional: the block rule expects the flag as the third token, and origin is in that position. Git doesn't care where the flag goes. Your rule does. Once again the ask rule on git push is what stops it.

That's the design lesson of this step. Rules are cheap, literal and exact, so layer them. A narrow block for the specific thing you never want, and a broad ask under it for the whole family, so a command that slips past the narrow rule still reaches a human.

Four rules meant to stop a force push. One loads.

Which of these, in .factory/settings.json → permissionRules.rules, actually blocks `git push --force origin main`?

Option A
rule
{ "id": "project/no-force",
  "decision": "deny",
  "match": { "prefix": ["git", "push", "--force"] },
  "tests": { "match": ["git push --force"], "noMatch": ["git push"] } }
Option B
rule
{ "id": "project/no-force",
  "decision": "block",
  "match": { "prefix": ["git", "push", "*"] },
  "tests": { "match": ["git push --force"], "noMatch": ["git status"] } }
Option C
rule
{ "id": "project/no-force",
  "decision": "block",
  "match": { "prefix": ["git", "push", ["--force", "-f"]] } }
Option D
rule
{ "id": "project/no-force",
  "decision": "block",
  "reason": "History on shared branches is evidence.",
  "match": { "prefix": ["git", "push", ["--force", "-f"]] },
  "tests": {
    "match": ["git push --force origin main", "git push -f"],
    "noMatch": ["git push origin main"] } }
Q4

The deny typo made no noise in the session, only in droid rules check. Where in Larkspur's workflow should that check run so that nobody has to remember to run it? What should happen when it exits 1?

Step 5 — headless, nobody to ask​

The scoreboard, and everything in Lab 9, runs droid exec. There is no human at the prompt, so ask means something different there.

droid exec with no --auto flag is read-only. --auto takes low, medium or high. There is no --auto off. When a run needs more than its level allows, it stops immediately, exits non-zero and makes no partial changes. And an effective ask is not cleared by --auto high, because a one-shot run cannot collect an approval.

See it. npm run db:migrate is a stub in the companion repo, so this is safe to run:

larkspur-payments
droid exec --auto high "Run npm run db:migrate and tell me what it prints."; echo "exit $?"

That's the behavior you want. A migration in a pipeline with nobody watching should stop the pipeline, not run. Lab 9 builds on this: pushing stays a human step because the ask rule makes any headless push fail.

There is one flag that does skip ask: --skip-permissions-unsafe. It skips every permission prompt, it can't be combined with --auto, and block rules still apply. Its name says what you need to know. Nothing in this workshop uses it.

Check 1 of 3

A CI job runs T4 with droid exec --auto high. Partway through, Droid decides to run npm run db:migrate, which matches an ask rule. What happens?

Check 2 of 3

Sam adds a rule with id personal/push-ok and decision allow for ["git", "push"] to his ~/.factory/settings.json. The project rule project/git-push is ask. What happens when Droid tries git push origin main in his session?

Check 3 of 3

An older config still has git push in commandDenylist. Which permission-rule decision does that map to?

Step 6 — what rules don't cover​

Now be precise about the edges, because Ruth will be.

Rules govern commands, not edits. A permission rule matches the tokens of a shell command. It doesn't see an Edit to src/money/rounding.ts. That is the whole of Lab 5.

Rules match text, not intent. /usr/bin/git push origin main and git -C . push origin main match no rule. They fall through to normal approval and the autonomy level. A push is a high-risk action, so at Low Droid asks. At High, a no-match push may run without asking. That's one reason Larkspur's default is Low: the autonomy level is the backstop for everything your rules didn't anticipate.

Two more layers exist, and they are not rules.

  • Droid Shield scans for secrets. It checks only added lines, and only on git commit and git push that Droid itself runs. Manual git commands outside Droid aren't scanned. It is on by default (enableDroidShield), and organization admins can enforce it. On a detection the git operation is blocked, and the documented overrides are to commit manually or turn it off in /settings. There is no documented allowlist.
  • The sandbox restricts file writes and network ("sandbox": {"enabled": true}). Shell commands run inside an OS-level sandbox, and file tools are checked against the same policy before each operation. By default, writes are denied outside the working directory, and network is denied except Factory's own domains and its authentication provider. A sandbox violation prompts even at High, and in droid exec it is auto-denied. Lab 5's Extension 4 has you turn it on.

Put together, these controls shrink the blast radiusBlast radiusEverything a mistake could reach. A session that can only edit files in one repo has a small blast radius; one that can push, migrate a database or call production has a large one.: the set of things a mistaken session can reach. After this lab, a Larkspur session can't open a database shell, can't force-push, and can't push or migrate without a person saying yes. It can still edit any file it likes. Lab 5 closes that.

Q5

Ruth asks: "Can the agent push to main?" Write the answer you would give her in three sentences. Name the rule, name the evidence she can check herself, and name one way a push reaches the autonomy level instead of the rule.

Step 7 — re-run the scoreboard​

npm run scoreboard
Scoreboard

This lab should not move the number: it is still 21 / 24. Run it anyway. Knowing which changes cannot move the score is half of knowing what the score means.

Nothing moved, and that is the right result. The scoreboard runs every task with droid exec --auto medium, and none of the eight tasks needs a command your rules block. The allow rules remove prompts that headless runs never showed anyway. The blocks and asks guard commands that a well-behaved T1 or T7 never runs.

What changed is what can happen, which the pass rate doesn't measure. A T4 run that reaches for npm run db:migrate now stops at that command with a non-zero exit, instead of running the migration. Whether your T4 check scores that as a pass is the argument you had in Lab 0 about whether stopping counts. Look at the session for any T4 run and see where it ended.

T2 is still 2 of 3. The March failure was an edit to a file, and nothing in this lab governs edits. Lab 5 does.

Checkpoint​

Checkpoint0 / 6

Which of Larkspur's controls so far would still hold if Droid decided to ignore AGENTS.md completely, and which would not?

Extensions​

  1. Ask before spending money. AGENTS.md says the scoreboard "costs tokens; ask first". That is an instruction. Write a rule project/scoreboard-asks with decision ask for npm run scoreboard, with tests, and confirm with droid rules check --command 'npm run scoreboard' that ask now beats the project/test allow.
  2. Test rules before they merge. droid rules check --file rules.json --command 'npm publish' --json checks a candidate rule set without touching your settings. --file takes a {version, rules} object or a bare array of rules, and rejects a whole settings.json. Write a pre-merge check that runs every rule's tests from a candidate file and fails on exit 1. Lab 9 puts the equivalent in CI.
  3. Rules that follow the code. A nested .factory/settings.json holds folder rules, and a settings.local.json next to any settings file holds local overrides. Rule ids are claimed in priority order: Org, Runtime, Folder, Project, User. Within one folder, settings.local.json comes before settings.json. Work out what happens if an engineer's .factory/settings.local.json defines a rule with the id project/git-push and decision allow. What does that tell you about which rules belong in Enterprise Controls instead of the repo?
  4. Close the absolute-path gap. /usr/bin/git push matches no rule. Write a rule for it with tests, and then list the other spellings of "push" you can't enumerate. That list is the argument for the sandbox's network restrictions, which block the push at the network rather than at the command text.