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 test without --workflow), rules with a workflow constraint 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.