Policies & trust

Policies are the attention router: every action an agent proposes — an edit, a command, an MCP tool call — is evaluated against your rules before it executes, deterministically, and routed to one of four levels. The agent never decides its own level.

LevelWhat happens
auto-approveRuns without you. No notification.
notifyRuns, and tells you — bundled into digests if you want.
ask-firstThe run stops and waits for your decision in the inbox.
denyThe action is refused outright. The run continues; an audit record is kept.

Where policies live

~/.config/loopkeep/policy.yaml        # home — you, across all projects
<workspace>/.loopkeep/policy.yaml            # project — the team, committed and reviewed
<workspace>/.loopkeep/policy.local.yaml      # project-local — you, this project only (gitignore it)

Layering is deliberately not "closest wins". Two kinds of rules behave differently:

  • Defaults are starting points. They resolve project-local → home → project, first match wins — you can always override a project's default for yourself.
  • Floors (allow_override: false) are non-negotiable minimums. All matching floors across all layers apply, and the strictest one wins. This cuts both ways: a team floor can't be relaxed by your personal config, and a floor in your home policy protects you inside a repository you don't trust.

Writing rules

Edit rules in the app's Policies screen (a project's layer) or Personal Policies (your home layer): a Rules Editor form builds them field by field, and a Raw tab holds the policy.yaml exactly as it is on disk. Or edit the file directly. The schema is the same either way:

version: 1
policies:
  ask-me: { level: ask-first, timeout: { after: "72h", then: abort } }
rules:
  - id: prod-migration
    match:
      paths: ["migrations/**", "infra/prod/**"] # gitignore-style globs, OR within the list
      tool: [bash, edit] # different match keys AND together
    policy: ask-me
    allow_override: false
    reason: "Production changes always get a human"
defaults:
  level: notify
delivery:
  digest: { cron: "0 9 * * *" }
  quiet_hours: ["23:00-07:00"]

Rules match on five axes: paths, tool, tags, workflow, and command (substring match against raw shell commands). A rule carries its level inline (level:) or references a named policy (policy:) — never both. The full schema is in the policy file reference.

Two axes deserve a warning. tags are self-declared by workflows and command can be dodged with shell variables — use them to escalate convenience cases, but write your hard floors with paths and tool, which the agent can't rewrite.

How evaluation works

For each proposed action:

  1. Resolve the default — the first matching default rule in project-local → home → project order; failing that, the strictest defaults.level declared by any layer.
  2. Compute the floor — the maximum level among all matching allow_override: false rules, in every layer, plus the built-in floor.
  3. Apply self-reporting — the agent may flag its own action as sensitive. That can only raise the level, never lower it.
  4. Final level = the maximum of the three.

When a floor overrides a lower default, that clamp is recorded as an audit event — you can see when supervision actually engaged.

The built-in floor

Writes to loopkeep's own supervision machinery always ask first, and no layer can relax this: your loopkeep config, the workspace's .loopkeep/ directory (except the run worktrees agents legitimately work in), the running workflow's own definition file, the loopkeep binaries, and the event log. Shell commands are screened too — a bash invocation that writes to any protected path, or mentions one in its raw text, is floored. An agent cannot quietly edit the policy that supervises it. The app lists these protected targets, read-only, on the Personal Policies screen.

Two backstops complete the picture: approval screens always show the raw diff or command as the primary display (an agent's own summary is secondary), and an action detected executing without a verdict pauses the run immediately.

Trust

A repository you just cloned could ship a permissive policy and eager triggers. So policies work like direnv: until you trust a workspace, its project policy and automatic triggers are inert — only your home policy and manual runs work. Trusting records a hash of the policy files; any later edit drops the workspace back to untrusted until you approve the diff. Your personal floors apply everywhere regardless of trust.

In the app, review the rules on the Policies screen and Save & trust (or Re-trust, or the untrusted banner's Trust on Policies). From the terminal, lk trust. To pause a trusted workspace's triggers without changing its policy, lk untrust (the app's Project Settings → Stop all triggers); it keeps the approval, so lk trust re-enables everything without re-reviewing an unchanged policy.

Explain and test

Every attention shows its provenance — which rule, which layer, which file and line. The same machinery is available on demand.

The Policies screen's Check the policy card takes a tool (plus optional paths and tags), reports the final level and which rule on which layer decided it, and can test it against a level you expect.

explain prints each matching rule, its layer and file location, whether it can be overridden, and the final level. test sets its exit code from the result, so policy changes can carry CI tests like any other code.

Digests and quiet hours

delivery (personal layers only) shapes how notifications reach you, never what level they get. digest bundles notify-level items on a schedule; quiet_hours holds notify delivery overnight. ask-first always rings immediately — a run blocked on you shouldn't wait quietly until morning.

Repo permission settings are honored

If the repository's .claude/settings.json asks for confirmation on a tool (permissions.ask), loopkeep imports that as an escalation-only clamp to ask-first — it can raise an action's level, never lower it. Opt out per workspace or globally with import_repo_ask: false in a personal layer.