gitvow 0.1.2__tar.gz → 0.2.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.1.2/src/gitvow.egg-info → gitvow-0.2.0}/PKG-INFO +1 -1
- {gitvow-0.1.2 → gitvow-0.2.0}/pyproject.toml +1 -1
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/cli.py +25 -3
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/collect.py +11 -1
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/default_policy.json +3 -3
- gitvow-0.2.0/src/gitvow/hooks/__init__.py +228 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/install.py +29 -4
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/redact.py +51 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/selftest.py +16 -5
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/state.py +5 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/transcript.py +6 -4
- {gitvow-0.1.2 → gitvow-0.2.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.1.2 → gitvow-0.2.0}/tests/test_collect.py +3 -1
- gitvow-0.2.0/tests/test_hooks.py +195 -0
- gitvow-0.2.0/tests/test_install_cli.py +141 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/tests/test_redact.py +39 -1
- gitvow-0.1.2/src/gitvow/hooks/__init__.py +0 -127
- gitvow-0.1.2/tests/test_hooks.py +0 -81
- gitvow-0.1.2/tests/test_install_cli.py +0 -72
- {gitvow-0.1.2 → gitvow-0.2.0}/LICENSE +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/README.md +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/setup.cfg +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/policy.py +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow.egg-info/SOURCES.txt +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/tests/test_packaging.py +0 -0
- {gitvow-0.1.2 → gitvow-0.2.0}/tests/test_policy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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.2.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"}
|
|
@@ -5,13 +5,15 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
|
+
import re
|
|
8
9
|
import sys
|
|
9
10
|
|
|
10
11
|
from . import __version__
|
|
11
12
|
from .collect import collect, summarize_text
|
|
12
|
-
from .hooks import HANDLERS
|
|
13
|
+
from .hooks import HANDLERS, LEGACY_NOTES_REF, notes_ref
|
|
13
14
|
from .install import install_repo, install_user, uninstall_repo, uninstall_user
|
|
14
15
|
from .policy import PolicyError, evaluate, load_policy
|
|
16
|
+
from .redact import RedactionError, load_rules, redact
|
|
15
17
|
from .state import git
|
|
16
18
|
|
|
17
19
|
|
|
@@ -70,8 +72,25 @@ def cmd_show(a: argparse.Namespace) -> int:
|
|
|
70
72
|
print(f"no such commit: {a.commit}", file=sys.stderr)
|
|
71
73
|
return 1
|
|
72
74
|
print(msg)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
m = re.search(r"^Gitvow-Session:\s*(\S+)", msg, re.M)
|
|
76
|
+
refs = ([notes_ref(m.group(1))] if m else []) + [LEGACY_NOTES_REF]
|
|
77
|
+
for ref in refs:
|
|
78
|
+
rc, note, _ = git(["notes", f"--ref={ref}", "show", a.commit], os.getcwd())
|
|
79
|
+
if rc == 0:
|
|
80
|
+
print(note)
|
|
81
|
+
return 0
|
|
82
|
+
print("(no session note)")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_redact(a: argparse.Namespace) -> int:
|
|
87
|
+
"""Apply the built-in layers plus the rules files to text."""
|
|
88
|
+
try:
|
|
89
|
+
rules = load_rules(os.getcwd(), os.path.expanduser("~"))
|
|
90
|
+
except RedactionError as e:
|
|
91
|
+
print(f"redaction rules error: {e}", file=sys.stderr)
|
|
92
|
+
return 2
|
|
93
|
+
print(redact(" ".join(a.text), rules))
|
|
75
94
|
return 0
|
|
76
95
|
|
|
77
96
|
|
|
@@ -119,6 +138,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
119
138
|
s = sub.add_parser("show", help="print a commit's trailers and session note")
|
|
120
139
|
s.add_argument("commit", nargs="?", default="HEAD")
|
|
121
140
|
s.set_defaults(f=cmd_show)
|
|
141
|
+
s = sub.add_parser("redact", help="apply built-in redaction plus your rules files to text")
|
|
142
|
+
s.add_argument("text", nargs="+")
|
|
143
|
+
s.set_defaults(f=cmd_redact)
|
|
122
144
|
s = sub.add_parser("collect", help="gather ledger, logs, trailers and notes into one directory")
|
|
123
145
|
s.add_argument("--out", default=os.path.expanduser("~/Desktop"))
|
|
124
146
|
s.set_defaults(f=cmd_collect)
|
|
@@ -43,7 +43,17 @@ def collect(home: str, out_dir: str) -> str:
|
|
|
43
43
|
shutil.copy(lp, os.path.join(d, "gitvow-hooks.log"))
|
|
44
44
|
for name, args in (
|
|
45
45
|
("commits-with-trailers.txt", ["log", "--format=%H %ad %s", "--date=short", "--grep=Gitvow-Session:"]),
|
|
46
|
-
(
|
|
46
|
+
(
|
|
47
|
+
"notes.txt",
|
|
48
|
+
[
|
|
49
|
+
"-c",
|
|
50
|
+
"notes.displayRef=refs/notes/gitvow/*",
|
|
51
|
+
"log",
|
|
52
|
+
"--show-notes=sessions",
|
|
53
|
+
"--format=%H%n%N%n----",
|
|
54
|
+
"--grep=Gitvow-Session:",
|
|
55
|
+
],
|
|
56
|
+
),
|
|
47
57
|
("remote.txt", ["remote", "get-url", "origin"]),
|
|
48
58
|
):
|
|
49
59
|
_, out, _ = git(args, r)
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
"reason": "edits CI definitions"
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
|
-
"pattern": "(^|/)\\.gitvow/(policy\\.json|hooks/)|(^|/)\\.claude/settings.*\\.json",
|
|
66
|
-
"reason": "edits
|
|
65
|
+
"pattern": "(^|/)\\.gitvow/(policy\\.json|redact-rules\\.json|hooks/|git-hooks/)|(^|/)\\.claude/settings.*\\.json",
|
|
66
|
+
"reason": "edits gitvow's own policy, redaction rules or hooks"
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
"pattern": "(^|/)(auth|authz|authorization|gate|whitelist|allowlist)[^/]*\\.(java|go|py|ts|js|ya?ml)$",
|
|
@@ -79,4 +79,4 @@
|
|
|
79
79
|
"command": "",
|
|
80
80
|
"_doc": "Optional: a command that reads the hook JSON on stdin and prints ALLOW|CONFIRM|DENY <reason>. Used only for calls not matched above."
|
|
81
81
|
}
|
|
82
|
-
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Hook handlers. Each takes the Claude Code hook payload (dict) and returns (exit_code, stderr_message)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..policy import PolicyError, evaluate, load_policy, message_for
|
|
12
|
+
from ..redact import RedactionError, load_rules, redact
|
|
13
|
+
from ..state import git, load_state, log_event, save_state, toplevel
|
|
14
|
+
from ..transcript import summarize
|
|
15
|
+
|
|
16
|
+
NOTES_REF_PREFIX = "gitvow" # refs/notes/gitvow/<session-id>; gitvow 0.1 wrote the single ref refs/notes/sessions
|
|
17
|
+
LEGACY_NOTES_REF = "sessions"
|
|
18
|
+
NOTE_SCHEMA = 2
|
|
19
|
+
COMMIT_RE = re.compile(r"\bgit\s+commit\b")
|
|
20
|
+
EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
21
|
+
REDACTION_UNAVAILABLE = "redaction rules invalid; nothing written for this event"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def notes_ref(session_id: str | None) -> str:
|
|
25
|
+
sid = re.sub(r"[^A-Za-z0-9._-]", "_", session_id or "unknown")
|
|
26
|
+
return f"{NOTES_REF_PREFIX}/{sid}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def session_start(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
30
|
+
cwd = h.get("cwd") or os.getcwd()
|
|
31
|
+
st = load_state(cwd)
|
|
32
|
+
st.update(
|
|
33
|
+
{
|
|
34
|
+
"session_id": h.get("session_id"),
|
|
35
|
+
"transcript_path": h.get("transcript_path"),
|
|
36
|
+
"started": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
37
|
+
"steps": st.get("steps", 0),
|
|
38
|
+
"agent_blobs": st.get("agent_blobs", {}) if st.get("session_id") == h.get("session_id") else {},
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
save_state(cwd, st)
|
|
42
|
+
log_event(cwd, "session_start", {"session_id": h.get("session_id")})
|
|
43
|
+
return 0, ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rules_or_none(cwd: str, home: str | None) -> tuple[list[tuple[str, str]] | None, str]:
|
|
47
|
+
try:
|
|
48
|
+
return load_rules(cwd, home), ""
|
|
49
|
+
except RedactionError as e:
|
|
50
|
+
log_event(cwd, "redaction_unavailable", {"error": str(e)[:200]})
|
|
51
|
+
return None, f"gitvow: {e}. {REDACTION_UNAVAILABLE}."
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
55
|
+
cwd = h.get("cwd") or os.getcwd()
|
|
56
|
+
tool = h.get("tool_name", "") or ""
|
|
57
|
+
inp = h.get("tool_input") or {}
|
|
58
|
+
try:
|
|
59
|
+
pol = load_policy(cwd, home)
|
|
60
|
+
except PolicyError as e:
|
|
61
|
+
return (
|
|
62
|
+
2,
|
|
63
|
+
f"BLOCKED: tool policy could not be loaded ({e}); refusing all tool calls until the policy is restored.",
|
|
64
|
+
)
|
|
65
|
+
d = evaluate(pol, tool, inp)
|
|
66
|
+
rules, warn = _rules_or_none(cwd, home)
|
|
67
|
+
if d.blocks:
|
|
68
|
+
payload: dict[str, Any] = {"tool": tool, "reason": d.reason, "session_id": h.get("session_id")}
|
|
69
|
+
if rules is not None:
|
|
70
|
+
payload["detail"] = redact(d.detail, rules)[:200]
|
|
71
|
+
log_event(cwd, "blocked" if d.outcome == "deny" else "confirm_required", payload)
|
|
72
|
+
return 2, message_for(d)
|
|
73
|
+
if tool == "Bash" and COMMIT_RE.search(inp.get("command", "")):
|
|
74
|
+
st = load_state(cwd)
|
|
75
|
+
st["session_id"] = h.get("session_id") or st.get("session_id")
|
|
76
|
+
st["transcript_path"] = h.get("transcript_path") or st.get("transcript_path")
|
|
77
|
+
st["steps"] = st.get("steps", 0) + 1
|
|
78
|
+
save_state(cwd, st)
|
|
79
|
+
if rules is None:
|
|
80
|
+
log_event(cwd, "allowed", {"tool": tool})
|
|
81
|
+
return 0, warn
|
|
82
|
+
log_event(
|
|
83
|
+
cwd,
|
|
84
|
+
"allowed",
|
|
85
|
+
{"tool": tool, "detail": redact(inp.get("command") or inp.get("file_path") or tool, rules)[:160]},
|
|
86
|
+
)
|
|
87
|
+
return 0, ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _record_agent_blob(cwd: str, file_path: str) -> None:
|
|
91
|
+
"""After an agent edit, remember the blob id of what the agent wrote (repo-relative path -> blob)."""
|
|
92
|
+
top = toplevel(cwd)
|
|
93
|
+
if not top:
|
|
94
|
+
return
|
|
95
|
+
abs_path = file_path if os.path.isabs(file_path) else os.path.join(cwd, file_path)
|
|
96
|
+
if not os.path.isfile(abs_path):
|
|
97
|
+
return
|
|
98
|
+
rel = os.path.relpath(os.path.realpath(abs_path), os.path.realpath(top))
|
|
99
|
+
if rel.startswith(".."):
|
|
100
|
+
return
|
|
101
|
+
rc, blob, _ = git(["hash-object", "-w", "--", abs_path], top)
|
|
102
|
+
if rc != 0:
|
|
103
|
+
return
|
|
104
|
+
st = load_state(cwd)
|
|
105
|
+
st.setdefault("agent_blobs", {})[rel] = blob
|
|
106
|
+
save_state(cwd, st)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _attribution(cwd: str, head: str, agent_blobs: dict[str, str], transcript_written: list[str]) -> dict[str, Any]:
|
|
110
|
+
_, numstat, _ = git(["show", "--numstat", "--format=", head], cwd)
|
|
111
|
+
files: list[dict[str, Any]] = []
|
|
112
|
+
total_added = human_changed = agent_added = 0
|
|
113
|
+
for ln in numstat.splitlines():
|
|
114
|
+
parts = ln.split("\t")
|
|
115
|
+
if len(parts) < 3:
|
|
116
|
+
continue
|
|
117
|
+
added = int(parts[0]) if parts[0].isdigit() else 0
|
|
118
|
+
path = parts[2]
|
|
119
|
+
total_added += added
|
|
120
|
+
agent_blob = agent_blobs.get(path)
|
|
121
|
+
touched = agent_blob is not None or any(path.endswith(w) or w.endswith(path) for w in transcript_written)
|
|
122
|
+
entry: dict[str, Any] = {"path": path, "agent_wrote": touched, "lines_added_in_commit": added}
|
|
123
|
+
if agent_blob:
|
|
124
|
+
rc, committed_out, _ = git(["rev-parse", f"{head}:{path}"], cwd)
|
|
125
|
+
committed: str | None = committed_out if rc == 0 else None
|
|
126
|
+
h_add = h_del = 0
|
|
127
|
+
if committed and committed != agent_blob:
|
|
128
|
+
rc, d, _ = git(["diff", "--numstat", agent_blob, committed], cwd)
|
|
129
|
+
if rc == 0 and d:
|
|
130
|
+
a, b = d.split("\t")[:2]
|
|
131
|
+
h_add, h_del = (int(a) if a.isdigit() else 0), (int(b) if b.isdigit() else 0)
|
|
132
|
+
human_changed += h_add + h_del
|
|
133
|
+
agent_added += max(added - h_add, 0)
|
|
134
|
+
entry.update(
|
|
135
|
+
{
|
|
136
|
+
"human_lines_added": h_add,
|
|
137
|
+
"human_lines_removed": h_del,
|
|
138
|
+
"agent_blob": agent_blob,
|
|
139
|
+
"committed_blob": committed,
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
files.append(entry)
|
|
143
|
+
have_blobs = any("agent_blob" in f for f in files)
|
|
144
|
+
return {
|
|
145
|
+
"files_in_commit": len(files),
|
|
146
|
+
"touched_by_agent": sum(1 for f in files if f["agent_wrote"]),
|
|
147
|
+
"lines_added_in_commit": total_added,
|
|
148
|
+
"lines_changed_by_human_after_agent": human_changed,
|
|
149
|
+
# None when nothing can be attributed: no lines added, or no agent-written blob was recorded
|
|
150
|
+
"agent_share": round(agent_added / total_added, 2) if total_added and have_blobs else None,
|
|
151
|
+
"files": files[:50],
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
156
|
+
cwd = h.get("cwd") or os.getcwd()
|
|
157
|
+
tool = h.get("tool_name")
|
|
158
|
+
inp = h.get("tool_input") or {}
|
|
159
|
+
if tool in EDIT_TOOLS:
|
|
160
|
+
if inp.get("file_path"):
|
|
161
|
+
_record_agent_blob(cwd, str(inp["file_path"]))
|
|
162
|
+
return 0, ""
|
|
163
|
+
if tool != "Bash" or not COMMIT_RE.search(inp.get("command", "")):
|
|
164
|
+
return 0, ""
|
|
165
|
+
rc, head, _ = git(["rev-parse", "HEAD"], cwd)
|
|
166
|
+
if rc != 0:
|
|
167
|
+
return 0, ""
|
|
168
|
+
rules, warn = _rules_or_none(cwd, home)
|
|
169
|
+
if rules is None:
|
|
170
|
+
return 0, warn
|
|
171
|
+
st = load_state(cwd)
|
|
172
|
+
summ = summarize(h.get("transcript_path") or st.get("transcript_path"), rules=rules)
|
|
173
|
+
_, files, _ = git(["show", "--stat", "--format=", head], cwd)
|
|
174
|
+
attribution = _attribution(cwd, head, st.get("agent_blobs", {}), summ["files_written"])
|
|
175
|
+
session_id = h.get("session_id") or st.get("session_id")
|
|
176
|
+
note = {
|
|
177
|
+
"schema": NOTE_SCHEMA,
|
|
178
|
+
"session_id": session_id,
|
|
179
|
+
"step": st.get("steps"),
|
|
180
|
+
"committed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
181
|
+
"assistant_turns_so_far": summ["turns"],
|
|
182
|
+
"tool_calls_so_far": len(summ["tool_calls"]),
|
|
183
|
+
"tools_used": sorted({t["tool"] for t in summ["tool_calls"] if t.get("tool")}),
|
|
184
|
+
"last_stated_plan": summ["last_assistant_text"],
|
|
185
|
+
"files_in_commit": [ln.strip() for ln in files.splitlines()[:-1]][:50] if files else [],
|
|
186
|
+
"files_written_by_agent_this_session": [f["path"] for f in attribution["files"] if f["agent_wrote"]][:50],
|
|
187
|
+
"attribution": attribution,
|
|
188
|
+
"transcript": "kept local; see ledger",
|
|
189
|
+
"redaction": "secrets/PII patterns, high-entropy tokens and custom rules replaced at write time",
|
|
190
|
+
}
|
|
191
|
+
ref = notes_ref(session_id)
|
|
192
|
+
body = "gitvow-session\n" + json.dumps(note, indent=1)
|
|
193
|
+
git(["notes", f"--ref={ref}", "add", "-f", "-m", body, head], cwd)
|
|
194
|
+
log_event(cwd, "note_added", {"commit": head[:12], "session_id": session_id, "step": note["step"]})
|
|
195
|
+
return 0, f"session note attached to {head[:12]} (refs/notes/{ref})"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def stop(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
199
|
+
cwd = h.get("cwd") or os.getcwd()
|
|
200
|
+
home = home or os.path.expanduser("~")
|
|
201
|
+
rules, warn = _rules_or_none(cwd, home)
|
|
202
|
+
if rules is None:
|
|
203
|
+
return 0, warn
|
|
204
|
+
st = load_state(cwd)
|
|
205
|
+
summ = summarize(h.get("transcript_path") or st.get("transcript_path"), rules=rules)
|
|
206
|
+
led = os.path.join(home, ".gitvow", "ledger")
|
|
207
|
+
os.makedirs(led, exist_ok=True)
|
|
208
|
+
commits: list[str] = []
|
|
209
|
+
if st.get("started"):
|
|
210
|
+
rc, out, _ = git(["log", "--format=%H", f"--since={st['started']}"], cwd)
|
|
211
|
+
commits = out.split() if rc == 0 else []
|
|
212
|
+
rec = {
|
|
213
|
+
"session_id": h.get("session_id"),
|
|
214
|
+
"repo": cwd,
|
|
215
|
+
"started": st.get("started"),
|
|
216
|
+
"ended": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
217
|
+
"assistant_turns": summ["turns"],
|
|
218
|
+
"tool_calls": summ["tool_calls"],
|
|
219
|
+
"commits_during_session": commits,
|
|
220
|
+
"last_stated_plan": summ["last_assistant_text"],
|
|
221
|
+
}
|
|
222
|
+
with open(os.path.join(led, f"{h.get('session_id') or 'unknown'}.json"), "w") as fh:
|
|
223
|
+
json.dump(rec, fh, indent=1)
|
|
224
|
+
log_event(cwd, "session_stop", {"session_id": h.get("session_id"), "tool_calls": len(summ["tool_calls"])})
|
|
225
|
+
return 0, ""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
HANDLERS = {"SessionStart": session_start, "PreToolUse": pre_tool_use, "PostToolUse": post_tool_use, "Stop": stop}
|
|
@@ -22,6 +22,20 @@ SELF="$(cd "$(dirname "$0")" && pwd)"; REPOHOOKS="$(cd "$GD/hooks" 2>/dev/null &
|
|
|
22
22
|
exit 0
|
|
23
23
|
"""
|
|
24
24
|
MARKER = "gitvow hook "
|
|
25
|
+
NOTES_GLOB = "refs/notes/gitvow/*"
|
|
26
|
+
NOTES_KEYS = ("notes.displayRef", "notes.rewriteRef")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _set_notes_config(cwd: str, scope: list[str]) -> None:
|
|
30
|
+
for key in NOTES_KEYS:
|
|
31
|
+
rc, cur, _ = git(["config", *scope, "--get-all", key], cwd)
|
|
32
|
+
if rc != 0 or NOTES_GLOB not in cur.splitlines():
|
|
33
|
+
git(["config", *scope, "--add", key, NOTES_GLOB], cwd)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _unset_notes_config(cwd: str, scope: list[str]) -> None:
|
|
37
|
+
for key in NOTES_KEYS:
|
|
38
|
+
git(["config", *scope, "--unset", key, "^" + NOTES_GLOB.replace("*", "\\*").replace(".", "\\.") + "$"], cwd)
|
|
25
39
|
|
|
26
40
|
|
|
27
41
|
def _hook_entries(cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
|
|
@@ -34,7 +48,7 @@ def _hook_entries(cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
|
|
|
34
48
|
return {
|
|
35
49
|
"SessionStart": [entry("SessionStart", None)],
|
|
36
50
|
"PreToolUse": [entry("PreToolUse", "Bash|Edit|Write|MultiEdit|NotebookEdit|mcp__.*")],
|
|
37
|
-
"PostToolUse": [entry("PostToolUse", "Bash")],
|
|
51
|
+
"PostToolUse": [entry("PostToolUse", "Bash|Edit|Write|MultiEdit|NotebookEdit")],
|
|
38
52
|
"Stop": [entry("Stop", None)],
|
|
39
53
|
}
|
|
40
54
|
|
|
@@ -94,7 +108,12 @@ def install_user(home: str, cmd_prefix: str = "gitvow") -> list[str]:
|
|
|
94
108
|
_write_git_hook(os.path.join(base, "git-hooks"))
|
|
95
109
|
merge_settings(os.path.join(home, ".claude", "settings.json"), cmd_prefix)
|
|
96
110
|
git(["config", "--global", "core.hooksPath", os.path.join(base, "git-hooks")], home)
|
|
97
|
-
|
|
111
|
+
_set_notes_config(home, ["--global"])
|
|
112
|
+
done += [
|
|
113
|
+
"hooks merged into ~/.claude/settings.json",
|
|
114
|
+
"global core.hooksPath → ~/.gitvow/git-hooks",
|
|
115
|
+
f"global notes.displayRef / notes.rewriteRef → {NOTES_GLOB}",
|
|
116
|
+
]
|
|
98
117
|
return done
|
|
99
118
|
|
|
100
119
|
|
|
@@ -107,6 +126,7 @@ def uninstall_user(home: str, purge_policy: bool = False, purge_ledger: bool = F
|
|
|
107
126
|
git(["config", "--global", "--unset", "core.hooksPath"], home)
|
|
108
127
|
done.append("global core.hooksPath unset")
|
|
109
128
|
shutil.rmtree(os.path.join(base, "git-hooks"), ignore_errors=True)
|
|
129
|
+
_unset_notes_config(home, ["--global"])
|
|
110
130
|
if purge_policy and os.path.exists(os.path.join(base, "policy.json")):
|
|
111
131
|
os.remove(os.path.join(base, "policy.json"))
|
|
112
132
|
done.append("policy removed")
|
|
@@ -128,10 +148,12 @@ def install_repo(repo: str, cmd_prefix: str = "gitvow") -> list[str]:
|
|
|
128
148
|
_write_git_hook(os.path.join(base, "git-hooks"))
|
|
129
149
|
merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix)
|
|
130
150
|
git(["config", "core.hooksPath", ".gitvow/git-hooks"], repo)
|
|
151
|
+
_set_notes_config(repo, [])
|
|
131
152
|
return [
|
|
132
153
|
f"policy → {pol}",
|
|
133
154
|
"hooks merged into .claude/settings.json",
|
|
134
155
|
"core.hooksPath → .gitvow/git-hooks",
|
|
156
|
+
f"notes.displayRef / notes.rewriteRef → {NOTES_GLOB}",
|
|
135
157
|
"commit .gitvow/ and .claude/settings.json to share; teammates run: git config core.hooksPath .gitvow/git-hooks",
|
|
136
158
|
]
|
|
137
159
|
|
|
@@ -144,6 +166,7 @@ def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
|
|
|
144
166
|
git(["config", "--unset", "core.hooksPath"], repo)
|
|
145
167
|
done.append("core.hooksPath unset")
|
|
146
168
|
shutil.rmtree(os.path.join(repo, ".gitvow"), ignore_errors=True)
|
|
169
|
+
_unset_notes_config(repo, [])
|
|
147
170
|
rc, gd, _ = git(["rev-parse", "--git-dir"], repo)
|
|
148
171
|
if rc == 0:
|
|
149
172
|
gd = gd if os.path.isabs(gd) else os.path.join(repo, gd)
|
|
@@ -152,7 +175,9 @@ def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
|
|
|
152
175
|
if os.path.exists(p):
|
|
153
176
|
os.remove(p)
|
|
154
177
|
if purge_notes:
|
|
155
|
-
git(["
|
|
156
|
-
|
|
178
|
+
_, refs, _ = git(["for-each-ref", "--format=%(refname)", "refs/notes/sessions", "refs/notes/gitvow/"], repo)
|
|
179
|
+
for ref in refs.split():
|
|
180
|
+
git(["update-ref", "-d", ref], repo)
|
|
181
|
+
done.append(f"{len(refs.split())} local session note ref(s) deleted (remote copies untouched)")
|
|
157
182
|
done.append(".gitvow removed; commit trailers already in history remain")
|
|
158
183
|
return done
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
7
|
import math
|
|
8
|
+
import os
|
|
8
9
|
import re
|
|
9
10
|
from collections import Counter
|
|
10
11
|
from collections.abc import Callable, Iterable
|
|
@@ -69,3 +70,53 @@ def redact(value: object, custom: Iterable[tuple[str, str]] = ()) -> str:
|
|
|
69
70
|
for rx, rep in PATTERNS:
|
|
70
71
|
s = rx.sub(rep, s)
|
|
71
72
|
return redact_high_entropy(s)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RedactionError(Exception):
|
|
76
|
+
"""A rules file is unreadable or a pattern does not compile. Callers must write nothing."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
RULES_FILENAME = "redact-rules.json"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_rules(path: str) -> list[tuple[str, str]]:
|
|
83
|
+
try:
|
|
84
|
+
with open(path) as fh:
|
|
85
|
+
data = json.load(fh)
|
|
86
|
+
except OSError as e:
|
|
87
|
+
raise RedactionError(f"{path}: {e.strerror or e}") from e
|
|
88
|
+
except json.JSONDecodeError as e:
|
|
89
|
+
raise RedactionError(f"{path}: invalid JSON ({e.msg} at line {e.lineno})") from e
|
|
90
|
+
if isinstance(data, dict):
|
|
91
|
+
data = data.get("rules", [])
|
|
92
|
+
if not isinstance(data, list):
|
|
93
|
+
raise RedactionError(f"{path}: expected a list of rules")
|
|
94
|
+
out: list[tuple[str, str]] = []
|
|
95
|
+
for i, r in enumerate(data):
|
|
96
|
+
if (
|
|
97
|
+
not isinstance(r, dict)
|
|
98
|
+
or not isinstance(r.get("pattern"), str)
|
|
99
|
+
or not isinstance(r.get("replacement"), str)
|
|
100
|
+
):
|
|
101
|
+
raise RedactionError(f"{path}: rule {i} needs string 'pattern' and 'replacement'")
|
|
102
|
+
try:
|
|
103
|
+
re.compile(r["pattern"])
|
|
104
|
+
except re.error as e:
|
|
105
|
+
raise RedactionError(f"{path}: rule {i} pattern does not compile ({e})") from e
|
|
106
|
+
out.append((r["pattern"], r["replacement"]))
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_rules(cwd: str | None = None, home: str | None = None) -> list[tuple[str, str]]:
|
|
111
|
+
"""Custom rules from <repo>/.gitvow/redact-rules.json then ~/.gitvow/redact-rules.json. Both apply.
|
|
112
|
+
|
|
113
|
+
Raises RedactionError on any unreadable or invalid file: callers must then write nothing.
|
|
114
|
+
"""
|
|
115
|
+
rules: list[tuple[str, str]] = []
|
|
116
|
+
for base in (cwd, home or os.path.expanduser("~")):
|
|
117
|
+
if not base:
|
|
118
|
+
continue
|
|
119
|
+
path = os.path.join(base, ".gitvow", RULES_FILENAME)
|
|
120
|
+
if os.path.exists(path):
|
|
121
|
+
rules += _read_rules(path)
|
|
122
|
+
return rules
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import os
|
|
6
7
|
import shutil
|
|
7
8
|
import subprocess
|
|
@@ -65,20 +66,30 @@ def run() -> int:
|
|
|
65
66
|
"deny: destructive MCP tool",
|
|
66
67
|
)
|
|
67
68
|
)
|
|
69
|
+
with open(os.path.join(repo, "a.txt"), "a") as fh:
|
|
70
|
+
fh.write("b\n")
|
|
71
|
+
post_tool_use({**base, "tool_name": "Edit", "tool_input": {"file_path": "a.txt"}}, home)
|
|
72
|
+
with open(os.path.join(repo, "a.txt"), "a") as fh:
|
|
73
|
+
fh.write("human\n")
|
|
68
74
|
pre_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, home)
|
|
69
75
|
hooks_dir = os.path.join(repo, ".gitvow", "git-hooks")
|
|
70
76
|
_write_git_hook(hooks_dir)
|
|
71
77
|
g("config", "core.hooksPath", ".gitvow/git-hooks")
|
|
72
|
-
|
|
73
|
-
fh.write("b\n")
|
|
78
|
+
g("config", "notes.rewriteRef", "refs/notes/gitvow/*")
|
|
74
79
|
g("commit", "-qam", "selftest commit")
|
|
75
80
|
results.append(("Gitvow-Session:" in g("log", "-1", "--format=%B"), "commit trailer added"))
|
|
76
81
|
post_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, home)
|
|
82
|
+
note = g("notes", "--ref=gitvow/selftest-session", "show", "HEAD")
|
|
83
|
+
results.append((note.startswith("gitvow-session"), "session note attached on the session's own ref"))
|
|
84
|
+
att = json.loads(note.split("\n", 1)[1])["attribution"] if note else {}
|
|
85
|
+
results.append(
|
|
86
|
+
(att.get("lines_changed_by_human_after_agent") == 1 and att.get("agent_share") == 0.5, "line attribution")
|
|
87
|
+
)
|
|
88
|
+
g("commit", "-q", "--amend", "-m", "amended")
|
|
77
89
|
results.append(
|
|
78
90
|
(
|
|
79
|
-
g("notes", "--ref=
|
|
80
|
-
|
|
81
|
-
"session note attached",
|
|
91
|
+
g("notes", "--ref=gitvow/selftest-session", "show", "HEAD").startswith("gitvow-session"),
|
|
92
|
+
"note follows amend",
|
|
82
93
|
)
|
|
83
94
|
)
|
|
84
95
|
stop(base, home)
|
|
@@ -50,3 +50,8 @@ def log_event(cwd: str, kind: str, payload: dict[str, Any]) -> None:
|
|
|
50
50
|
return
|
|
51
51
|
with open(os.path.join(gd, "gitvow-hooks.log"), "a") as fh:
|
|
52
52
|
fh.write(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "kind": kind, **payload}) + "\n")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def toplevel(cwd: str) -> str | None:
|
|
56
|
+
rc, out, _ = git(["rev-parse", "--show-toplevel"], cwd)
|
|
57
|
+
return out if rc == 0 else None
|
|
@@ -4,16 +4,18 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
from collections.abc import Iterable
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
9
10
|
from .redact import redact
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
def summarize(path: str | None, max_tools: int = 500) -> dict[str, Any]:
|
|
13
|
+
def summarize(path: str | None, max_tools: int = 500, rules: Iterable[tuple[str, str]] = ()) -> dict[str, Any]:
|
|
13
14
|
out: dict[str, Any] = {"turns": 0, "tool_calls": [], "last_assistant_text": "", "files_written": []}
|
|
14
15
|
if not path or not os.path.exists(path):
|
|
15
16
|
return out
|
|
16
17
|
written: set[str] = set()
|
|
18
|
+
rules = list(rules)
|
|
17
19
|
with open(path, errors="ignore") as fh:
|
|
18
20
|
for line in fh:
|
|
19
21
|
try:
|
|
@@ -28,7 +30,7 @@ def summarize(path: str | None, max_tools: int = 500) -> dict[str, Any]:
|
|
|
28
30
|
out["turns"] += 1
|
|
29
31
|
if isinstance(content, str):
|
|
30
32
|
if content.strip():
|
|
31
|
-
out["last_assistant_text"] = redact(content)[:600]
|
|
33
|
+
out["last_assistant_text"] = redact(content, rules)[:600]
|
|
32
34
|
continue
|
|
33
35
|
if not isinstance(content, list):
|
|
34
36
|
continue
|
|
@@ -38,10 +40,10 @@ def summarize(path: str | None, max_tools: int = 500) -> dict[str, Any]:
|
|
|
38
40
|
if c.get("type") == "tool_use" and len(out["tool_calls"]) < max_tools:
|
|
39
41
|
inp = c.get("input") or {}
|
|
40
42
|
brief = inp.get("command") or inp.get("file_path") or inp.get("query") or inp.get("pattern") or ""
|
|
41
|
-
out["tool_calls"].append({"tool": c.get("name"), "arg": redact(str(brief))[:160]})
|
|
43
|
+
out["tool_calls"].append({"tool": c.get("name"), "arg": redact(str(brief), rules)[:160]})
|
|
42
44
|
if c.get("name") in ("Edit", "Write", "MultiEdit", "NotebookEdit") and inp.get("file_path"):
|
|
43
45
|
written.add(str(inp["file_path"]))
|
|
44
46
|
elif c.get("type") == "text" and str(c.get("text", "")).strip():
|
|
45
|
-
out["last_assistant_text"] = redact(c["text"])[:600]
|
|
47
|
+
out["last_assistant_text"] = redact(c["text"], rules)[:600]
|
|
46
48
|
out["files_written"] = sorted(written)
|
|
47
49
|
return out
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -19,7 +19,9 @@ def test_collect_and_summarize(repo, home, payload, transcript, tmp_path):
|
|
|
19
19
|
stop(payload("Stop", transcript=transcript), str(home))
|
|
20
20
|
w = collect(str(home), str(tmp_path / "out"))
|
|
21
21
|
s = summarize(w)
|
|
22
|
-
assert
|
|
22
|
+
assert (
|
|
23
|
+
s["sessions"] == 1 and s["commits_with_trailers"] == 1 and s["notes_attached"] == 1
|
|
24
|
+
) # read from refs/notes/gitvow/*
|
|
23
25
|
assert s["hook_decisions"]["blocked"] == 1 and s["gate_fired_on"][0]["reason"] == "force push"
|
|
24
26
|
assert "ghp_" not in Path(f"{w}/SUMMARY.txt").read_text()
|
|
25
27
|
led = json.loads(next((tmp_path / "out").rglob("sess-1.json")).read_text())
|