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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
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 Down
86 changes: 79 additions & 7 deletions src/quorumgit/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from pathlib import Path
from typing import Any

from . import audit
from . import audit, work
from .canonical import stable_hash
from .registry import (
RegistryError,
Expand Down Expand Up @@ -317,6 +317,51 @@ def _is_fast_forward(git_dir: str, oldrev: str, newrev: str) -> bool:
return result.returncode == 0


def _new_commit_paths(git_dir: str, newrev: str) -> list[str]:
"""Paths changed by the commits this push introduces.

Only commits not yet reachable from any existing ref are inspected, so a
branch created from (or merged with) already-published history is not
charged for that history. Merge commits use Git's combined diff, which
reports only paths the merge result changes relative to every parent:
conflict resolutions and edits made inside the merge itself. Renames are
split into a deletion and an addition so both paths are checked.
"""
commits = subprocess.run(
["git", "--git-dir", git_dir, "rev-list", newrev, "--not", "--all"],
capture_output=True,
check=False,
)
if commits.returncode != 0:
raise PushRejected("Unable to list the commits introduced by this push.")
if not commits.stdout.strip():
return []
result = subprocess.run(
[
"git",
"--git-dir",
git_dir,
"diff-tree",
"--stdin",
"-r",
"-c",
"-z",
"--root",
"--no-renames",
"--no-commit-id",
"--name-only",
],
input=commits.stdout,
capture_output=True,
check=False,
)
if result.returncode != 0:
raise PushRejected("Unable to determine the paths changed by this push.")
return sorted(
{item.decode("utf-8") for item in result.stdout.split(b"\0") if item}
)


def _invoking_git_common_dir(git_dir: str) -> Path:
result = subprocess.run(
["git", "--git-dir", git_dir, "rev-parse", "--git-common-dir"],
Expand Down Expand Up @@ -365,6 +410,8 @@ def check_ref_update(
"""Enforce governance for one ref update. Raises PushRejected."""
repo = _verify_repository_binding(conn, repository, git_dir)
branch = refname.removeprefix("refs/heads/")
deletion = _is_zero(newrev)
out_of_scope: list[str] = []

if refname.startswith("refs/heads/"):
pending = open_handoff_for_branch(conn, repo["id"], branch)
Expand All @@ -380,30 +427,55 @@ def check_ref_update(
f"(claim {claim['id']}); pusher is "
f"{pusher or 'unidentified — set QUORUMGIT_AGENT'}."
)
if claim and not deletion:
# Declared scopes are enforced on what the push actually changes,
# not only on what the claim said it would change.
out_of_scope = work.paths_outside_scopes(
_new_commit_paths(git_dir, newrev),
work.claim_scopes(conn, claim["id"]),
)

protected = refname in repo["protected_refs"]
deletion = _is_zero(newrev)
forced = (
not deletion
and not _is_zero(oldrev)
and not _is_fast_forward(git_dir, oldrev, newrev)
)

if protected or deletion or forced:
operation = {
"type": "protected_ref_update"
if protected
else ("ref_delete" if deletion else "force_update"),
if protected or deletion or forced or out_of_scope:
if protected:
op_type = "protected_ref_update"
elif deletion:
op_type = "ref_delete"
elif forced:
op_type = "force_update"
else:
op_type = "out_of_scope_update"
operation: dict[str, Any] = {
"type": op_type,
"repository": repository,
"refname": refname,
"oldrev": oldrev,
"newrev": newrev,
}
if out_of_scope:
operation["out_of_scope_paths"] = out_of_scope
approval = approved_instance(conn, operation)
if approval is None:
scope_note = ""
if out_of_scope:
shown = ", ".join(out_of_scope[:10])
more = len(out_of_scope) - 10
if more > 0:
shown += f", … (+{more} more)"
scope_note = (
f" It changes paths outside the pusher's claimed scopes: "
f"{shown}."
)
raise PushRejected(
f"{operation['type']} on {refname} requires an approval "
f"bound to this exact update (hash {operation_hash(operation)})."
f"{scope_note}"
)
assert pusher is not None
consume_approval(conn, approval["id"], operation, agent=pusher)
Expand Down
76 changes: 76 additions & 0 deletions src/quorumgit/work.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import math
import re
import subprocess
from typing import Any

Expand Down Expand Up @@ -318,6 +319,81 @@ def scopes_overlap(a: str, b: str) -> bool:
return pa.startswith(pb) or pb.startswith(pa)


def _segment_regex(segment: str) -> str:
"""Regex for one path segment: `*` and `?` never cross a `/`."""
out: list[str] = []
i = 0
while i < len(segment):
ch = segment[i]
if ch == "*":
out.append("[^/]*")
elif ch == "?":
out.append("[^/]")
elif ch == "[":
end = segment.find("]", i + 2 if segment[i + 1 : i + 2] in ("!", "]") else i + 1)
if end == -1:
out.append(re.escape(ch))
else:
body = segment[i + 1 : end]
if body.startswith("!"):
body = "^" + body[1:]
out.append("[" + body.replace("\\", "\\\\") + "]")
i = end
else:
out.append(re.escape(ch))
i += 1
return "".join(out)


def _scope_regex(glob: str) -> re.Pattern[str]:
parts = glob.split("/")
pieces: list[str] = []
for index, part in enumerate(parts):
last = index == len(parts) - 1
if part == "**":
pieces.append(".*" if last else "(?:[^/]+/)*")
else:
pieces.append(_segment_regex(part) + ("" if last else "/"))
return re.compile("".join(pieces))


def scope_matches(glob: str, path: str) -> bool:
"""True if a repository-relative path falls inside a declared scope.

`**` spans any number of directories, `*` and `?` stay within one path
segment, and a glob without wildcards names a file or a whole directory.
"""
glob = glob.strip().removeprefix("./").lstrip("/")
path = path.removeprefix("./").lstrip("/")
if not glob:
return False
if _glob_prefix(glob) == glob:
literal = glob.rstrip("/")
return path == literal or path.startswith(literal + "/")
return _scope_regex(glob).fullmatch(path) is not None


def paths_outside_scopes(paths: list[str], scope_globs: list[str]) -> list[str]:
"""The sorted subset of paths that no declared scope covers."""
return sorted(
{
path
for path in paths
if not any(scope_matches(glob, path) for glob in scope_globs)
}
)


def claim_scopes(conn: Connection, claim_id: int) -> list[str]:
return [
row[0]
for row in conn.execute(
"SELECT path_glob FROM scopes WHERE claim_id = ? ORDER BY id",
(claim_id,),
).fetchall()
]


def verify_commit(
repo_path: str, commit_oid: str, branch: str | None = None
) -> None:
Expand Down
Loading