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.
Files changed (31) hide show
  1. {gitvow-0.1.2/src/gitvow.egg-info → gitvow-0.2.0}/PKG-INFO +1 -1
  2. {gitvow-0.1.2 → gitvow-0.2.0}/pyproject.toml +1 -1
  3. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/cli.py +25 -3
  4. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/collect.py +11 -1
  5. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/default_policy.json +3 -3
  6. gitvow-0.2.0/src/gitvow/hooks/__init__.py +228 -0
  7. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/install.py +29 -4
  8. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/redact.py +51 -0
  9. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/selftest.py +16 -5
  10. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/state.py +5 -0
  11. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/transcript.py +6 -4
  12. {gitvow-0.1.2 → gitvow-0.2.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  13. {gitvow-0.1.2 → gitvow-0.2.0}/tests/test_collect.py +3 -1
  14. gitvow-0.2.0/tests/test_hooks.py +195 -0
  15. gitvow-0.2.0/tests/test_install_cli.py +141 -0
  16. {gitvow-0.1.2 → gitvow-0.2.0}/tests/test_redact.py +39 -1
  17. gitvow-0.1.2/src/gitvow/hooks/__init__.py +0 -127
  18. gitvow-0.1.2/tests/test_hooks.py +0 -81
  19. gitvow-0.1.2/tests/test_install_cli.py +0 -72
  20. {gitvow-0.1.2 → gitvow-0.2.0}/LICENSE +0 -0
  21. {gitvow-0.1.2 → gitvow-0.2.0}/README.md +0 -0
  22. {gitvow-0.1.2 → gitvow-0.2.0}/setup.cfg +0 -0
  23. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/__init__.py +0 -0
  24. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow/policy.py +0 -0
  25. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow.egg-info/SOURCES.txt +0 -0
  26. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  27. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  28. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow.egg-info/requires.txt +0 -0
  29. {gitvow-0.1.2 → gitvow-0.2.0}/src/gitvow.egg-info/top_level.txt +0 -0
  30. {gitvow-0.1.2 → gitvow-0.2.0}/tests/test_packaging.py +0 -0
  31. {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.1.2
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.1.2"
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
- rc, note, _ = git(["notes", "--ref=sessions", "show", a.commit], os.getcwd())
74
- print(note if rc == 0 else "(no session note)")
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
- ("notes.txt", ["log", "--show-notes=sessions", "--format=%H%n%N%n----", "--grep=Gitvow-Session:"]),
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 the gate policy or the hook configuration"
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
- done += ["hooks merged into ~/.claude/settings.json", "global core.hooksPath → ~/.gitvow/git-hooks"]
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(["update-ref", "-d", "refs/notes/sessions"], repo)
156
- done.append("local refs/notes/sessions deleted (remote copies untouched)")
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
- with open(os.path.join(repo, "a.txt"), "a") as fh:
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=sessions", "list").count("\n") + (1 if g("notes", "--ref=sessions", "list") else 0)
80
- == 1,
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.1.2
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 s["sessions"] == 1 and s["commits_with_trailers"] == 1 and s["notes_attached"] == 1
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())