Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Then initialize the store once:
```bash
$ quorumgit init
store: /home/you/.quorumgit/quorumgit.db
migrations applied: ['001_core.sql', '002_approval_identities.sql']
migrations applied: ['001_core.sql', '002_approval_identities.sql', '003_approver_policy.sql']
contract: ok
```

Expand Down Expand Up @@ -161,13 +161,17 @@ git push origin feat/x # rejected — unidentified

The hook learns who is pushing from `QUORUMGIT_AGENT` in its own environment, which Git passes through only when the remote is a **local path** (as `origin` is above). Over SSH or HTTP the variable does not reach the hook, so every push is rejected as unidentified. The hook also opens the store under its own `QUORUMGIT_DATA_DIR`, so pushers must use the same data directory as the rest of the deployment.

**Scopes are enforced on the push, not just the claim.** When the owner pushes a claimed branch, the hook lists the paths changed by the commits the push introduces (commits not yet reachable from any ref on the hub) and checks each against the claim's declared scopes. A push that touches anything outside them is rejected as an `out_of_scope_update` naming the stray paths, and lands only with an approval bound to that exact update — the operation includes the sorted `out_of_scope_paths`, so the approver sees what they are signing off on. Renames count as a deletion plus an addition, so moving a file *into* scope still charges its old location. Merging already-published history (for example, `main`) into the branch is not charged; edits made inside a merge commit — conflict resolutions or extra changes — are. Unclaimed branches are not scope-checked, and neither is the local model, which has no hook.

Scope globs match repository-relative paths: `**` spans any number of directories, `*` and `?` stay within one path segment, and a glob without wildcards names a single file or a whole directory (`docs` covers `docs/guide.md`).

Accepting a handoff in the hub model prints `worktree: (none — create manually)`: there is no managed worktree to transfer, so the successor fetches the branch into its own clone and continues from the reported commit.

Checkpoints in the hub model take an explicit `--commit <oid>`. Continuation points are **verified, not trusted**: the commit must exist in the registered repository, and when the claimed branch exists, be reachable from it. A typo'd or fabricated OID is rejected.

## Protected operations and approvals

Updates to protected refs, force pushes, ref deletions, and lease takeovers all require an approval. An approval is bound by SHA-256 hash to the **exact operation payload** (canonical JSON) — approving one push authorizes that push at those exact revisions, and nothing else.
Updates to protected refs, force pushes, ref deletions, pushes that change paths outside the pusher's claimed scopes, and lease takeovers all require an approval. An approval is bound by SHA-256 hash to the **exact operation payload** (canonical JSON) — approving one push authorizes that push at those exact revisions, and nothing else.

The flow, driven by the rejection messages themselves:

Expand All @@ -177,9 +181,12 @@ The flow, driven by the rejection messages themselves:
# bound to this exact update (hash sha256:ab12…).

# 2. Any registered agent requests that exact operation, and an approver votes
# on the returned approval instance ID. The approver must be registered too:
# on the returned approval instance ID. The approver must be registered too.
# --push derives the operation from the commit in your clone, so nobody
# has to write the JSON by hand:
quorumgit agent add operator
quorumgit approve request '{"type":"protected_ref_update","repository":"myproject","refname":"refs/heads/main","oldrev":"<old>","newrev":"<new>"}'
quorumgit approve request --repo myproject --push main --from ./my-clone --agent agent-one
# operation: {"newrev": "<new>", "oldrev": "<old>", "refname": "refs/heads/main", "repository": "myproject", "type": "protected_ref_update"}
# approval 17 hash=sha256:ab12… status=pending
quorumgit approve vote 17 --agent operator

Expand All @@ -190,11 +197,31 @@ quorumgit approve vote 17 --agent operator
Rules that hold no matter what:

- **An approval never bypasses branch reservations.** A claimed branch still accepts pushes only from its claiming agent; a branch frozen by an open handoff accepts none at all. The hook checks reservations *before* approvals.
- **Denial has precedence and is terminal.** One `--deny` vote denies the approval; later yes votes are refused.
- **Denial has precedence and is terminal.** One `--deny` vote denies the approval; later yes votes are refused. An approved approval that has not been used yet still accepts votes, so a deny revokes it before use.
- **Consumption is single-use under concurrency.** Two simultaneous pushes racing for one approval produce exactly one accepted push — the loser is rejected, not silently allowed.
- **Votes bind to one approval instance.** A delayed vote for an older denied or consumed instance cannot decide a newer request with the same operation hash. Requesters, voters, consumers, and pushers must name registered agents.
- **A consumed approval is spent, not blacklisted.** Consumption moves the approval to a terminal `consumed` state (with `consumed_at`) rather than reusing `denied`, and only one *live* (`pending` or `approved`) approval may exist per operation hash. The same operation can therefore be requested and approved again later as a new approval instance — which matters for takeovers, whose payload is stable and legitimately repeatable. What is never possible is one approval authorizing twice.
- The default threshold is 1; `--threshold` sets a higher one. "Operator" is a convention, not a role: **any registered agent can vote, including the agent that requested the approval or is making the push.** Keeping approvals in human hands depends on how agent identities are used, not on anything QuorumGit enforces (see the threat model).
- **By default the policy is open:** the threshold is 1 (`--threshold` sets a higher one) and any registered agent can vote, including the agent that requested the approval or is making the push. A repository can tighten this — see below.

### Approval policy per repository

Two optional settings restrict who can approve operations on a repository (operations name it in their `repository` field):

```bash
quorumgit repo approver add myproject alice # build an approver roster
quorumgit repo approver add myproject bob
quorumgit repo approver add myproject carol
quorumgit repo policy myproject --separate-duties
quorumgit repo policy myproject # show the current policy
```

- **Approver roster.** Once a repository has approvers, only they can vote on its approvals, and the threshold becomes a **2/3 + 1 quorum** of the roster (1 of 1, 2 of 2, 3 of 3, 3 of 4, 4 of 5, …). `--threshold` on a request can raise that bar but not lower it.
- **Separation of duties.** With `--separate-duties`, the agent that requested an approval cannot vote on it, and the agent that *uses* it — the pusher, or the agent taking over a lease — cannot count its own yes vote. With a roster, the quorum is counted over the approvers eligible for that particular approval, so these exclusions never make approval impossible while other approvers exist; a sole approver, however, can never approve their own operation.
- **Quorum is re-checked when an approval is used,** against the roster and policy at that moment. Removing an approver withdraws their vote from every approval not yet used; if that drops an approval below quorum, the push or takeover is refused until more eligible approvers vote.

Every roster and policy change is audited. Without a roster or separation of duties, the open policy above applies — "operator" is then a convention, not a role, and keeping approvals in human hands depends on how agent identities are used (see the threat model).

`approve request --push` runs the hook's own derivation against the hub, with your clone's object store attached read-only (the way Git exposes incoming objects to a pre-receive hook). It fetches and writes nothing, reads the old revision from the hub's current ref, resolves `--rev` (default `HEAD`) in `--from`, and produces the same operation the hook will compute — protected update, force push, deletion (`--delete`), or out-of-scope update with its path list — for the agent named by `--pusher` (default: the requester). It applies the same branch reservations as the hook, so a push the hook would refuse outright is refused here too. If the hub's ref moves before you push, the operation no longer matches: derive and request again.

Takeovers follow the same pattern: claiming a task someone else holds (`claim <task> --takeover`) prints the takeover operation to approve. Its payload includes the incumbent claim ID, so an unused approval cannot displace a later claim by the same agent. The takeover is atomic — the incumbent is released, the replacement claim created, and the approval consumed in one transaction, or none of it happens. A refused takeover leaves the incumbent untouched and the approval unconsumed.

Expand All @@ -207,6 +234,8 @@ Takeovers follow the same pattern: claiming a task someone else holds (`claim <t
| `quorumgit doctor [--repair]` | Detect and conservatively reconcile recorded managed-worktree drift |
| `quorumgit destroy --yes` | Delete the database file (managed worktrees under `QUORUMGIT_DATA_DIR/worktrees` are left in place) |
| `quorumgit repo add <name> <path> [--protected-ref <ref>]…` | Register a repository |
| `quorumgit repo approver add\|remove <repo> <agent>` / `repo approver list <repo>` | Manage a repository's approver roster |
| `quorumgit repo policy <repo> [--separate-duties \| --no-separate-duties]` | Show or change a repository's approval policy |
| `quorumgit agent add <name>` | Register an agent identity |
| `quorumgit task add --repo <name> --title <t> [--objective <o>]` | Create a task |
| `quorumgit claim <task> --branch <b> --scope <glob>… [--no-worktree] [--takeover] [--override-overlap] [--lease-hours <h>]` | Claim a task |
Expand All @@ -219,6 +248,7 @@ Takeovers follow the same pattern: claiming a task someone else holds (`claim <t
| `quorumgit handoff cancel <id>` | Cancel — creator only; removes the retained worktree, and is refused if it has uncommitted changes |
| `quorumgit handoff list [--status <s>] / show <id>` | Inspect handoffs |
| `quorumgit approve request <json> [--threshold <n>]` | Open an approval for an exact operation |
| `quorumgit approve request --repo <name> --push <ref> (--from <clone> [--rev <commit>] \| --delete) [--pusher <agent>]` | Derive the operation a push will need, before pushing, and open an approval for it (prints nothing to approve if the push is ungoverned) |
| `quorumgit approve vote <approval-id> [--deny]` | Vote on one approval instance |
| `quorumgit approve hash <json>` | Compute an operation's hash |
| `quorumgit hook install --repo <name>` | Install the pre-receive hook (hub model) |
Expand Down Expand Up @@ -259,7 +289,7 @@ QuorumGit v1 coordinates **cooperating agents**; the adversary is *accident, not
- Agent identity is asserted (`QUORUMGIT_AGENT`), not cryptographically authenticated. Any local process can claim to be any agent.
- The trust root is write access to the database and the filesystem. An actor with either can bypass governance.
- The pre-receive hook governs `git push` only. Direct ref manipulation inside a repository bypasses it.
- Approval hashes provide exact-payload binding and tamper-evidence, not approver authentication. Any registered agent may vote, including the requester or pusher, so an agent can approve its own protected operation.
- Approval hashes provide exact-payload binding and tamper-evidence, not approver authentication. Approver rosters and separation of duties restrict *which asserted identities* may approve, but because identity is asserted, a process that can claim to be an approver can vote as one.

These are the correct trade-offs for preventing well-intentioned agents from colliding on one machine. They are not Byzantine fault tolerance, and this document will not pretend otherwise.

Expand Down
118 changes: 115 additions & 3 deletions src/quorumgit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,56 @@ def cmd_repo_list(args, cfg) -> int:
return 0


def cmd_repo_approver_add(args, cfg) -> int:
with store.connect(cfg) as conn:
registry.add_approver(conn, args.repo, args.agent_name)
conn.commit()
print(f"{args.agent_name} is now an approver for {args.repo}.")
return 0


def cmd_repo_approver_remove(args, cfg) -> int:
with store.connect(cfg) as conn:
registry.remove_approver(conn, args.repo, args.agent_name)
conn.commit()
print(f"{args.agent_name} is no longer an approver for {args.repo}.")
return 0


def cmd_repo_approver_list(args, cfg) -> int:
with store.connect(cfg) as conn:
for name in registry.get_repository(conn, args.repo)["approvers"]:
print(name)
return 0


def cmd_repo_policy(args, cfg) -> int:
with store.connect(cfg) as conn:
if args.separate_duties is not None:
registry.set_separate_duties(conn, args.repo, args.separate_duties)
conn.commit()
repo = registry.get_repository(conn, args.repo)
approvers = repo["approvers"]
if approvers:
print(f"approvers: {', '.join(approvers)}")
print(
f"quorum: {gate.quorum_threshold(len(approvers))} of "
f"{len(approvers)} (2/3 + 1)"
)
else:
print("approvers: any registered agent")
print("quorum: the threshold given with each request (default 1)")
if repo["separate_duties"]:
print("separate duties: on — the requester's vote is refused and the "
"agent using an approval cannot count its own vote")
if approvers:
print(" (quorum is counted over the approvers eligible for each "
"approval)")
else:
print("separate duties: off")
return 0


def cmd_agent_add(args, cfg) -> int:
with store.connect(cfg) as conn:
agent_id = registry.add_agent(conn, args.name)
Expand Down Expand Up @@ -357,10 +407,37 @@ def _operation_from_args(args) -> dict:


def cmd_approve_request(args, cfg) -> int:
requester = _agent(args, cfg)
if (args.operation is None) == (args.push is None):
print("Give either an operation JSON object or --push <ref>.",
file=sys.stderr)
return 2
with store.connect(cfg) as conn:
if args.push is not None:
if not args.repo:
print("--push requires --repo <name>.", file=sys.stderr)
return 2
if args.delete == bool(args.source):
print("--push requires exactly one of --from <clone> or --delete.",
file=sys.stderr)
return 2
operation = gate.derive_push_operation(
conn,
args.repo,
args.push,
args.pusher or requester,
source=args.source,
rev=args.rev,
delete=args.delete,
)
if operation is None:
print("This push needs no approval; nothing requested.")
return 0
print(f"operation: {json.dumps(operation, sort_keys=True)}")
else:
operation = _operation_from_args(args)
approval = gate.request_approval(
conn, _operation_from_args(args), requested_by=_agent(args, cfg),
threshold=args.threshold,
conn, operation, requested_by=requester, threshold=args.threshold,
)
conn.commit()
print(
Expand Down Expand Up @@ -445,6 +522,26 @@ def add(name, fn, configure=None, parent=None):
sp.add_argument("--protected-ref", action="append", default=[]),
), parent=repo)
add("list", cmd_repo_list, parent=repo)
approver = repo.add_parser("approver").add_subparsers(dest="action", required=True)
add("add", cmd_repo_approver_add, lambda sp: (
sp.add_argument("repo"),
sp.add_argument("agent_name"),
), parent=approver)
add("remove", cmd_repo_approver_remove, lambda sp: (
sp.add_argument("repo"),
sp.add_argument("agent_name"),
), parent=approver)
add("list", cmd_repo_approver_list,
lambda sp: sp.add_argument("repo"), parent=approver)
add("policy", cmd_repo_policy, lambda sp: (
sp.add_argument("repo"),
sp.add_argument("--separate-duties", dest="separate_duties",
action="store_true", default=None,
help="forbid agents from approving their own requests, "
"pushes, or takeovers"),
sp.add_argument("--no-separate-duties", dest="separate_duties",
action="store_false"),
), parent=repo)

agent = sub.add_parser("agent").add_subparsers(dest="sub", required=True)
add("add", cmd_agent_add, lambda sp: sp.add_argument("name"), parent=agent)
Expand Down Expand Up @@ -525,7 +622,22 @@ def add(name, fn, configure=None, parent=None):

ap = sub.add_parser("approve").add_subparsers(dest="sub", required=True)
add("request", cmd_approve_request, lambda sp: (
sp.add_argument("operation", help="operation JSON object"),
sp.add_argument("operation", nargs="?", help="operation JSON object"),
sp.add_argument("--push", metavar="REF",
help="derive the operation for pushing this ref "
"(branch name or full refname) instead of "
"passing JSON"),
sp.add_argument("--repo", help="registered repository (with --push)"),
sp.add_argument("--from", dest="source", metavar="CLONE",
help="repository holding the commit to push "
"(with --push)"),
sp.add_argument("--rev", default="HEAD",
help="commit to push, resolved in --from "
"(default HEAD)"),
sp.add_argument("--delete", action="store_true",
help="derive a deletion of the ref (with --push)"),
sp.add_argument("--pusher",
help="agent that will push (default: the requester)"),
sp.add_argument("--agent"),
sp.add_argument("--threshold", type=int, default=1),
), parent=ap)
Expand Down
Loading
Loading