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.
| Level | What happens |
|---|---|
auto-approve | Runs without you. No notification. |
notify | Runs and creates a non-blocking inbox update; delivery depends on channel. |
ask-first | The run stops and waits for your decision in the inbox. |
deny | The action is refused outright. The run continues; an audit record is kept. |
The deny row describes an ordinary policy decision. An audit-storage denial
is different: the action is not executed, the run ends in Failed, and the
audit record whose write failed is absent.
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 while completing its
keys and values as you type. 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:
- Resolve the default — the first matching default rule in
project-local → home → project order; failing that, the strictest
defaults.leveldeclared by any layer. - Compute the floor — the maximum level among all matching
allow_override: falserules, in every layer, plus the built-in floor. - Apply self-reporting — the agent may flag its own action as sensitive. That can only raise the level, never lower it.
- 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. The floor is
ask-first, not deny — it puts the decision in front of you, it doesn't
refuse the action. Shell commands are screened too, but only where a protected
path lands in a write position: a redirect target, or an argument to rm,
cp, mv, tee, dd, sed -i. Reading a protected file, or merely naming
it somewhere in the command, is not floored. An agent cannot quietly edit the
policy that supervises it. The app lists these protected targets, read-only, on
the Personal Policies screen.
Four loopkeep tools carry a floor of their own, by tool name rather than by
path: ask_human never drops below ask-first, and notify_human,
slack_reply, and slack_post never below notify. The routes an agent uses to reach you stay
open whatever your layers say. Any layer may still raise them.
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 (home and project-local layers only) shapes how notifications
reach you, never what level they get. notify items remain visible in the
inbox immediately. A connected desktop receives the live inbox update right
away outside quiet hours; delivery.digest.cron also schedules one bundled
digest notification, daily at 09:00 by default. Both that cron and
quiet_hours use the daemon machine's system-local time. Workflow on.schedule
cron remains UTC.
Each quiet window is fixed-width HH:MM-HH:MM, with different start and end
times; the start is inclusive and the end exclusive. It may cross midnight,
and several windows are unioned; weekday
syntax is not supported in v0.3. An invalid entry warns and is ignored without
discarding valid entries; the warning is written to the daemon log. Live
notify updates are held during the window and flushed as one ordered batch
when it ends. A digest due inside the window is retained and merged per
workspace until then.
ask-first always rings immediately — it neither waits for a digest nor
releases held notify items early. If Settings → Slack notifications names a
workspace and channel, the same gate fans out to Slack: approval requests are
sent individually and immediately, while notify appears only in the scheduled
digest. Slack messages are informational; approve or deny in loopkeep.
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.