gitvow 0.2.3__tar.gz → 0.4.0__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.2.3/src/gitvow.egg-info → gitvow-0.4.0}/PKG-INFO +1 -1
- {gitvow-0.2.3 → gitvow-0.4.0}/pyproject.toml +1 -1
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/cli.py +61 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/hooks/__init__.py +1 -1
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/install.py +17 -5
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/policy.py +18 -3
- gitvow-0.4.0/src/gitvow/providers.py +126 -0
- gitvow-0.4.0/src/gitvow/report.py +140 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/selftest.py +19 -0
- {gitvow-0.2.3 → gitvow-0.4.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow.egg-info/SOURCES.txt +5 -1
- gitvow-0.4.0/tests/test_providers.py +160 -0
- gitvow-0.4.0/tests/test_report.py +84 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/LICENSE +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/README.md +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/setup.cfg +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/state.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/tests/test_collect.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/tests/test_hooks.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/tests/test_packaging.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/tests/test_policy.py +0 -0
- {gitvow-0.2.3 → gitvow-0.4.0}/tests/test_redact.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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.
|
|
7
|
+
version = "0.4.0"
|
|
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"}
|
|
@@ -13,7 +13,9 @@ from .collect import collect, summarize_text
|
|
|
13
13
|
from .hooks import HANDLERS, LEGACY_NOTES_REF, notes_ref
|
|
14
14
|
from .install import install_repo, install_user, uninstall_repo, uninstall_user
|
|
15
15
|
from .policy import PolicyError, evaluate, load_policy
|
|
16
|
+
from .providers import QUESTIONS, ask
|
|
16
17
|
from .redact import RedactionError, load_rules, redact
|
|
18
|
+
from .report import build, render_markdown
|
|
17
19
|
from .state import git
|
|
18
20
|
|
|
19
21
|
|
|
@@ -65,6 +67,26 @@ def cmd_check(a: argparse.Namespace) -> int:
|
|
|
65
67
|
return 2 if d.blocks else 0
|
|
66
68
|
|
|
67
69
|
|
|
70
|
+
def cmd_ask(a: argparse.Namespace) -> int:
|
|
71
|
+
"""Ask every configured provider one question and show the gate's decision."""
|
|
72
|
+
try:
|
|
73
|
+
pol = load_policy(os.getcwd())
|
|
74
|
+
except PolicyError as e:
|
|
75
|
+
print(f"policy error: {e}", file=sys.stderr)
|
|
76
|
+
return 2
|
|
77
|
+
if not pol.get("providers"):
|
|
78
|
+
print("no providers configured in the active policy", file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
path = a.path or (a.subject if a.question == "gate_bearing" else "")
|
|
81
|
+
answers = ask(pol, "Edit", {"file_path": path}, os.getcwd(), only=[(a.question, a.subject)])
|
|
82
|
+
for ans in answers:
|
|
83
|
+
ev = " — " + "; ".join(ans.evidence) if ans.evidence else ""
|
|
84
|
+
print(f"{ans.provider}: {ans.answer}{ev}")
|
|
85
|
+
blocked = any(x.blocks for x in answers)
|
|
86
|
+
print("decision: " + ("CONFIRM" if blocked else "ALLOW"))
|
|
87
|
+
return 2 if blocked else 0
|
|
88
|
+
|
|
89
|
+
|
|
68
90
|
def cmd_show(a: argparse.Namespace) -> int:
|
|
69
91
|
"""Print a commit's trailers and session note."""
|
|
70
92
|
rc, msg, _ = git(["log", "-1", "--format=%H%n%s%n%b", a.commit], os.getcwd())
|
|
@@ -94,6 +116,31 @@ def cmd_redact(a: argparse.Namespace) -> int:
|
|
|
94
116
|
return 0
|
|
95
117
|
|
|
96
118
|
|
|
119
|
+
def cmd_report(a: argparse.Namespace) -> int:
|
|
120
|
+
"""Per-commit report for base..head; exit 1 when --require-notes and a trailered commit has no note."""
|
|
121
|
+
try:
|
|
122
|
+
r = build(os.getcwd(), a.base, a.head)
|
|
123
|
+
except ValueError as e:
|
|
124
|
+
print(f"report error: {e}", file=sys.stderr)
|
|
125
|
+
return 1
|
|
126
|
+
print(json.dumps(r, indent=1) if a.json else render_markdown(r), end="" if not a.json else "\n")
|
|
127
|
+
if a.require_notes and r["missing_notes"]:
|
|
128
|
+
print(f"missing session notes for: {', '.join(r['missing_notes'])}", file=sys.stderr)
|
|
129
|
+
return 1
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_push_notes(a: argparse.Namespace) -> int:
|
|
134
|
+
import subprocess
|
|
135
|
+
|
|
136
|
+
env = {**os.environ, "GITVOW_PUSHING_NOTES": "1"} # the pre-push hook must not push the same refs again
|
|
137
|
+
r = subprocess.run(
|
|
138
|
+
["git", "push", a.remote, "refs/notes/gitvow/*:refs/notes/gitvow/*"], env=env, capture_output=True, text=True
|
|
139
|
+
)
|
|
140
|
+
print((r.stderr or r.stdout).strip() or "notes pushed", file=sys.stderr if r.returncode else sys.stdout)
|
|
141
|
+
return 1 if r.returncode else 0
|
|
142
|
+
|
|
143
|
+
|
|
97
144
|
def cmd_collect(a: argparse.Namespace) -> int:
|
|
98
145
|
w = collect(os.path.expanduser("~"), a.out)
|
|
99
146
|
print(summarize_text(w))
|
|
@@ -138,9 +185,23 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
138
185
|
s = sub.add_parser("show", help="print a commit's trailers and session note")
|
|
139
186
|
s.add_argument("commit", nargs="?", default="HEAD")
|
|
140
187
|
s.set_defaults(f=cmd_show)
|
|
188
|
+
s = sub.add_parser("ask", help="ask configured providers a question: gitvow ask route_gate /v1/x --path src/api.py")
|
|
189
|
+
s.add_argument("question", choices=QUESTIONS)
|
|
190
|
+
s.add_argument("subject")
|
|
191
|
+
s.add_argument("--path", default="")
|
|
192
|
+
s.set_defaults(f=cmd_ask)
|
|
141
193
|
s = sub.add_parser("redact", help="apply built-in redaction plus your rules files to text")
|
|
142
194
|
s.add_argument("text", nargs="+")
|
|
143
195
|
s.set_defaults(f=cmd_redact)
|
|
196
|
+
s = sub.add_parser("report", help="per-commit report for a range: trailers, notes, attribution, said vs did")
|
|
197
|
+
s.add_argument("--base", required=True)
|
|
198
|
+
s.add_argument("--head", default="HEAD")
|
|
199
|
+
s.add_argument("--json", action="store_true")
|
|
200
|
+
s.add_argument("--require-notes", action="store_true")
|
|
201
|
+
s.set_defaults(f=cmd_report)
|
|
202
|
+
s = sub.add_parser("push-notes", help="push refs/notes/gitvow/* to a remote (default origin)")
|
|
203
|
+
s.add_argument("remote", nargs="?", default="origin")
|
|
204
|
+
s.set_defaults(f=cmd_push_notes)
|
|
144
205
|
s = sub.add_parser("collect", help="gather ledger, logs, trailers and notes into one directory")
|
|
145
206
|
s.add_argument("--out", default=os.path.expanduser("~/Desktop"))
|
|
146
207
|
s.set_defaults(f=cmd_collect)
|
|
@@ -64,7 +64,7 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
|
64
64
|
2,
|
|
65
65
|
f"BLOCKED: tool policy could not be loaded ({e}); refusing all tool calls until the policy is restored.",
|
|
66
66
|
)
|
|
67
|
-
d = evaluate(pol, tool, inp)
|
|
67
|
+
d = evaluate(pol, tool, inp, cwd)
|
|
68
68
|
rules, warn = _rules_or_none(cwd, home)
|
|
69
69
|
if d.blocks:
|
|
70
70
|
payload: dict[str, Any] = {"tool": tool, "reason": d.reason, "session_id": h.get("session_id")}
|
|
@@ -24,6 +24,17 @@ SELF="$(cd "$(dirname "$0")" && pwd)"; REPOHOOKS="$(cd "$GD/hooks" 2>/dev/null &
|
|
|
24
24
|
[ -x "$GD/hooks/prepare-commit-msg" ] && [ "$SELF" != "$REPOHOOKS" ] && exec "$GD/hooks/prepare-commit-msg" "$@"
|
|
25
25
|
exit 0
|
|
26
26
|
"""
|
|
27
|
+
PRE_PUSH_HOOK = """#!/bin/sh
|
|
28
|
+
# gitvow: push session notes (refs/notes/gitvow/*) to the same remote whenever a branch is pushed.
|
|
29
|
+
# The inner push re-enters this hook; GITVOW_PUSHING_NOTES stops the recursion.
|
|
30
|
+
REMOTE="$1"
|
|
31
|
+
if [ -z "$GITVOW_PUSHING_NOTES" ] && [ -n "$REMOTE" ] && git for-each-ref --count=1 refs/notes/gitvow/ | grep -q .; then
|
|
32
|
+
GITVOW_PUSHING_NOTES=1 git push --quiet "$REMOTE" 'refs/notes/gitvow/*:refs/notes/gitvow/*' 2>/dev/null || true
|
|
33
|
+
fi
|
|
34
|
+
GD="$(git rev-parse --git-dir)"; SELF="$(cd "$(dirname "$0")" && pwd)"; REPOHOOKS="$(cd "$GD/hooks" 2>/dev/null && pwd || true)"
|
|
35
|
+
[ -x "$GD/hooks/pre-push" ] && [ "$SELF" != "$REPOHOOKS" ] && exec "$GD/hooks/pre-push" "$@"
|
|
36
|
+
exit 0
|
|
37
|
+
"""
|
|
27
38
|
MARKER = "gitvow hook "
|
|
28
39
|
NOTES_GLOB = "refs/notes/gitvow/*"
|
|
29
40
|
NOTES_KEYS = ("notes.displayRef", "notes.rewriteRef")
|
|
@@ -106,11 +117,12 @@ def unmerge_settings(path: str) -> None:
|
|
|
106
117
|
|
|
107
118
|
def _write_git_hook(dirpath: str) -> str:
|
|
108
119
|
os.makedirs(dirpath, exist_ok=True)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
120
|
+
for name, body in (("prepare-commit-msg", GIT_HOOK), ("pre-push", PRE_PUSH_HOOK)):
|
|
121
|
+
p = os.path.join(dirpath, name)
|
|
122
|
+
with open(p, "w") as fh:
|
|
123
|
+
fh.write(body)
|
|
124
|
+
os.chmod(p, 0o755) # noqa: S103 # nosec B103 - git runs hooks as the invoking user; must be executable
|
|
125
|
+
return os.path.join(dirpath, "prepare-commit-msg")
|
|
114
126
|
|
|
115
127
|
|
|
116
128
|
def install_user(home: str, cmd_prefix: str | None = None) -> list[str]:
|
|
@@ -10,6 +10,8 @@ import subprocess
|
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
+
from . import providers as _providers
|
|
14
|
+
|
|
13
15
|
DEFAULT_POLICY_PATH = os.path.join(os.path.dirname(__file__), "default_policy.json")
|
|
14
16
|
|
|
15
17
|
|
|
@@ -63,9 +65,18 @@ def _validate(pol: dict[str, Any], path: str) -> None:
|
|
|
63
65
|
re.compile(pat)
|
|
64
66
|
except re.error as e:
|
|
65
67
|
raise PolicyError(f"{path}: bad regex in {key}: {pat} ({e})") from e
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
provs = pol.get("providers", [])
|
|
69
|
+
if not isinstance(provs, list):
|
|
70
|
+
raise PolicyError(f"{path}: providers must be a list")
|
|
71
|
+
for prov in provs:
|
|
72
|
+
if not isinstance(prov, dict) or not prov.get("name") or not prov.get("command"):
|
|
73
|
+
raise PolicyError(f"{path}: each provider needs 'name' and 'command'")
|
|
74
|
+
bad = [q for q in prov.get("questions", []) if q not in _providers.QUESTIONS]
|
|
75
|
+
if bad:
|
|
76
|
+
raise PolicyError(f"{path}: provider {prov['name']} lists unknown questions {bad}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any], cwd: str | None = None) -> Decision:
|
|
69
80
|
text = tool_input.get("command", "") if tool == "Bash" else ""
|
|
70
81
|
path = tool_input.get("file_path", "") or tool_input.get("notebook_path", "")
|
|
71
82
|
if tool == "Bash":
|
|
@@ -85,6 +96,10 @@ def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any]) -> Deci
|
|
|
85
96
|
allow = pol.get("mcp_allow", [])
|
|
86
97
|
if allow and not any(re.fullmatch(p, tool) for p in allow):
|
|
87
98
|
return Decision("confirm", "MCP tool not on allow list", tool)
|
|
99
|
+
if pol.get("providers") and tool in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
|
|
100
|
+
hits = [a for a in _providers.ask(pol, tool, tool_input, cwd) if a.blocks]
|
|
101
|
+
if hits:
|
|
102
|
+
return Decision("confirm", "; ".join(h.text() for h in hits[:3]), path)
|
|
88
103
|
clf = pol.get("llm_classifier") or {}
|
|
89
104
|
if clf.get("enabled") and clf.get("command"):
|
|
90
105
|
return _classify(clf["command"], tool, tool_input, text or path or tool)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Providers: external programs the gate asks factual questions (gate_bearing, route_gate, route_callers)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
PROTOCOL = 1
|
|
14
|
+
QUESTIONS = ("gate_bearing", "route_gate", "route_callers")
|
|
15
|
+
EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
16
|
+
ROUTE_RE = re.compile(r"""["'`](/[A-Za-z0-9_\-./{}:*$]*)["'`]""")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Answer:
|
|
21
|
+
provider: str
|
|
22
|
+
question: str
|
|
23
|
+
subject: str
|
|
24
|
+
answer: str # yes | no | unknown | failed
|
|
25
|
+
evidence: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def blocks(self) -> bool:
|
|
29
|
+
return self.answer in ("yes", "failed")
|
|
30
|
+
|
|
31
|
+
def text(self) -> str:
|
|
32
|
+
why = "; ".join(self.evidence[:4]) if self.evidence else ""
|
|
33
|
+
if self.answer == "failed":
|
|
34
|
+
return f"provider {self.provider} unavailable for {self.question} {self.subject}"
|
|
35
|
+
return f"provider {self.provider}: {self.question} {self.subject}" + (f": {why}" if why else "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def route_literals(text: str) -> list[str]:
|
|
39
|
+
"""Quoted strings that look like routes: begin with '/', no spaces, at least one more character."""
|
|
40
|
+
out: list[str] = []
|
|
41
|
+
for m in ROUTE_RE.finditer(text or ""):
|
|
42
|
+
r = m.group(1)
|
|
43
|
+
if len(r) > 1 and not r.startswith("//") and "." not in os.path.basename(r) and r not in out:
|
|
44
|
+
out.append(r)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def questions_for(tool: str, tool_input: dict[str, Any]) -> list[tuple[str, str]]:
|
|
49
|
+
"""(question, subject) pairs the gate should ask for this tool call."""
|
|
50
|
+
if tool not in EDIT_TOOLS:
|
|
51
|
+
return []
|
|
52
|
+
path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
|
|
53
|
+
qs: list[tuple[str, str]] = []
|
|
54
|
+
if path:
|
|
55
|
+
qs.append(("gate_bearing", path))
|
|
56
|
+
old, new = "", ""
|
|
57
|
+
if tool == "Edit":
|
|
58
|
+
old, new = str(tool_input.get("old_string") or ""), str(tool_input.get("new_string") or "")
|
|
59
|
+
elif tool == "MultiEdit":
|
|
60
|
+
for e in tool_input.get("edits") or []:
|
|
61
|
+
old += "\n" + str(e.get("old_string") or "")
|
|
62
|
+
new += "\n" + str(e.get("new_string") or "")
|
|
63
|
+
elif tool == "Write":
|
|
64
|
+
new = str(tool_input.get("content") or "")
|
|
65
|
+
before, after = set(route_literals(old)), set(route_literals(new))
|
|
66
|
+
qs += [("route_gate", r) for r in sorted(after - before)]
|
|
67
|
+
qs += [("route_callers", r) for r in sorted(before - after)]
|
|
68
|
+
return qs
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _run(prov: dict[str, Any], question: str, subject: str, repo: str, path: str, tool: str) -> Answer:
|
|
72
|
+
name = str(prov.get("name") or "provider")
|
|
73
|
+
req = {
|
|
74
|
+
"protocol": PROTOCOL,
|
|
75
|
+
"question": question,
|
|
76
|
+
"subject": subject,
|
|
77
|
+
"repo": repo,
|
|
78
|
+
"path": path,
|
|
79
|
+
"tool_name": tool,
|
|
80
|
+
}
|
|
81
|
+
try:
|
|
82
|
+
argv = shlex.split(str(prov.get("command") or ""))
|
|
83
|
+
if not argv:
|
|
84
|
+
return Answer(name, question, subject, "failed", ["empty command"])
|
|
85
|
+
r = subprocess.run(
|
|
86
|
+
argv,
|
|
87
|
+
input=json.dumps(req),
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=float(prov.get("timeout") or 10),
|
|
91
|
+
check=False,
|
|
92
|
+
cwd=repo or None,
|
|
93
|
+
)
|
|
94
|
+
if r.returncode != 0:
|
|
95
|
+
return Answer(name, question, subject, "failed", [f"exit {r.returncode}"])
|
|
96
|
+
data = json.loads(r.stdout)
|
|
97
|
+
ans = str(data.get("answer", "unknown")).lower()
|
|
98
|
+
if ans not in ("yes", "no", "unknown"):
|
|
99
|
+
return Answer(name, question, subject, "failed", ["bad answer"])
|
|
100
|
+
ev = [str(e)[:200] for e in (data.get("evidence") or [])[:8]]
|
|
101
|
+
return Answer(name, question, subject, ans, ev)
|
|
102
|
+
except (OSError, subprocess.TimeoutExpired, ValueError, AttributeError) as e:
|
|
103
|
+
return Answer(name, question, subject, "failed", [type(e).__name__])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ask(
|
|
107
|
+
pol: dict[str, Any],
|
|
108
|
+
tool: str,
|
|
109
|
+
tool_input: dict[str, Any],
|
|
110
|
+
cwd: str | None = None,
|
|
111
|
+
only: list[tuple[str, str]] | None = None,
|
|
112
|
+
) -> list[Answer]:
|
|
113
|
+
provs = pol.get("providers") or []
|
|
114
|
+
if not provs:
|
|
115
|
+
return []
|
|
116
|
+
repo = cwd or os.getcwd()
|
|
117
|
+
path = str(tool_input.get("file_path") or tool_input.get("notebook_path") or "")
|
|
118
|
+
if path and os.path.isabs(path):
|
|
119
|
+
rel = os.path.relpath(path, repo)
|
|
120
|
+
path = rel if not rel.startswith("..") else path
|
|
121
|
+
answers: list[Answer] = []
|
|
122
|
+
for q, subj in only if only is not None else questions_for(tool, {**tool_input, "file_path": path}):
|
|
123
|
+
for prov in provs:
|
|
124
|
+
if q in (prov.get("questions") or QUESTIONS):
|
|
125
|
+
answers.append(_run(prov, q, subj, repo, path, tool))
|
|
126
|
+
return answers
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Per-commit report for a range: trailers paired with session notes, attribution, and said-vs-did."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .hooks import LEGACY_NOTES_REF, notes_ref
|
|
11
|
+
from .state import git
|
|
12
|
+
|
|
13
|
+
TRAILER_RE = re.compile(r"^Gitvow-Session:\s*(\S+)", re.M)
|
|
14
|
+
STEP_RE = re.compile(r"^Gitvow-Step:\s*(\d+)", re.M)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _note_for(cwd: str, sha: str, session_id: str) -> dict[str, Any] | None:
|
|
18
|
+
for ref in (notes_ref(session_id), LEGACY_NOTES_REF):
|
|
19
|
+
rc, body, _ = git(["notes", f"--ref={ref}", "show", sha], cwd)
|
|
20
|
+
if rc == 0 and body.startswith("gitvow-session"):
|
|
21
|
+
try:
|
|
22
|
+
return json.loads(body.split("\n", 1)[1])
|
|
23
|
+
except (json.JSONDecodeError, IndexError):
|
|
24
|
+
return None
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _changed_files(cwd: str, sha: str) -> list[str]:
|
|
29
|
+
_, out, _ = git(["show", "--name-only", "--format=", sha], cwd)
|
|
30
|
+
return [ln for ln in out.splitlines() if ln.strip()]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def said_vs_did(plan: str, files: list[str]) -> dict[str, list[str]]:
|
|
34
|
+
"""Which changed files does the stated plan mention (by path or basename, case-insensitive)?"""
|
|
35
|
+
low = plan.lower()
|
|
36
|
+
mentioned, unmentioned = [], []
|
|
37
|
+
for f in files:
|
|
38
|
+
base = os.path.basename(f).lower()
|
|
39
|
+
stem = os.path.splitext(base)[0]
|
|
40
|
+
if f.lower() in low or base in low or (len(stem) > 3 and stem in low):
|
|
41
|
+
mentioned.append(f)
|
|
42
|
+
else:
|
|
43
|
+
unmentioned.append(f)
|
|
44
|
+
return {"mentioned": mentioned, "unmentioned": unmentioned}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build(cwd: str, base: str, head: str = "HEAD") -> dict[str, Any]:
|
|
48
|
+
rc, out, err = git(["log", "--reverse", "--format=%H%x00%s%x00%B%x01", f"{base}..{head}"], cwd)
|
|
49
|
+
if rc != 0:
|
|
50
|
+
raise ValueError(err or f"cannot list {base}..{head}")
|
|
51
|
+
commits: list[dict[str, Any]] = []
|
|
52
|
+
for rec in out.split("\x01"):
|
|
53
|
+
rec = rec.strip("\n")
|
|
54
|
+
if not rec.strip():
|
|
55
|
+
continue
|
|
56
|
+
sha, subject, body = rec.split("\x00", 2)
|
|
57
|
+
m = TRAILER_RE.search(body)
|
|
58
|
+
entry: dict[str, Any] = {"sha": sha, "short": sha[:7], "subject": subject, "files": _changed_files(cwd, sha)}
|
|
59
|
+
if not m:
|
|
60
|
+
entry["kind"] = "human"
|
|
61
|
+
commits.append(entry)
|
|
62
|
+
continue
|
|
63
|
+
sid = m.group(1)
|
|
64
|
+
sm = STEP_RE.search(body)
|
|
65
|
+
entry.update({"kind": "agent", "session_id": sid, "step": int(sm.group(1)) if sm else None})
|
|
66
|
+
note = _note_for(cwd, sha, sid)
|
|
67
|
+
entry["note_found"] = note is not None
|
|
68
|
+
if note:
|
|
69
|
+
plan = note.get("last_stated_plan") or ""
|
|
70
|
+
att = note.get("attribution") or {}
|
|
71
|
+
entry.update(
|
|
72
|
+
{
|
|
73
|
+
"plan": plan,
|
|
74
|
+
"tools_used": note.get("tools_used", []),
|
|
75
|
+
"tool_calls": note.get("tool_calls_so_far"),
|
|
76
|
+
"turns": note.get("assistant_turns_so_far"),
|
|
77
|
+
"attribution": {
|
|
78
|
+
"files_in_commit": att.get("files_in_commit"),
|
|
79
|
+
"touched_by_agent": att.get("touched_by_agent"),
|
|
80
|
+
"agent_share": att.get("agent_share"),
|
|
81
|
+
"lines_changed_by_human_after_agent": att.get("lines_changed_by_human_after_agent"),
|
|
82
|
+
},
|
|
83
|
+
"said_vs_did": said_vs_did(plan, entry["files"]),
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
commits.append(entry)
|
|
87
|
+
agent = [c for c in commits if c["kind"] == "agent"]
|
|
88
|
+
return {
|
|
89
|
+
"base": base,
|
|
90
|
+
"head": head,
|
|
91
|
+
"commits": commits,
|
|
92
|
+
"agent_commits": len(agent),
|
|
93
|
+
"human_commits": len(commits) - len(agent),
|
|
94
|
+
"missing_notes": [c["short"] for c in agent if not c["note_found"]],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_markdown(r: dict[str, Any]) -> str:
|
|
99
|
+
lines = [
|
|
100
|
+
f"## gitvow: {r['agent_commits']} agent commit{'s' if r['agent_commits'] != 1 else ''}, "
|
|
101
|
+
f"{r['human_commits']} human commit{'s' if r['human_commits'] != 1 else ''}",
|
|
102
|
+
"",
|
|
103
|
+
]
|
|
104
|
+
if r["missing_notes"]:
|
|
105
|
+
lines += [
|
|
106
|
+
f"> **Missing session notes** for {', '.join(r['missing_notes'])}. "
|
|
107
|
+
"The author's notes were not pushed; run `gitvow push-notes` or `git push origin 'refs/notes/gitvow/*'`.",
|
|
108
|
+
"",
|
|
109
|
+
]
|
|
110
|
+
for c in r["commits"]:
|
|
111
|
+
if c["kind"] == "human":
|
|
112
|
+
lines += [f"### {c['short']} {c['subject']} — no session trailer (made by a person)", ""]
|
|
113
|
+
continue
|
|
114
|
+
sid = c["session_id"][:8]
|
|
115
|
+
lines.append(
|
|
116
|
+
f"### {c['short']} {c['subject']} — session {sid}, step {c['step'] if c['step'] is not None else '?'}"
|
|
117
|
+
)
|
|
118
|
+
if not c["note_found"]:
|
|
119
|
+
lines += ["**Session note:** missing (not pushed)", ""]
|
|
120
|
+
continue
|
|
121
|
+
plan = c["plan"].strip().replace("\n", " ")
|
|
122
|
+
lines.append(f"**Plan:** {plan[:400] if plan else '(none stated before committing)'}")
|
|
123
|
+
tools = ", ".join(c["tools_used"]) or "none recorded"
|
|
124
|
+
lines.append(f"**Tools:** {tools} · {c['tool_calls']} tool calls · {c['turns']} turns")
|
|
125
|
+
a = c["attribution"]
|
|
126
|
+
share = "n/a" if a["agent_share"] is None else f"{a['agent_share']:.2f}"
|
|
127
|
+
lines.append(
|
|
128
|
+
f"**Attribution:** {a['files_in_commit']} file{'s' if a['files_in_commit'] != 1 else ''}, "
|
|
129
|
+
f"agent share {share}, {a['lines_changed_by_human_after_agent'] or 0} lines changed by a human after the agent"
|
|
130
|
+
)
|
|
131
|
+
svd = c["said_vs_did"]
|
|
132
|
+
parts = []
|
|
133
|
+
if svd["mentioned"]:
|
|
134
|
+
parts.append(", ".join(svd["mentioned"]) + " mentioned in plan ✓")
|
|
135
|
+
if svd["unmentioned"]:
|
|
136
|
+
parts.append(", ".join(svd["unmentioned"]) + " **not mentioned in plan**")
|
|
137
|
+
if not plan:
|
|
138
|
+
parts.append("no plan to compare")
|
|
139
|
+
lines += ["**Said vs did:** " + " · ".join(parts), ""]
|
|
140
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import shutil
|
|
8
8
|
import subprocess
|
|
9
|
+
import sys
|
|
9
10
|
import tempfile
|
|
10
11
|
|
|
11
12
|
from .hooks import post_tool_use, pre_tool_use, session_start, stop
|
|
@@ -66,6 +67,24 @@ def run() -> int:
|
|
|
66
67
|
"deny: destructive MCP tool",
|
|
67
68
|
)
|
|
68
69
|
)
|
|
70
|
+
prov = (
|
|
71
|
+
f'{sys.executable} -c "import sys,json;q=json.load(sys.stdin);'
|
|
72
|
+
"print(json.dumps({'answer':'yes' if q['question']=='route_gate' else 'no',"
|
|
73
|
+
"'evidence':['route not covered by authorization (selftest provider)']}))\""
|
|
74
|
+
)
|
|
75
|
+
os.makedirs(os.path.join(repo, ".gitvow"), exist_ok=True)
|
|
76
|
+
with open(os.path.join(repo, ".gitvow", "policy.json"), "w") as fh:
|
|
77
|
+
json.dump({"providers": [{"name": "selftest", "command": prov}]}, fh)
|
|
78
|
+
code, msg = pre_tool_use(
|
|
79
|
+
{
|
|
80
|
+
**base,
|
|
81
|
+
"tool_name": "Edit",
|
|
82
|
+
"tool_input": {"file_path": "api.py", "old_string": "", "new_string": '"/v1/new"'},
|
|
83
|
+
},
|
|
84
|
+
home,
|
|
85
|
+
)
|
|
86
|
+
results.append((code == 2 and "route not covered" in msg, "confirm: provider says new route is unauthorised"))
|
|
87
|
+
os.remove(os.path.join(repo, ".gitvow", "policy.json"))
|
|
69
88
|
with open(os.path.join(repo, "a.txt"), "a") as fh:
|
|
70
89
|
fh.write("b\n")
|
|
71
90
|
post_tool_use({**base, "tool_name": "Edit", "tool_input": {"file_path": "a.txt"}}, home)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -8,7 +8,9 @@ src/gitvow/collect.py
|
|
|
8
8
|
src/gitvow/default_policy.json
|
|
9
9
|
src/gitvow/install.py
|
|
10
10
|
src/gitvow/policy.py
|
|
11
|
+
src/gitvow/providers.py
|
|
11
12
|
src/gitvow/redact.py
|
|
13
|
+
src/gitvow/report.py
|
|
12
14
|
src/gitvow/selftest.py
|
|
13
15
|
src/gitvow/state.py
|
|
14
16
|
src/gitvow/transcript.py
|
|
@@ -24,4 +26,6 @@ tests/test_hooks.py
|
|
|
24
26
|
tests/test_install_cli.py
|
|
25
27
|
tests/test_packaging.py
|
|
26
28
|
tests/test_policy.py
|
|
27
|
-
tests/
|
|
29
|
+
tests/test_providers.py
|
|
30
|
+
tests/test_redact.py
|
|
31
|
+
tests/test_report.py
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from gitvow import cli
|
|
6
|
+
from gitvow.hooks import pre_tool_use
|
|
7
|
+
from gitvow.policy import evaluate
|
|
8
|
+
from gitvow.providers import questions_for, route_literals
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
EXAMPLE = ROOT / "examples" / "providers" / "static_facts.py"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_route_literals_and_questions():
|
|
15
|
+
txt = 'a = "/v1/orders"; b = \'/v1/orders/{id}\'; c = "/static/app.js"; d = "//comment"; e = "/"; f = "/v1/orders"'
|
|
16
|
+
assert route_literals(txt) == ["/v1/orders", "/v1/orders/{id}"]
|
|
17
|
+
qs = questions_for(
|
|
18
|
+
"Edit",
|
|
19
|
+
{"file_path": "api.py", "old_string": '@app.get("/old")', "new_string": '@app.get("/new")\n@app.get("/old2")'},
|
|
20
|
+
)
|
|
21
|
+
assert qs == [
|
|
22
|
+
("gate_bearing", "api.py"),
|
|
23
|
+
("route_gate", "/new"),
|
|
24
|
+
("route_gate", "/old2"),
|
|
25
|
+
("route_callers", "/old"),
|
|
26
|
+
]
|
|
27
|
+
assert questions_for("Write", {"file_path": "x.py", "content": 'r("/w")'}) == [
|
|
28
|
+
("gate_bearing", "x.py"),
|
|
29
|
+
("route_gate", "/w"),
|
|
30
|
+
]
|
|
31
|
+
assert questions_for("Bash", {"command": 'echo "/v1/x"'}) == []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _facts(tmp_path):
|
|
35
|
+
f = tmp_path / "facts.json"
|
|
36
|
+
f.write_text(
|
|
37
|
+
json.dumps(
|
|
38
|
+
{
|
|
39
|
+
"gate_bearing": ["auth/AuthorizeWhitelistedPaths.java", "deploy/values/production-*.yaml"],
|
|
40
|
+
"whitelist_file": "auth/AuthorizeWhitelistedPaths.java",
|
|
41
|
+
"authorized_routes": ["/v1/orders", "/v1/orders/{id}"],
|
|
42
|
+
"callers": {"/v1/orders": ["client-orch (OrdersClient.java:88)"]},
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return f
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _policy(tmp_path, command):
|
|
50
|
+
return {"providers": [{"name": "facts", "command": command}]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_provider_confirms_with_evidence(tmp_path):
|
|
54
|
+
pol = _policy(tmp_path, f"{sys.executable} {EXAMPLE} --facts {_facts(tmp_path)}")
|
|
55
|
+
d = evaluate(
|
|
56
|
+
pol,
|
|
57
|
+
"Edit",
|
|
58
|
+
{"file_path": "src/api/Orders.java", "old_string": "x", "new_string": '@Path("/v1/orders/export")'},
|
|
59
|
+
str(tmp_path),
|
|
60
|
+
)
|
|
61
|
+
assert d.outcome == "confirm" and "not covered by any authorization rule" in d.reason
|
|
62
|
+
assert "AuthorizeWhitelistedPaths.java" in d.reason
|
|
63
|
+
d = evaluate(
|
|
64
|
+
pol,
|
|
65
|
+
"Edit",
|
|
66
|
+
{"file_path": "src/api/Orders.java", "old_string": "x", "new_string": '@Path("/v1/orders/{id}")'},
|
|
67
|
+
str(tmp_path),
|
|
68
|
+
)
|
|
69
|
+
assert d.outcome == "allow" # listed as authorized
|
|
70
|
+
d = evaluate(
|
|
71
|
+
pol,
|
|
72
|
+
"Edit",
|
|
73
|
+
{"file_path": "src/api/Orders.java", "old_string": '@Path("/v1/orders")', "new_string": ""},
|
|
74
|
+
str(tmp_path),
|
|
75
|
+
)
|
|
76
|
+
assert d.outcome == "confirm" and "client-orch" in d.reason
|
|
77
|
+
d = evaluate(
|
|
78
|
+
pol,
|
|
79
|
+
"Write",
|
|
80
|
+
{"file_path": str(tmp_path / "deploy/values/production-in.yaml"), "content": "a: 1"},
|
|
81
|
+
str(tmp_path),
|
|
82
|
+
)
|
|
83
|
+
assert d.outcome == "confirm" and "gate-bearing" in d.reason
|
|
84
|
+
assert (
|
|
85
|
+
evaluate(pol, "Edit", {"file_path": "README.md", "old_string": "a", "new_string": "b"}, str(tmp_path)).outcome
|
|
86
|
+
== "allow"
|
|
87
|
+
)
|
|
88
|
+
assert evaluate(pol, "Bash", {"command": 'echo "/v1/new"'}, str(tmp_path)).outcome == "allow"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_provider_failure_means_confirm(tmp_path):
|
|
92
|
+
for cmd in (
|
|
93
|
+
"/nonexistent/provider",
|
|
94
|
+
f"{sys.executable} -c \"print('not json')\"",
|
|
95
|
+
f"{sys.executable} -c 'import sys; sys.exit(3)'",
|
|
96
|
+
"",
|
|
97
|
+
):
|
|
98
|
+
pol = _policy(tmp_path, cmd)
|
|
99
|
+
d = evaluate(pol, "Edit", {"file_path": "a.py", "old_string": "", "new_string": ""}, str(tmp_path))
|
|
100
|
+
assert d.outcome == "confirm" and "unavailable" in d.reason, cmd
|
|
101
|
+
pol = _policy(tmp_path, f"{sys.executable} -c 'import time; time.sleep(5)'")
|
|
102
|
+
pol["providers"][0]["timeout"] = 0.5
|
|
103
|
+
assert evaluate(pol, "Edit", {"file_path": "a.py"}, str(tmp_path)).outcome == "confirm"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_unknown_answer_does_not_block(tmp_path):
|
|
107
|
+
pol = _policy(tmp_path, f'{sys.executable} -c "print(\'{{\\"answer\\": \\"unknown\\"}}\')"')
|
|
108
|
+
assert evaluate(pol, "Edit", {"file_path": "a.py"}, str(tmp_path)).outcome == "allow"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_provider_questions_filter_and_validation(tmp_path):
|
|
112
|
+
from gitvow.policy import PolicyError, load_policy
|
|
113
|
+
|
|
114
|
+
(tmp_path / ".gitvow").mkdir()
|
|
115
|
+
(tmp_path / ".gitvow" / "policy.json").write_text(
|
|
116
|
+
json.dumps({"providers": [{"name": "x", "command": "y", "questions": ["nope"]}]})
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
load_policy(str(tmp_path), str(tmp_path / "h"))
|
|
120
|
+
raise AssertionError("expected PolicyError")
|
|
121
|
+
except PolicyError as e:
|
|
122
|
+
assert "unknown questions" in str(e)
|
|
123
|
+
(tmp_path / ".gitvow" / "policy.json").write_text(json.dumps({"providers": [{"name": "x"}]}))
|
|
124
|
+
try:
|
|
125
|
+
load_policy(str(tmp_path), str(tmp_path / "h"))
|
|
126
|
+
raise AssertionError("expected PolicyError")
|
|
127
|
+
except PolicyError:
|
|
128
|
+
pass
|
|
129
|
+
pol = _policy(tmp_path, f"{sys.executable} {EXAMPLE} --facts {_facts(tmp_path)}")
|
|
130
|
+
pol["providers"][0]["questions"] = ["gate_bearing"] # route questions not asked of this provider
|
|
131
|
+
d = evaluate(pol, "Edit", {"file_path": "a.py", "old_string": "", "new_string": '"/v1/zzz"'}, str(tmp_path))
|
|
132
|
+
assert d.outcome == "allow"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_hook_and_cli_ask_end_to_end(repo, home, payload, tmp_path, monkeypatch, capsys):
|
|
136
|
+
facts = _facts(tmp_path)
|
|
137
|
+
(repo / ".gitvow").mkdir()
|
|
138
|
+
(repo / ".gitvow" / "policy.json").write_text(
|
|
139
|
+
json.dumps({"providers": [{"name": "facts", "command": f"{sys.executable} {EXAMPLE} --facts {facts}"}]})
|
|
140
|
+
)
|
|
141
|
+
code, msg = pre_tool_use(
|
|
142
|
+
payload(
|
|
143
|
+
"PreToolUse",
|
|
144
|
+
"Edit",
|
|
145
|
+
{"file_path": str(repo / "src/Orders.java"), "old_string": "", "new_string": '@Path("/v1/orders/export")'},
|
|
146
|
+
),
|
|
147
|
+
str(home),
|
|
148
|
+
)
|
|
149
|
+
assert code == 2 and "CONFIRMATION REQUIRED" in msg and "AuthorizeWhitelistedPaths.java" in msg
|
|
150
|
+
log = (repo / ".git" / "gitvow-hooks.log").read_text()
|
|
151
|
+
assert '"kind": "confirm_required"' in log and "provider facts" in log
|
|
152
|
+
monkeypatch.chdir(repo)
|
|
153
|
+
assert cli.main(["ask", "route_gate", "/v1/orders/export", "--path", "src/Orders.java"]) == 2
|
|
154
|
+
out = capsys.readouterr().out
|
|
155
|
+
assert "facts: yes" in out and "decision: CONFIRM" in out
|
|
156
|
+
assert cli.main(["ask", "route_gate", "/v1/orders"]) == 0
|
|
157
|
+
assert "decision: ALLOW" in capsys.readouterr().out
|
|
158
|
+
assert cli.main(["ask", "gate_bearing", "auth/AuthorizeWhitelistedPaths.java"]) == 2
|
|
159
|
+
(repo / ".gitvow" / "policy.json").write_text("{}")
|
|
160
|
+
assert cli.main(["ask", "gate_bearing", "x"]) == 1
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from gitvow import cli
|
|
5
|
+
from gitvow.hooks import post_tool_use, pre_tool_use, session_start
|
|
6
|
+
from gitvow.install import _write_git_hook
|
|
7
|
+
from gitvow.report import build, render_markdown, said_vs_did
|
|
8
|
+
from tests.conftest import git
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_said_vs_did_matches_path_basename_or_stem():
|
|
12
|
+
r = said_vs_did(
|
|
13
|
+
"Fix the cache key in QueryCacheHelper.java and touch calc", ["a/QueryCacheHelper.java", "calc.py", "x/util.go"]
|
|
14
|
+
)
|
|
15
|
+
assert r == {"mentioned": ["a/QueryCacheHelper.java", "calc.py"], "unmentioned": ["x/util.go"]}
|
|
16
|
+
assert said_vs_did("", ["a.py"]) == {"mentioned": [], "unmentioned": ["a.py"]}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _agent_commit(repo, home, payload, transcript, sid, text):
|
|
20
|
+
p = payload("SessionStart", transcript=transcript)
|
|
21
|
+
p["session_id"] = sid
|
|
22
|
+
session_start(p, str(home))
|
|
23
|
+
(repo / "a.txt").write_text(text)
|
|
24
|
+
post_tool_use({**p, "tool_name": "Edit", "tool_input": {"file_path": "a.txt"}}, str(home))
|
|
25
|
+
pre_tool_use({**p, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, str(home))
|
|
26
|
+
git(repo, "commit", "-qam", f"agent {sid}")
|
|
27
|
+
post_tool_use({**p, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, str(home))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_report_pairs_trailers_with_notes_and_flags_missing(repo, home, payload, transcript, monkeypatch, capsys):
|
|
31
|
+
_write_git_hook(str(repo / ".gitvow" / "git-hooks"))
|
|
32
|
+
git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
|
|
33
|
+
base = git(repo, "rev-parse", "HEAD")
|
|
34
|
+
_agent_commit(repo, home, payload, transcript, "s-aaa", "one\n")
|
|
35
|
+
(repo / "h.txt").write_text("human\n")
|
|
36
|
+
git(repo, "add", "h.txt")
|
|
37
|
+
git(repo, "commit", "-qm", "human commit")
|
|
38
|
+
_agent_commit(repo, home, payload, transcript, "s-bbb", "two\n")
|
|
39
|
+
git(repo, "update-ref", "-d", "refs/notes/gitvow/s-bbb") # simulate notes never pushed
|
|
40
|
+
r = build(str(repo), base, "HEAD")
|
|
41
|
+
assert r["agent_commits"] == 2 and r["human_commits"] == 1 and len(r["missing_notes"]) == 1
|
|
42
|
+
kinds = [c["kind"] for c in r["commits"]]
|
|
43
|
+
assert kinds == ["agent", "human", "agent"]
|
|
44
|
+
first = r["commits"][0]
|
|
45
|
+
assert first["note_found"] and first["attribution"]["agent_share"] == 1.0 and first["step"] == 1
|
|
46
|
+
assert "[github-token]" in first["plan"] # redacted plan travels into the report
|
|
47
|
+
assert first["said_vs_did"]["unmentioned"] == ["a.txt"]
|
|
48
|
+
md = render_markdown(r)
|
|
49
|
+
assert "2 agent commits, 1 human commit" in md and "Missing session notes" in md
|
|
50
|
+
assert "made by a person" in md and "not mentioned in plan" in md and "ghp_" not in md
|
|
51
|
+
monkeypatch.chdir(repo)
|
|
52
|
+
assert cli.main(["report", "--base", base]) == 0
|
|
53
|
+
assert cli.main(["report", "--base", base, "--require-notes"]) == 1
|
|
54
|
+
assert "missing session notes" in capsys.readouterr().err
|
|
55
|
+
assert cli.main(["report", "--base", base, "--json"]) == 0
|
|
56
|
+
assert json.loads(capsys.readouterr().out)["agent_commits"] == 2
|
|
57
|
+
assert cli.main(["report", "--base", "nonexistent-rev"]) == 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_pre_push_hook_pushes_notes_with_branch(repo, home, tmp_path):
|
|
61
|
+
remote = tmp_path / "remote.git"
|
|
62
|
+
git(remote.parent, "init", "-q", "--bare", str(remote))
|
|
63
|
+
git(repo, "remote", "add", "origin", str(remote))
|
|
64
|
+
_write_git_hook(str(repo / ".gitvow" / "git-hooks"))
|
|
65
|
+
git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
|
|
66
|
+
assert (repo / ".gitvow" / "git-hooks" / "pre-push").exists()
|
|
67
|
+
git(repo, "notes", "--ref=gitvow/s1", "add", "-m", "gitvow-session\n{}", "HEAD")
|
|
68
|
+
branch = git(repo, "branch", "--show-current")
|
|
69
|
+
git(repo, "push", "-q", "origin", branch)
|
|
70
|
+
assert git(remote, "for-each-ref", "--format=%(refname)", "refs/notes/gitvow/") == "refs/notes/gitvow/s1"
|
|
71
|
+
# push-notes by hand, and idempotent second push
|
|
72
|
+
(repo / "a.txt").write_text("n\n")
|
|
73
|
+
git(repo, "commit", "-qam", "n")
|
|
74
|
+
git(repo, "notes", "--ref=gitvow/s2", "add", "-m", "gitvow-session\n{}", "HEAD")
|
|
75
|
+
import os
|
|
76
|
+
|
|
77
|
+
cwd = os.getcwd()
|
|
78
|
+
os.chdir(repo)
|
|
79
|
+
try:
|
|
80
|
+
assert cli.main(["push-notes"]) == 0
|
|
81
|
+
finally:
|
|
82
|
+
os.chdir(cwd)
|
|
83
|
+
assert "refs/notes/gitvow/s2" in git(remote, "for-each-ref", "--format=%(refname)", "refs/notes/gitvow/")
|
|
84
|
+
assert time.time() > 0
|
|
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
|