policy.yaml
The complete policy.yaml schema. Concepts — layering, floors, evaluation
order, trust — are in the policies guide.
Files
~/.config/loopkeep/policy.yaml # home layer
<workspace>/.loopkeep/policy.yaml # project layer (committed)
<workspace>/.loopkeep/policy.local.yaml # project-local layer (gitignored)
A fourth layer, org, is reserved in the evaluation order. Nothing loads it,
so it has no effect today.
Full schema
version: 1
policies: # named policies: reusable behavior bundles
ask-me:
level: ask-first # required
timeout: # optional; waiting runs only
after: "72h"
then: abort # abort | deny
rules:
- id: infra-approval # required; keep it unique within the file
policy: ask-me # reference a named policy defined in THIS file…
# level: ask-first # …or carry the level inline — never both
# timeout: { ... } # inline form may also carry a timeout
match:
paths: ["infra/**"] # gitignore-style globs, workspace-relative; OR within the list
tool: [bash, edit] # OR within the list; keys AND together
tags: [migration] # matches the workflow's x-loopkeep tags / harness-assigned tags
workflow: [deploy, "release-*"] # run's workflow name; exact + * glob
command: ["migrate"] # substring of the raw command, case-insensitive, any-match
allow_override: false # floor rule: no layer may relax it (default: true)
reason: "Production infra always gets a human"
defaults:
level: notify # when no rule matches; strictest declared layer wins
delivery: # personal layers only; shapes delivery, never levels
digest: { cron: "0 9 * * *" } # system-local time; this is also the default
quiet_hours: ["23:00-07:00"] # system-local; holds notify pushes and due digests
# Optional, personal layers only: opt out of importing the repo's
# .claude permissions.ask entries as ask-first clamps.
import_repo_ask: false
delivery is active only in the home and project-local layers. Digest cron and
quiet hours use the daemon machine's system-local zone; workflow on.schedule
uses UTC. Notify items appear in the inbox immediately, and a connected desktop
receives the live update outside quiet hours. With no explicit digest cron, a
separate bundled digest notification is scheduled daily at 09:00 local time.
The home and project-local digest crons form a union rather than overriding one
another, so defining one in each layer can produce two digests in the same day.
Every quiet-hours entry must be exactly HH:MM-HH:MM, with different start and
end times. The start is inclusive, the end exclusive; a range may cross midnight, and multiple entries are
unioned. Weekdays are not supported. Invalid entries warn and are ignored one
at a time; the warning is written to the daemon log. Inbox entries remain
visible while notification delivery is held. Live notify updates flush as one
ordered batch after the window. A due digest is retained and merged per
workspace until then; ask-first is always delivered immediately. Configured
Slack notifications receive ask-first immediately and notify only through
the scheduled digest.
Levels
auto-approve < notify < ask-first < deny. Rank order matters: floors
and self-reported escalation take the maximum.
Match semantics
- paths — gitignore-style:
**any depth,*within one segment,?one character,[...]character class. No brace expansion. Matched relative to the workspace root. - tool — the action's tool name; MCP tools as
mcp:<server>/<tool>. - workflow — exact match plus
*glob. When the evaluation has no workflow name (e.g.lk policy testwithout--workflow), rules with aworkflowconstraint don't match. - command — substring, case-insensitive, any element matches. Same no-context rule: no raw command available, no match. Evadable via shell variables — don't build floors on it.
- Empty/omitted axes are unconstrained. All present axes must match (AND).
Named policies
Defined per file; policy: references resolve only within the same file.
Unknown references are parse errors. A rule with both policy: and
level:/timeout: is an error — behavior lives in one place.
Rule timeouts
Apply to waiting runs stopped by that rule. On expiry the run is aborted (or
the action denied, per then:) and the decision is recorded as a timeout.
Without a timeout, waiting runs wait indefinitely — visible, never
auto-decided.
Testing policies in CI
lk policy test runs without the daemon and sets its exit code from the
result — --expect <level> gives 0/1, no --expect returns the level's rank
(0–3), errors exit ≥ 10. Policy changes can ship with tests like any other
code. See the CLI reference.
The starter policy
lk init writes this:
version: 1
rules:
- id: block-deploy
match:
tags: [deploy]
level: ask-first
allow_override: true
defaults:
level: notify
Everything notifies; anything tagged deploy asks first. A deliberate
starting point to loosen or tighten, not a recommendation.