Skip to main content

Lab 4 solution

This is the finished state of Lab 4 — Autonomy levels and permission rules. The lab creates one file. Most of the learning is in breaking it, so do Step 4 yourself before you compare.

The reference copy is in the companion repo at harness-reference/.factory/settings.json. Later labs leave these rules alone. Hooks go in their own hooks.json (Lab 5).

.factory/settings.json​

.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"]
}
}
]
}
}

These are the same six rules (ids, decisions and prefixes) as larkspurRules in the site's rule simulator, so the playground at /playground/permissions and your repo reach the same decisions. The simulator's copy has shorter reasons and test lists. If you change a prefix or decision in one, change it in the other.

A few things about the file that are easy to get wrong:

  • The rules live under permissionRules, with version: 1 and a rules array. JSON only, with no YAML form. A standalone rules file is only an input to droid rules check --file; Droid doesn't load one as policy.
  • Every rule has both tests.match and tests.noMatch, and every test passes. A rule missing either array, or failing either one, is skipped.
  • Decisions are allow, ask and block. Nothing else is valid.
  • sessionDefaultSettings.autonomyLevel is the current key. autonomyMode is deprecated.

Check it​

larkspur-payments
droid rules check # validates every rule and runs its tests
droid rules check --command 'git push origin main --force'
droid rules check --command 'npm run db:migrate' --json; echo "exit $?"

droid rules check with no arguments should exit 0. If it exits 1, a rule is invalid or one of its test examples failed, and Droid is skipping that rule in every session.

Preview these commands and compare. The decision column is what the documented semantics produce, and what the site's simulator returns. droid rules check is the source of truth, so if your version disagrees, trust it and tell your facilitator.

CommandDecisionRule that decidesWhy
git status --shortallowproject/git-inspectNested alternatives at position 2
npm testallowproject/test
npm run lint -- --fixallowproject/testAllows every npm run script. Lab 5's Stop hook catches what --fix does to src/money/
npm run scoreboardallowproject/testCosts tokens. See the lab's Extension 1
npm run test:watchallowproject/testNever exits. Another reason to narrow this rule
npm run db:migrateaskproject/migrations-need-a-humanTwo rules match; ask beats allow
npm run db:migrate:checkallowproject/testdb:migrate:check is a different token from db:migrate
git push origin mainaskproject/git-push
git push --force origin mainblockproject/no-force-pushTwo rules match; block beats ask
git push origin main --forceaskproject/git-pushPositional: the flag isn't the third token, so the block misses and the ask catches
psql -h ledger-prod.internalblockproject/no-database-shells
git status > status.txtno allownoneA redirect can prevent an allow even when the prefix looks right; normal approval applies
/usr/bin/git push origin mainno-matchnoneDifferent first token; session approvals and the autonomy level decide
git -C . push origin mainno-matchnoneDifferent second token; the autonomy level decides
git commit -m "…"no-matchnoneNo rule. Needs Medium, so at Low it asks

Every one of those previews exits 0, including the two blocks. Read the decision, not the exit code.

What a good run looks like​

Interactive, at the Low default. File edits and reads run on their own. npm test, npm run lint and git inspection run without a prompt because of the allow rules. git commit and npm install ask, because Low doesn't cover them and no rule allows them. git push asks every time, at any level. git push --force … and psql are rejected with no approval option.

Headless. A run that hits an ask rule stops:

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

Scoreboard. It holds at 21 of 24, the Lab 2 number. That's the expected result. None of the eight tasks needs a command these rules block or ask about. If a T4 run now ends at npm run db:migrate, read its session: stopping there is the control working.

Three wrong turns, and how to spot them​

A rule that isn't there. The commonest cause is "decision": "deny". A PreToolUse hook's JSON output uses "permissionDecision": "deny", and permission rules don't. Close behind: a missing tests.noMatch, a "*" used as a wildcard, or a test example that doesn't actually match the prefix. All of these are skipped silently in a session. The rule looks present in the file and does nothing. Spot it with droid rules check and no arguments: it exits 1. Run it after every edit, and in CI (Lab 9).

Trusting one narrow rule. A single block on ["git", "push", "--force"] feels complete, and git push origin main --force walks past it. Spot it by previewing the same command with the flag moved. The fix is layering: keep the broad ask on ["git", "push"] underneath every narrow block, so anything that slips past the narrow rule still reaches a human.

Scripting against the exit code. A CI step that runs droid rules check --command 'git push --force origin main' and treats exit 0 as "allowed" will pass while the decision is block. Exit 0 only means the check ran. Spot it by running a known block through your script. Parse the decision from --json output instead, after checking the field names on your version.

If none of these explains rules that seem to do nothing in a session, check workspace trust. Project and folder rules require it.

Where this file lives​

FileCompanion repo
.factory/settings.jsonharness-reference/.factory/settings.json