gitvow 0.28.0__tar.gz → 0.28.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {gitvow-0.28.0/src/gitvow.egg-info → gitvow-0.28.2}/PKG-INFO +1 -1
  2. {gitvow-0.28.0 → gitvow-0.28.2}/pyproject.toml +1 -1
  3. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/default_policy.json +27 -5
  4. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/hooks/__init__.py +19 -1
  5. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/install.py +5 -2
  6. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/policy.py +1 -1
  7. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/targets.py +11 -2
  8. {gitvow-0.28.0 → gitvow-0.28.2/src/gitvow.egg-info}/PKG-INFO +1 -1
  9. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow.egg-info/SOURCES.txt +2 -0
  10. gitvow-0.28.2/tests/test_commit_window.py +59 -0
  11. gitvow-0.28.2/tests/test_git_rules.py +56 -0
  12. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_tiers.py +8 -4
  13. {gitvow-0.28.0 → gitvow-0.28.2}/LICENSE +0 -0
  14. {gitvow-0.28.0 → gitvow-0.28.2}/README.md +0 -0
  15. {gitvow-0.28.0 → gitvow-0.28.2}/setup.cfg +0 -0
  16. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/__init__.py +0 -0
  17. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/__main__.py +0 -0
  18. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/adapters.py +0 -0
  19. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/claims.py +0 -0
  20. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/cli.py +0 -0
  21. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/collect.py +0 -0
  22. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/decisions.py +0 -0
  23. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/digest.py +0 -0
  24. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/export.py +0 -0
  25. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/pack.py +0 -0
  26. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/paths.py +0 -0
  27. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/pricing.py +0 -0
  28. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/proposals.py +0 -0
  29. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/providers.py +0 -0
  30. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/recall.py +0 -0
  31. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/redact.py +0 -0
  32. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/report.py +0 -0
  33. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/rules.py +0 -0
  34. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/safewrite.py +0 -0
  35. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/scan.py +0 -0
  36. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/selftest.py +0 -0
  37. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/serve.py +0 -0
  38. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/snapshots.py +0 -0
  39. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/state.py +0 -0
  40. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/status.py +0 -0
  41. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/sync.py +0 -0
  42. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/transcript.py +0 -0
  43. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow.egg-info/dependency_links.txt +0 -0
  44. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow.egg-info/entry_points.txt +0 -0
  45. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow.egg-info/requires.txt +0 -0
  46. {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow.egg-info/top_level.txt +0 -0
  47. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_adapters.py +0 -0
  48. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_claims.py +0 -0
  49. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_collect.py +0 -0
  50. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_command_position.py +0 -0
  51. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_cost.py +0 -0
  52. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_decisions.py +0 -0
  53. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_digest.py +0 -0
  54. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_e2e_globalopt.py +0 -0
  55. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_export.py +0 -0
  56. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_external_adapter.py +0 -0
  57. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_hardening.py +0 -0
  58. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_hooks.py +0 -0
  59. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_install_cli.py +0 -0
  60. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_pack.py +0 -0
  61. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_packaging.py +0 -0
  62. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_policy.py +0 -0
  63. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_proposals.py +0 -0
  64. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_providers.py +0 -0
  65. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_recall.py +0 -0
  66. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_redact.py +0 -0
  67. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_report.py +0 -0
  68. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_rules.py +0 -0
  69. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_scan.py +0 -0
  70. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_serve.py +0 -0
  71. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_snapshots.py +0 -0
  72. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_status.py +0 -0
  73. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_sync.py +0 -0
  74. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_sync_http.py +0 -0
  75. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_targets.py +0 -0
  76. {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_transcript_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.28.0
3
+ Version: 0.28.2
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitvow"
7
- version = "0.28.0"
7
+ version = "0.28.2"
8
8
  description = "Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -6,11 +6,19 @@
6
6
  "reason": "recursive delete of root/home"
7
7
  },
8
8
  {
9
- "pattern": "\\bgit\\s+push\\b.*(--force|-f\\b|\\+refs)",
9
+ "program": "git",
10
+ "verbs": [
11
+ "push\\b.*?(?:--force|\\s-f\\b|\\+refs)"
12
+ ],
10
13
  "reason": "force push"
11
14
  },
12
15
  {
13
- "pattern": "\\bgit\\s+(reset\\s+--hard|clean\\s+-fd|branch\\s+-D)\\b",
16
+ "program": "git",
17
+ "verbs": [
18
+ "reset\\s+--hard",
19
+ "clean\\s+-fd",
20
+ "branch\\s+-D"
21
+ ],
14
22
  "reason": "destructive git history/worktree operation"
15
23
  },
16
24
  {
@@ -48,8 +56,20 @@
48
56
  ],
49
57
  "bash_confirm": [
50
58
  {
51
- "pattern": "\\bgit\\s+push\\b",
52
- "reason": "pushing to a remote"
59
+ "program": "git",
60
+ "verbs": [
61
+ "push"
62
+ ],
63
+ "target": "branch=(main|master|production|release[^ ]*)$",
64
+ "reason": "pushing to a protected branch"
65
+ },
66
+ {
67
+ "program": "git",
68
+ "verbs": [
69
+ "push"
70
+ ],
71
+ "reason": "pushing to a remote",
72
+ "when": "observe"
53
73
  },
54
74
  {
55
75
  "program": "kubectl",
@@ -154,7 +174,9 @@
154
174
  "rule_decay_days": 90,
155
175
  "_doc": "rule_threshold: consistent answers by authorities before a finding becomes an earned rule; rule_decay_days: days without a new confirmation before it lapses. mode: open (the agent's commit is stopped once with the card, then goes through with Gitvow-Open for every finding nobody answered; a person's commit goes through the same way) or strict (agent and person alike: refused until every finding has a gitvow decide answer). authorities: emails, email local parts or names whose decisions count; empty means anyone with commit access. session_scope: an immediate confirm is recorded as a finding and a person's yes (gitvow decide <n> accept --scope session, or the agent's own approve button) holds for the rest of the session; the next commit carries it as a scoped decision, which is never precedent. false: ask every time, record nothing.",
156
176
  "session_scope": true,
157
- "indirect_commands": "observe"
177
+ "indirect_commands": "observe",
178
+ "commit_window_seconds": 1800,
179
+ "_doc_commit_window": "How long after the gate sees the agent's `git commit` the git hooks still treat the commit as the agent's and write its trailers. Guards against a stale flag catching a person's later commit; too short and a harness that queues tool calls loses trailers. When a commit lands past the window, PostToolUse says so and logs commit_without_trailer."
158
180
  },
159
181
  "llm_classifier": {
160
182
  "enabled": false,
@@ -239,6 +239,10 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
239
239
  st["transcript_path"] = h.get("transcript_path") or st.get("transcript_path")
240
240
  st["steps"] = st.get("steps", 0) + 1
241
241
  st["pending_commit"] = time.time() # the git hook adds trailers only while this is fresh
242
+ # How long "fresh" is. Five minutes was the original guard against a stale flag catching a person's
243
+ # later commit; a harness that queues tool calls can run the agent's commit well after the gate saw it,
244
+ # and one of our own release commits lost its trailer to a seven-minute queue. The window is policy.
245
+ st["pending_ttl"] = int((pol.get("decisions") or {}).get("commit_window_seconds", 1800))
242
246
  save_state(cwd, st)
243
247
  if rules is None:
244
248
  log_event(cwd, "allowed", {"tool": tool})
@@ -400,6 +404,7 @@ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
400
404
  if tool != "Bash":
401
405
  return 0, ""
402
406
  st = load_state(cwd)
407
+ pending_at = st.get("pending_commit")
403
408
  if "pending_commit" in st or "card_ack" in st:
404
409
  st.pop("pending_commit", None)
405
410
  st.pop("card_ack", None) # the commit happened; the next set of findings earns its own card
@@ -413,7 +418,20 @@ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
413
418
  summ = summarize(h.get("transcript_path") or st.get("transcript_path"), rules=rules)
414
419
  _, head_msg, _ = git(["log", "-1", "--format=%B", head], cwd)
415
420
  if "Gitvow-Session:" not in head_msg:
416
- return 0, "" # the commit did not go through (or was not the agent's): no note
421
+ # The commit did not go through, was not the agent's, or landed after the window closed. The last case
422
+ # is a silent hole in the record unless said out loud, so measure it and say it.
423
+ rc, cts, _ = git(["log", "-1", "--format=%ct", head], cwd)
424
+ if pending_at and rc == 0 and cts.isdigit() and int(cts) >= int(pending_at):
425
+ gap = int(cts) - int(pending_at)
426
+ window = int(st.get("pending_ttl") or 300)
427
+ if gap >= window:
428
+ log_event(cwd, "commit_without_trailer", {"commit": head[:12], "gap_seconds": gap, "window": window})
429
+ return 0, (
430
+ f"gitvow: commit {head[:12]} landed {gap}s after the gate saw the commit command, past the "
431
+ f"{window}s window, so it carries no session trailer and no note. Raise "
432
+ f"decisions.commit_window_seconds if your agent queues tool calls this long."
433
+ )
434
+ return 0, ""
417
435
  _, files, _ = git(["show", "--stat", "--format=", head], cwd)
418
436
  attribution = _attribution(cwd, head, st.get("agent_blobs", {}), summ["files_written"])
419
437
  session_id = h.get("session_id") or st.get("session_id")
@@ -17,7 +17,10 @@ GD="$(git rev-parse --git-dir)"; STATE="$GD/gitvow-session.json"
17
17
  if [ -f "$STATE" ]; then
18
18
  # Trailers go only on commits the agent itself runs: the PreToolUse gate sets pending_commit when the
19
19
  # agent invokes git commit and PostToolUse clears it. A human commit in a terminal gets no trailer.
20
- SID=$(python3 -c "import json,time;s=json.load(open('$STATE'));p=s.get('pending_commit') or 0;print(s.get('session_id') or '' if time.time()-p<300 else '')" 2>/dev/null)
20
+ # The window is written into the state by the gate from decisions.commit_window_seconds (default 1800): a
21
+ # harness that queues tool calls can run the commit minutes after the gate saw it. Older states carry no
22
+ # window and keep the original five minutes.
23
+ SID=$(python3 -c "import json,time;s=json.load(open('$STATE'));p=s.get('pending_commit') or 0;w=s.get('pending_ttl') or 300;print(s.get('session_id') or '' if time.time()-p<w else '')" 2>/dev/null)
21
24
  STEP=$(python3 -c "import json;print(json.load(open('$STATE')).get('steps') or 0)" 2>/dev/null)
22
25
  if [ -n "$SID" ] && ! grep -q "^Gitvow-Session:" "$1"; then printf "\\nGitvow-Session: %s\\nGitvow-Step: %s\\n" "$SID" "$STEP" >> "$1"; fi
23
26
  # Decisions recorded with `gitvow decide` become Gitvow-Accepted/Declined/Referred trailers; findings nobody
@@ -72,7 +75,7 @@ st = json.load(open(sys.argv[1]))
72
75
  open_ = [f for f in st.get("findings") or [] if not f.get("decision") and not f.get("observe") and not f.get("immediate")]
73
76
  if not open_:
74
77
  sys.exit(0)
75
- fresh = time.time() - (st.get("pending_commit") or 0) < 300 # the agent's own commit: the gate already asked
78
+ fresh = time.time() - (st.get("pending_commit") or 0) < (st.get("pending_ttl") or 300) # the agent's own commit
76
79
  if fresh:
77
80
  sys.exit(0)
78
81
  mode = "open"
@@ -295,7 +295,7 @@ def _validate(pol: dict[str, Any], path: str) -> None:
295
295
  for key in ("authorities", "production_branches"):
296
296
  if not isinstance(dec.get(key, []), list):
297
297
  raise PolicyError(f"{path}: decisions.{key} must be a list")
298
- for key in ("rule_threshold", "rule_decay_days"):
298
+ for key in ("rule_threshold", "rule_decay_days", "commit_window_seconds"):
299
299
  v = dec.get(key, 1)
300
300
  if not isinstance(v, int) or isinstance(v, bool) or v < 1:
301
301
  raise PolicyError(f"{path}: decisions.{key} must be a positive integer")
@@ -106,8 +106,17 @@ def resolve(prog: str, args: list[str], cwd: str | None, env: dict[str, str] | N
106
106
  return kubectl(args, env)
107
107
  if prog == "helm":
108
108
  return helm(args, env)
109
- if prog == "git" and args and args[0] == "push":
110
- return git_push(args[1:], cwd)
109
+ if prog == "git":
110
+ # step over git's own options (`-C dir`, `-c k=v`, `--git-dir=…`) to the subcommand
111
+ i = 0
112
+ while i < len(args) and args[i].startswith("-"):
113
+ i += 2 if args[i] in ("-C", "-c", "--git-dir", "--work-tree", "--namespace") else 1
114
+ if i < len(args) and args[i] == "push":
115
+ repo_dir = None
116
+ for j in range(0, i):
117
+ if args[j] == "-C" and j + 1 < len(args):
118
+ repo_dir = args[j + 1]
119
+ return git_push(args[i + 1 :], repo_dir if repo_dir and os.path.isdir(repo_dir) else cwd)
111
120
  if prog in ("terraform", "tofu"):
112
121
  return terraform(args, cwd)
113
122
  except Exception: # a target resolver must never take the gate down; None asks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.28.0
3
+ Version: 0.28.2
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -43,12 +43,14 @@ tests/test_adapters.py
43
43
  tests/test_claims.py
44
44
  tests/test_collect.py
45
45
  tests/test_command_position.py
46
+ tests/test_commit_window.py
46
47
  tests/test_cost.py
47
48
  tests/test_decisions.py
48
49
  tests/test_digest.py
49
50
  tests/test_e2e_globalopt.py
50
51
  tests/test_export.py
51
52
  tests/test_external_adapter.py
53
+ tests/test_git_rules.py
52
54
  tests/test_hardening.py
53
55
  tests/test_hooks.py
54
56
  tests/test_install_cli.py
@@ -0,0 +1,59 @@
1
+ """0.28.1: the pending-commit window is policy, and a commit that lands past it is said out loud."""
2
+
3
+ import json
4
+ import time
5
+
6
+ from gitvow.hooks import post_tool_use, pre_tool_use, session_start
7
+ from gitvow.install import _write_git_hook
8
+ from tests.conftest import git
9
+
10
+
11
+ def _hooked(repo):
12
+ _write_git_hook(str(repo / ".gitvow" / "git-hooks"))
13
+ git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
14
+
15
+
16
+ def test_window_comes_from_policy_and_old_states_keep_five_minutes(repo, home, payload):
17
+ _hooked(repo)
18
+ session_start(payload("SessionStart"), str(home))
19
+ pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}), str(home))
20
+ p = repo / ".git" / "gitvow-session.json"
21
+ st = json.loads(p.read_text())
22
+ assert st["pending_ttl"] == 1800
23
+ # ten minutes ago is inside the default window: the trailer is written
24
+ st["pending_commit"] = time.time() - 600
25
+ p.write_text(json.dumps(st))
26
+ (repo / "a.txt").write_text("1\n")
27
+ git(repo, "commit", "-qam", "queued ten minutes")
28
+ assert "Gitvow-Session: sess-1" in git(repo, "log", "-1", "--format=%B")
29
+ # a state written by an older gitvow has no window and keeps the original five minutes
30
+ st = json.loads(p.read_text())
31
+ st["pending_commit"] = time.time() - 600
32
+ st.pop("pending_ttl", None)
33
+ p.write_text(json.dumps(st))
34
+ (repo / "a.txt").write_text("2\n")
35
+ git(repo, "commit", "-qam", "old state, ten minutes")
36
+ assert "Gitvow-Session" not in git(repo, "log", "-1", "--format=%B")
37
+
38
+
39
+ def test_a_commit_past_the_window_is_reported_and_logged(repo, home, payload, monkeypatch, tmp_path):
40
+ _hooked(repo)
41
+ pol = tmp_path / "policy.json"
42
+ from gitvow.policy import DEFAULT_POLICY_PATH
43
+
44
+ with open(DEFAULT_POLICY_PATH) as fh:
45
+ d = json.load(fh)
46
+ d["decisions"]["commit_window_seconds"] = 1
47
+ (repo / ".gitvow").mkdir(exist_ok=True)
48
+ (repo / ".gitvow" / "policy.json").write_text(json.dumps(d))
49
+ session_start(payload("SessionStart"), str(home))
50
+ pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}), str(home))
51
+ assert json.loads((repo / ".git" / "gitvow-session.json").read_text())["pending_ttl"] == 1
52
+ time.sleep(1.2)
53
+ (repo / "a.txt").write_text("late\n")
54
+ git(repo, "commit", "-qam", "late commit")
55
+ assert "Gitvow-Session" not in git(repo, "log", "-1", "--format=%B")
56
+ code, msg = post_tool_use(payload("PostToolUse", "Bash", {"command": "git commit -m x"}), str(home))
57
+ assert code == 0 and "past the 1s window" in msg and "carries no session trailer" in msg
58
+ assert '"kind": "commit_without_trailer"' in (repo / ".git" / "gitvow-hooks.log").read_text()
59
+ assert pol is not None
@@ -0,0 +1,56 @@
1
+ """0.28.2: the git rules are program rules, so `git -C <dir> push` and `git -c k=v push` are seen.
2
+
3
+ Found by dogfooding: every push made through `git -C <repo> push` on 2026-09-22 walked past the `pattern` rule
4
+ `\\bgit\\s+push\\b`, which needed the two words adjacent. Same bug class the kubectl rules had until 0.17.1.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from gitvow.policy import DEFAULT_POLICY_PATH, evaluate
13
+ from tests.conftest import git
14
+
15
+ C = "/Users/someone/work/repo"
16
+
17
+
18
+ @pytest.fixture
19
+ def pol():
20
+ return json.loads(Path(DEFAULT_POLICY_PATH).read_text())
21
+
22
+
23
+ @pytest.mark.parametrize(
24
+ "cmd",
25
+ [
26
+ "git push --force origin main",
27
+ "git push -f",
28
+ f"git -C {C} push -q -f origin v0.26",
29
+ f"git -C {C} push --force-with-lease origin main",
30
+ "git -c user.name=x push +refs/heads/main",
31
+ f"git -C {C} reset --hard HEAD~1",
32
+ f"git -C {C} clean -fd",
33
+ "git branch -D feature",
34
+ ],
35
+ )
36
+ def test_destructive_git_is_denied_whatever_sits_between_git_and_the_verb(pol, cmd):
37
+ assert evaluate(pol, "Bash", {"command": cmd}).outcome == "deny", cmd
38
+
39
+
40
+ def test_a_push_to_a_protected_branch_asks_and_other_pushes_are_observed(repo, home, pol):
41
+ git(repo, "remote", "add", "origin", "git@example.com:acme/ledger.git")
42
+ d = evaluate(pol, "Bash", {"command": f"git -C {repo} push -q origin main"}, str(repo))
43
+ assert d.outcome == "confirm" and d.when == "immediate" and d.reason == "pushing to a protected branch"
44
+ assert d.findings[0]["finding"].endswith("@ remote=git@example.com:acme/ledger.git branch=main")
45
+ d = evaluate(pol, "Bash", {"command": "git push origin feature/x"}, str(repo))
46
+ assert d.outcome == "confirm" and d.when == "observe" and d.reason == "pushing to a remote"
47
+ d = evaluate(pol, "Bash", {"command": "git push origin HEAD:refs/heads/release-2"}, str(repo))
48
+ assert d.when == "immediate"
49
+ # a push whose destination cannot be read (no such remote) is asked about
50
+ d = evaluate(pol, "Bash", {"command": "git push nowhere feature/x"}, str(repo))
51
+ assert d.when == "immediate"
52
+
53
+
54
+ def test_mentions_of_push_inside_arguments_are_not_pushes(pol):
55
+ assert evaluate(pol, "Bash", {"command": 'git commit -m "docs: how to git push safely"'}).outcome == "allow"
56
+ assert evaluate(pol, "Bash", {"command": "echo 'git push --force is denied' >> notes.md"}).outcome == "allow"
@@ -105,10 +105,14 @@ def test_immediate_confirm_is_answered_once_per_session(repo, home, payload, tra
105
105
  session_start(payload("SessionStart"), str(home))
106
106
  push = payload("PreToolUse", "Bash", {"command": "git push origin feat"}, transcript)
107
107
  code, msg = pre_tool_use(push, str(home))
108
- assert code == 2 and msg.startswith("CONFIRMATION REQUIRED (pushing to a remote)")
108
+ assert code == 2 and msg.startswith("CONFIRMATION REQUIRED (pushing to a protected branch)")
109
109
  assert "gitvow decide 1 accept --scope session" in msg and "gitvow decide 1 decline" in msg
110
110
  fs = dec.open_findings(str(repo))
111
- assert fs[0]["finding"] == "run git (pushing to a remote)" and fs[0]["immediate"] and fs[0]["decision"] is None
111
+ assert (
112
+ fs[0]["finding"] == "run git (pushing to a protected branch)"
113
+ and fs[0]["immediate"]
114
+ and fs[0]["decision"] is None
115
+ )
112
116
  # asked and never answered: not on the card, not owed
113
117
  assert dec.undecided(str(repo)) == []
114
118
  assert "No open findings" in dec.card(str(repo), for_agent=False)
@@ -130,7 +134,7 @@ def test_immediate_confirm_is_answered_once_per_session(repo, home, payload, tra
130
134
  pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))
131
135
  git(repo, "commit", "-qam", "after the push was allowed")
132
136
  body = git(repo, "log", "-1", "--format=%B")
133
- assert "Gitvow-Accepted: run git (pushing to a remote) by t scope=session: release branch" in body
137
+ assert "Gitvow-Accepted: run git (pushing to a protected branch) by t scope=session: release branch" in body
134
138
  post_tool_use(payload("PostToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))
135
139
  from gitvow.rules import derive
136
140
 
@@ -154,7 +158,7 @@ def test_declined_immediate_confirm_stays_blocked_for_the_session(repo, home, pa
154
158
  (repo / "a.txt").write_text("d\n")
155
159
  pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))
156
160
  git(repo, "commit", "-qam", "carries the decline")
157
- assert "Gitvow-Declined: run git (pushing to a remote) by t: not from this branch" in git(
161
+ assert "Gitvow-Declined: run git (pushing to a protected branch) by t: not from this branch" in git(
158
162
  repo, "log", "-1", "--format=%B"
159
163
  )
160
164
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes