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
15 changes: 13 additions & 2 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', '003_approver_policy.sql']
migrations applied: ['001_core.sql', '002_approval_identities.sql', '003_approver_policy.sql', '004_open_ref_namespaces.sql']
contract: ok
```

Expand Down Expand Up @@ -171,7 +171,7 @@ Checkpoints in the hub model take an explicit `--commit <oid>`. Continuation poi

## Protected operations and approvals

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.
Updates to protected refs, force pushes, ref deletions, pushes that change paths outside the pusher's claimed scopes, updates to refs outside `refs/heads/`, 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 Down Expand Up @@ -223,6 +223,16 @@ Every roster and policy change is audited. Without a roster or separation of dut

`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.

**Refs outside branches are governed too.** Claims only coordinate `refs/heads/*`, so a push that creates or moves anything else — a tag, a note, a custom `refs/…` ref — is a `non_branch_ref_update` and needs an approval bound to that exact update. For annotated tags the operation names the tag object, so derive it with `--rev <tag>`: `quorumgit approve request --repo myproject --push refs/tags/v1.0 --from ./my-clone --rev v1.0`. A repository that wants a namespace ungoverned (for example, release tooling that tags freely) opens it explicitly:

```bash
quorumgit repo namespace open myproject refs/tags/ # tag pushes need no approval
quorumgit repo namespace list myproject
quorumgit repo namespace close myproject refs/tags/ # governed again
```

Opening a namespace only lifts the non-branch rule: deleting or force-moving a ref there, or updating one listed with `--protected-ref`, still needs an approval. `refs/heads/` itself cannot be opened. Opening and closing are audited.

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.

## Command reference
Expand All @@ -236,6 +246,7 @@ Takeovers follow the same pattern: claiming a task someone else holds (`claim <t
| `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 repo namespace open\|close <repo> <refs/prefix/>` / `repo namespace list <repo>` | Let updates under a non-branch ref namespace skip approval, or govern it again |
| `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 Down
37 changes: 37 additions & 0 deletions src/quorumgit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,29 @@ def cmd_repo_policy(args, cfg) -> int:
return 0


def cmd_repo_namespace_open(args, cfg) -> int:
with store.connect(cfg) as conn:
prefix = registry.open_namespace(conn, args.repo, args.prefix)
conn.commit()
print(f"{prefix} is open in {args.repo}: updates there need no approval.")
return 0


def cmd_repo_namespace_close(args, cfg) -> int:
with store.connect(cfg) as conn:
prefix = registry.close_namespace(conn, args.repo, args.prefix)
conn.commit()
print(f"{prefix} is closed in {args.repo}: updates there need an approval.")
return 0


def cmd_repo_namespace_list(args, cfg) -> int:
with store.connect(cfg) as conn:
for prefix in registry.get_repository(conn, args.repo)["open_namespaces"]:
print(prefix)
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 @@ -533,6 +556,20 @@ def add(name, fn, configure=None, parent=None):
), parent=approver)
add("list", cmd_repo_approver_list,
lambda sp: sp.add_argument("repo"), parent=approver)
namespace = repo.add_parser(
"namespace",
help="non-branch ref namespaces (refs/tags/, …) that skip approval",
).add_subparsers(dest="action", required=True)
add("open", cmd_repo_namespace_open, lambda sp: (
sp.add_argument("repo"),
sp.add_argument("prefix", help="e.g. refs/tags/"),
), parent=namespace)
add("close", cmd_repo_namespace_close, lambda sp: (
sp.add_argument("repo"),
sp.add_argument("prefix"),
), parent=namespace)
add("list", cmd_repo_namespace_list,
lambda sp: sp.add_argument("repo"), parent=namespace)
add("policy", cmd_repo_policy, lambda sp: (
sp.add_argument("repo"),
sp.add_argument("--separate-duties", dest="separate_duties",
Expand Down
18 changes: 14 additions & 4 deletions src/quorumgit/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,20 +562,27 @@ def governed_operation(
)

protected = refname in repo["protected_refs"]
# Only branches are coordinated by claims; tags, notes, and custom refs
# are governed by approval unless the repository opened their namespace.
outside_branches = not refname.startswith("refs/heads/") and not any(
refname.startswith(prefix) for prefix in repo["open_namespaces"]
)
forced = (
not deletion
and not _is_zero(oldrev)
and not _is_fast_forward(git_dir, oldrev, newrev, env)
)

if not (protected or deletion or forced or out_of_scope):
if not (protected or deletion or forced or out_of_scope or outside_branches):
return None
if protected:
op_type = "protected_ref_update"
elif deletion:
op_type = "ref_delete"
elif forced:
op_type = "force_update"
elif outside_branches:
op_type = "non_branch_ref_update"
else:
op_type = "out_of_scope_update"
operation: dict[str, Any] = {
Expand Down Expand Up @@ -624,9 +631,12 @@ def derive_push_operation(
repo = get_repository(conn, repository)
get_agent(conn, pusher)
hub_git_dir = str(assert_repository_identity_unique(conn, repo))
# Branches point at commits. Other refs (an annotated tag, say) are pushed
# as the object itself, which is what the hook sees, so do not peel them.
peel = "^{commit}" if refname.startswith("refs/heads/") else ""
current = subprocess.run(
["git", "--git-dir", hub_git_dir, "rev-parse", "--verify", "--quiet",
f"{refname}^{{commit}}"],
f"{refname}{peel}"],
capture_output=True,
text=True,
check=False,
Expand All @@ -644,8 +654,8 @@ def derive_push_operation(
raise GateError("Deriving a push needs --from <your clone>.")
source_path = Path(source).resolve()
newrev = _git_out(
["-C", str(source_path), "rev-parse", "--verify", f"{rev}^{{commit}}"],
f"Cannot resolve {rev!r} to a commit in {source_path}",
["-C", str(source_path), "rev-parse", "--verify", f"{rev}{peel}"],
f"Cannot resolve {rev!r} in {source_path}",
)
try:
source_common = git_common_dir(source_path)
Expand Down
15 changes: 15 additions & 0 deletions src/quorumgit/migrations/004_open_ref_namespaces.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Ref namespaces outside refs/heads/ that a repository opts out of approval.
-- By default every non-branch ref update (tags, notes, custom refs) needs an
-- approval bound to the exact update.

CREATE TABLE open_ref_namespaces (
id INTEGER PRIMARY KEY,
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
prefix TEXT NOT NULL CHECK (
prefix GLOB 'refs/?*/'
AND prefix <> 'refs/heads/'
AND substr(prefix, 1, 11) <> 'refs/heads/'
),
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE (repository_id, prefix)
);
73 changes: 73 additions & 0 deletions src/quorumgit/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ def approvers_for_repository(conn: Connection, repository_id: int) -> list[str]:
]


def open_namespaces_for_repository(conn: Connection, repository_id: int) -> list[str]:
return [
row[0]
for row in conn.execute(
"SELECT prefix FROM open_ref_namespaces WHERE repository_id = ? "
"ORDER BY prefix",
(repository_id,),
).fetchall()
]


def _protected_refs(conn: Connection, repository_id: int) -> list[str]:
return [
row[0]
Expand Down Expand Up @@ -180,6 +191,7 @@ def get_repository(conn: Connection, name: str) -> dict:
"protected_refs": _protected_refs(conn, row[0]),
"approvers": approvers_for_repository(conn, row[0]),
"separate_duties": bool(row[3]),
"open_namespaces": open_namespaces_for_repository(conn, row[0]),
}


Expand Down Expand Up @@ -290,3 +302,64 @@ def set_separate_duties(conn: Connection, repository: str, enabled: bool) -> Non
repo["id"],
detail={"repository": repository, "separate_duties": enabled},
)


# ------------------------------------------------------------ ref namespaces


def _normalize_namespace(prefix: str) -> str:
prefix = prefix.strip()
if not prefix.endswith("/"):
prefix += "/"
if not prefix.startswith("refs/") or prefix.count("/") < 2 or prefix == "refs/":
raise RegistryError(
f"Ref namespace must look like 'refs/<name>/', got {prefix!r}."
)
if prefix.startswith("refs/heads/"):
raise RegistryError(
"Branches (refs/heads/) are governed by claims, protected refs, and "
"approvals; they cannot be opened as a namespace."
)
return prefix


def open_namespace(conn: Connection, repository: str, prefix: str) -> str:
"""Let ref updates under a non-branch namespace skip approval."""
begin_immediate(conn)
repo = get_repository(conn, repository)
prefix = _normalize_namespace(prefix)
if prefix in repo["open_namespaces"]:
raise RegistryError(f"{prefix} is already open in {repository}.")
conn.execute(
"INSERT INTO open_ref_namespaces (repository_id, prefix) VALUES (?, ?)",
(repo["id"], prefix),
)
audit.record(
conn,
"repository.namespace_opened",
"repository",
repo["id"],
detail={"repository": repository, "prefix": prefix},
)
return prefix


def close_namespace(conn: Connection, repository: str, prefix: str) -> str:
"""Require approval again for ref updates under a namespace."""
begin_immediate(conn)
repo = get_repository(conn, repository)
prefix = _normalize_namespace(prefix)
cur = conn.execute(
"DELETE FROM open_ref_namespaces WHERE repository_id = ? AND prefix = ?",
(repo["id"], prefix),
)
if cur.rowcount != 1:
raise RegistryError(f"{prefix} is not open in {repository}.")
audit.record(
conn,
"repository.namespace_closed",
"repository",
repo["id"],
detail={"repository": repository, "prefix": prefix},
)
return prefix
6 changes: 5 additions & 1 deletion tests/test_authorization_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ def _agents(conn, *names):
conn.execute("INSERT INTO agents (name) VALUES (?) ON CONFLICT DO NOTHING", (name,))


UPGRADES_AFTER_001 = ["002_approval_identities.sql", "003_approver_policy.sql"]
UPGRADES_AFTER_001 = [
"002_approval_identities.sql",
"003_approver_policy.sql",
"004_open_ref_namespaces.sql",
]


def _apply_001(cfg: Config):
Expand Down
Loading
Loading