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.
- {gitvow-0.28.0/src/gitvow.egg-info → gitvow-0.28.2}/PKG-INFO +1 -1
- {gitvow-0.28.0 → gitvow-0.28.2}/pyproject.toml +1 -1
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/default_policy.json +27 -5
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/hooks/__init__.py +19 -1
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/install.py +5 -2
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/policy.py +1 -1
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/targets.py +11 -2
- {gitvow-0.28.0 → gitvow-0.28.2/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow.egg-info/SOURCES.txt +2 -0
- gitvow-0.28.2/tests/test_commit_window.py +59 -0
- gitvow-0.28.2/tests/test_git_rules.py +56 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_tiers.py +8 -4
- {gitvow-0.28.0 → gitvow-0.28.2}/LICENSE +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/README.md +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/setup.cfg +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/__init__.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/__main__.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/adapters.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/claims.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/cli.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/collect.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/decisions.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/digest.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/export.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/pack.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/paths.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/pricing.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/proposals.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/providers.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/recall.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/redact.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/report.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/rules.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/safewrite.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/scan.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/selftest.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/serve.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/state.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/status.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/sync.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow/transcript.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_adapters.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_claims.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_collect.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_command_position.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_cost.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_decisions.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_digest.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_e2e_globalopt.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_export.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_external_adapter.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_hardening.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_hooks.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_install_cli.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_pack.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_packaging.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_policy.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_proposals.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_providers.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_recall.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_redact.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_report.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_rules.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_scan.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_serve.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_snapshots.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_status.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_sync.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_sync_http.py +0 -0
- {gitvow-0.28.0 → gitvow-0.28.2}/tests/test_targets.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
"
|
|
9
|
+
"program": "git",
|
|
10
|
+
"verbs": [
|
|
11
|
+
"push\\b.*?(?:--force|\\s-f\\b|\\+refs)"
|
|
12
|
+
],
|
|
10
13
|
"reason": "force push"
|
|
11
14
|
},
|
|
12
15
|
{
|
|
13
|
-
"
|
|
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
|
-
"
|
|
52
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
110
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|