lazy documentationv0.90

Protected branches and human approvals

Branch protection flips the builder's default on sensitive merges from "accept unless told not to" to "cannot accept without a deliberate human act." It is friction, not security: the builder is an over-eager cooperator, not an adversary. Treat it as a speed bump that makes an unreviewed merge require a deliberate act, not as a boundary that would hold against one.

Turning it on: one command, zero branch listing

Protection is opt-in — OFF by default. A project with no [protection] section (or with enabled = false) has no protection at all: accepts into main behave exactly as they always have. That is deliberate — a brand-new user's first lazy accept should merge, not refuse over a gate they have not heard of yet.

One command turns it on:

lazy protect main on

That both lists main in [protection].protected_branches and sets enabled = true (it says so when it does). From then on, accepting any task into main prompts for the approval passphrase at lazy accept itself — or is satisfied by an approval on the task's PR/MR, which is the same gate.

The equivalent by hand, which needs no branch listing at all because the repo's default branch is protected as soon as the switch is on:

[protection]
enabled = true   # protects the repo's default branch; set false (or delete) to turn off

Toggling enabled off and on again loses nothing: the rest of the section is kept, it just has no effect while disabled. lazy protect <target> off never touches the switch — unprotecting one branch is not the same act as turning the feature off.

How you find out it exists

Since protection is off by default, lazy accept advertises it: after an accept succeeds into the repo's default branch, it prints one line —

Tip: gate future accepts into `main` behind a human approval — lazy protect main on

The tip is suppressed once you have expressed an opinion: writing enabled into [protection] (either value) stops it for good, as does having protection on. An explicit enabled = false means you know about the feature and said no, so lazy stops mentioning it.

Seeing a gate before it bites

Protection is friction, and friction you meet for the first time as a refusal is just a surprise. Once a project protects anything, every read surface says so — before you try to accept.

Surface What you see
lazy show <task> a Protected: line — yes (branch gate), yes (task gate), or yes (task gate + branch gate) — followed by what each gate is, how to accept, and whether a builder review is waiting
lazy status <task> the same Protected: line, in the same words
lazy list / lazy active / lazy blocked a compact [P] marker on the goal, plus a one-line legend under the table
lazy browse <task> the gate in the header line (and above the hunks under -i), so you learn about it before you press a
MCP lazy_show a protection object — gated, target_branch, task_gate, branch_gate, pending_review, markers, summary
Web dashboard the same [P] badge on the task list, and a Protected row on the task detail page

Two properties are deliberate:

A task you listed in protected_tasks while enabled = false gets no marker — it gates nothing — but lazy show still reports it, as no — listed in [protection].protected_tasks, but protection is disabled. Silence there would leave you believing a gate was armed when it was not.

What a protected accept looks like

With protection on, lazy accept of a task targeting a protected branch prompts for the approval passphrase and merges in the same invocation:

lazy system passphrase set   # once per MACHINE, at your own terminal
lazy accept <task>           # prompts, verifies, merges

Approve-then-merge in one step is the point: the approval is inherently bound to the exact commits being merged. There is no stored approval token that could go stale, be recorded before the agent finished, or let a later, different diff through. (Earlier releases had a separate lazy approve <task> command recording a one-shot approval; that command and its floating credential are gone.)

Task-to-task merges (a subtask accepted into its parent's lazy/* branch) are never protected by default — no friction in the inner loop — and never get a pull request unless a person asks for one with lazy submit (see Submitting a subtask for review). Re-entry of a task already in merging status is exempt — reaching that state already passed the gate; re-entry only completes the authorized merge.

The builder's review survives the refusal

The reason a builder hands lazy_accept is often a real code review. When the gate refuses the merge, that review is captured on the task (as pending_accept_review metadata — review text, it authorizes nothing) and the refusal says so. Your own lazy accept then:

  1. prints the review — attributed, e.g. Review by builder (recorded …) — before the passphrase prompt, so it is part of what you approve;
  2. attaches it to the merge: it lands in the [Accepted] task comment. Your --reason (or typed reason) comes first, then the attributed review. It is not written to the PR — lazy posts no reviews or comments to a forge.

A review recorded before later commits is kept and labeled stale (recorded before N later commit(s)), never silently dropped — and a builder retrying lazy_accept with an updated reason replaces its earlier review rather than stacking copies.

CI and scripting

There is deliberately no non-interactive path for the passphrase: no flag, no env var, and no stdin route (stdin to lazy accept is the accept reason). A value a script can supply is a value that sits in shell history, CI logs, and agent transcripts — which breaks the one property the mechanism must keep: the token originates outside the builder's context. For automation on forge projects, approve the PR/MR instead; that satisfies the same gate.

Approving the PR/MR counts

On a GitHub or GitLab project, approving the task's PR/MR satisfies the same gate — no passphrase asked. Clicking "Approve" while reading the diff is the same deliberate human act, expressed where you were already reviewing; demanding the passphrase on top would be friction with no extra judgement behind it.

The two are one mechanism, not two: the forge approval is resolved inside the same gate, so a local-driver project and a forge project reach the identical decision. Details that follow from that:

Enrolling the passphrase: lazy system passphrase

lazy system passphrase set      # enroll or rotate (asks twice; rotation asks for the current one)
lazy system passphrase status   # is this machine enrolled? never prints or hints at the value
lazy system passphrase delete   # un-enroll (asks for the current passphrase first)

One enrollment per machine, not per project. Keying the credential to a project would need a stable project identity, and the only identity available is the path — which changes the moment the repo is cloned, moved, or opened from a worktree. A passphrase that silently stops working after git clone is worse than one shared across your projects, and the passphrase is friction (proof a human is at the keyboard), not a per-repo secret.

Stored hashed, outside every repository. ~/.lazy/passphrase.json holds a Bun.password hash (argon2id) and mode 0600 — never the passphrase. It sits one level above ~/.lazy/daemon/, so moving the daemon's directory cannot relocate your credential out from under you. lazy refuses — not warns — to use a store that is group- or world-readable, and tells you the chmod to fix it: a hash other accounts can read is a hash they can attack offline while you believe the gate is intact.

There is no recovery — reset instead. The store holds a hash, so a forgotten passphrase cannot be read back, and both rotation (set) and delete ask for the current one. The way back is to remove the store yourself and enroll again:

rm ~/.lazy/passphrase.json      # the file `lazy system passphrase status` names
lazy system passphrase set      # enroll a new one, at your terminal

That is a plain local file only your account can write, and removing it resets the passphrase and nothing else — protection settings, tasks and branches are untouched. Until you re-enroll, protected merges fail closed: lazy accept refuses and points back at lazy system passphrase set rather than letting the merge through.

Three deliberate restrictions, each load-bearing:

Migrating from .lazy/approve-passphrase

In earlier releases the passphrase sat in plaintext inside the repo, at .lazy/approve-passphrase or wherever [protection].passphrase_file pointed. Both are gone: the key is removed from the schema (lazy doctor explains the migration if it is still in your lazy.toml), and the old file is never consulted — leaving it in place does not satisfy the gate. Run lazy system passphrase set, which offers to delete the stale file for you; lazy system passphrase status and lazy doctor flag it until it is gone.

What this does not close

If nothing has ever been enrolled on a machine, the first enrollment is reachable by an agent running under the host-process runner — it shares your user account, so it could run the command's code path itself. Rotation is not (it requires the current passphrase), and neither is reading the passphrase back (only a hash is stored). This residual is accepted rather than papered over: the real fix is running agents in containers, which is the default runner and where the command refuses outright.

Advanced: protecting additional branches

For projects with more than one sensitive branch, protected_branches adds to the automatic default-branch protection (exact names, no globs):

[protection]
protected_branches = ["release"]

gate_default_branch = false switches off the automatic default-branch protection, leaving only the explicit list. See the [protection] reference in lazy-toml.md.

The default branch is an implicit entry

The default branch is gated without appearing in protected_branches — it is resolved from the remote (refs/remotes/<remote>/HEAD) at decision time, so it stays correct if the repo's default branch ever changes. Because nobody typed it into a list, it is surfaced everywhere the list is:

Protected tasks: gating work on the way OUT

A protected branch gates merges coming in. A protected task gates one task's work going out: merging its branch upward requires approval regardless of the target — including into a lazy/* parent branch, which is otherwise never gated.

Use it when a particular piece of work is the sensitive thing, wherever it lands: a risky refactor, a task you want to read yourself before it moves anywhere, a long-running task that accumulates subtask work freely but must not be promoted without you.

Tasks are listed by code or short id, not by branch — the branch is resolved from the task at decision time, so it stays correct:

[protection]
protected_tasks = ["add-auth"]

The refusal names the task and, as always, the passphrase prompt at lazy accept <task>.

Stale entries fail open — loudly

A listed task that no longer resolves to a branch — deleted, code changed, identifier now ambiguous, or simply never started — gates nothing. It fails open rather than blocking every accept on a config typo.

That is the dangerous half of the trade: you believe a gate is armed when it is not. So a stale entry is made loud in three places:

The lazy protect command

lazy protect is the single CLI for all of the above. It edits the [protection] section of lazy.toml in place — there is one store, and you can always read or hand-edit it.

lazy protect                       # Show the current protection state
lazy protect release on            # Protect merges INTO branch 'release'
lazy protect add-auth on           # Protect merges OUT OF task 'add-auth'
lazy protect add-auth off          # Lift that task gate
lazy protect --branch main off     # Stop protecting the 'main' branch

What the config editing does and does not preserve

lazy protect edits lazy.toml as text rather than reparsing and re-serializing it, so comments, blank lines, key order and quoting style survive. Known limitations:

Mechanics

The approval is checked against the passphrase you enrolled on this machine. Before prompting, lazy accept asks the daemon whether a passphrase is enrolled at all, so an accept that could never succeed is refused up front instead of after you type. That check is answered by the same project the merge runs in.

A forge PR approval is an additional way to satisfy the same check, not a separate mechanism — see Approving the PR/MR counts above.

A separate gate on the same path

The deleted-file resurrection guard sits immediately after this one in the accept path and refuses a merge that would silently put back a file the target branch deleted. It is unrelated to protection — always on, no config, and it asks about the content of the merge rather than about who is allowed to make it — but it shares the enforcement point and the --approve-file approval channel.

What this does NOT do

Deliberate non-goals: no threat model against a hostile builder, no push-prevention (an agent on a host runner can git push around the check — accepted), no cryptographic hardening or rate limiting. The feature succeeds if it changes the default path from auto-accept to human-approved.

Moving the passphrase out of the repo closed one concrete hole — the secret is no longer sitting in plaintext in a tree every agent can read, at a path the repo's own config could redirect. It did not turn friction into security: see What this does not close for the residual that remains under the host-process runner.

Submitting a subtask for review

Lazy does not open a pull or merge request for a subtask on its own: its work goes into the parent task's branch, and lazy accept merges it there locally. If a reviewer wants to look at a subtask on your forge anyway, ask for it:

lazy submit <subtask>

This opens the PR/MR with the parent task's branch as its base and marks the subtask submitted, so PR comments reach it like any submitted task. A few things to know: