gitvow 0.1.2__tar.gz → 0.2.1__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 (32) hide show
  1. {gitvow-0.1.2/src/gitvow.egg-info → gitvow-0.2.1}/PKG-INFO +1 -1
  2. {gitvow-0.1.2 → gitvow-0.2.1}/pyproject.toml +1 -1
  3. gitvow-0.2.1/src/gitvow/__main__.py +7 -0
  4. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/cli.py +25 -3
  5. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/collect.py +11 -1
  6. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/default_policy.json +3 -3
  7. gitvow-0.2.1/src/gitvow/hooks/__init__.py +228 -0
  8. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/install.py +48 -6
  9. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/redact.py +51 -0
  10. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/selftest.py +16 -5
  11. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/state.py +5 -0
  12. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/transcript.py +6 -4
  13. {gitvow-0.1.2 → gitvow-0.2.1/src/gitvow.egg-info}/PKG-INFO +1 -1
  14. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow.egg-info/SOURCES.txt +1 -0
  15. {gitvow-0.1.2 → gitvow-0.2.1}/tests/test_collect.py +3 -1
  16. gitvow-0.2.1/tests/test_hooks.py +195 -0
  17. gitvow-0.2.1/tests/test_install_cli.py +167 -0
  18. {gitvow-0.1.2 → gitvow-0.2.1}/tests/test_redact.py +39 -1
  19. gitvow-0.1.2/src/gitvow/hooks/__init__.py +0 -127
  20. gitvow-0.1.2/tests/test_hooks.py +0 -81
  21. gitvow-0.1.2/tests/test_install_cli.py +0 -72
  22. {gitvow-0.1.2 → gitvow-0.2.1}/LICENSE +0 -0
  23. {gitvow-0.1.2 → gitvow-0.2.1}/README.md +0 -0
  24. {gitvow-0.1.2 → gitvow-0.2.1}/setup.cfg +0 -0
  25. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/__init__.py +0 -0
  26. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/policy.py +0 -0
  27. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow.egg-info/dependency_links.txt +0 -0
  28. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow.egg-info/entry_points.txt +0 -0
  29. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow.egg-info/requires.txt +0 -0
  30. {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow.egg-info/top_level.txt +0 -0
  31. {gitvow-0.1.2 → gitvow-0.2.1}/tests/test_packaging.py +0 -0
  32. {gitvow-0.1.2 → gitvow-0.2.1}/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.1
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.1"
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"}
@@ -0,0 +1,7 @@
1
+ """Allow `python -m gitvow`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -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}
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import shutil
8
+ import sys
8
9
  from typing import Any
9
10
 
10
11
  from .state import git
@@ -22,6 +23,35 @@ SELF="$(cd "$(dirname "$0")" && pwd)"; REPOHOOKS="$(cd "$GD/hooks" 2>/dev/null &
22
23
  exit 0
23
24
  """
24
25
  MARKER = "gitvow hook "
26
+ NOTES_GLOB = "refs/notes/gitvow/*"
27
+ NOTES_KEYS = ("notes.displayRef", "notes.rewriteRef")
28
+
29
+
30
+ def _set_notes_config(cwd: str, scope: list[str]) -> None:
31
+ for key in NOTES_KEYS:
32
+ rc, cur, _ = git(["config", *scope, "--get-all", key], cwd)
33
+ if rc != 0 or NOTES_GLOB not in cur.splitlines():
34
+ git(["config", *scope, "--add", key, NOTES_GLOB], cwd)
35
+
36
+
37
+ def _unset_notes_config(cwd: str, scope: list[str]) -> None:
38
+ for key in NOTES_KEYS:
39
+ git(["config", *scope, "--unset", key, "^" + NOTES_GLOB.replace("*", "\\*").replace(".", "\\.") + "$"], cwd)
40
+
41
+
42
+ def executable_command() -> str:
43
+ """The command Claude Code should run for this install of gitvow.
44
+
45
+ Hooks run in a shell that may not have a virtualenv or pipx path activated, and a hook that
46
+ cannot be found exits non-zero without blocking, which would silently switch the gate off.
47
+ A per-user install therefore records the absolute path of the console script when one exists,
48
+ falling back to `<python> -m gitvow`; per-repo installs keep the bare name because the settings
49
+ file is shared by people with different paths.
50
+ """
51
+ exe = shutil.which("gitvow", path=os.path.dirname(sys.executable)) or shutil.which("gitvow")
52
+ if exe:
53
+ return exe
54
+ return f"{sys.executable} -m gitvow"
25
55
 
26
56
 
27
57
  def _hook_entries(cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
@@ -34,7 +64,7 @@ def _hook_entries(cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
34
64
  return {
35
65
  "SessionStart": [entry("SessionStart", None)],
36
66
  "PreToolUse": [entry("PreToolUse", "Bash|Edit|Write|MultiEdit|NotebookEdit|mcp__.*")],
37
- "PostToolUse": [entry("PostToolUse", "Bash")],
67
+ "PostToolUse": [entry("PostToolUse", "Bash|Edit|Write|MultiEdit|NotebookEdit")],
38
68
  "Stop": [entry("Stop", None)],
39
69
  }
40
70
 
@@ -81,10 +111,11 @@ def _write_git_hook(dirpath: str) -> str:
81
111
  return p
82
112
 
83
113
 
84
- def install_user(home: str, cmd_prefix: str = "gitvow") -> list[str]:
114
+ def install_user(home: str, cmd_prefix: str | None = None) -> list[str]:
85
115
  base = os.path.join(home, ".gitvow")
86
116
  os.makedirs(base, exist_ok=True)
87
- done = []
117
+ cmd_prefix = cmd_prefix or executable_command()
118
+ done = [f"hook command → {cmd_prefix}"]
88
119
  pol = os.path.join(base, "policy.json")
89
120
  if not os.path.exists(pol):
90
121
  from .policy import DEFAULT_POLICY_PATH
@@ -94,7 +125,12 @@ def install_user(home: str, cmd_prefix: str = "gitvow") -> list[str]:
94
125
  _write_git_hook(os.path.join(base, "git-hooks"))
95
126
  merge_settings(os.path.join(home, ".claude", "settings.json"), cmd_prefix)
96
127
  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"]
128
+ _set_notes_config(home, ["--global"])
129
+ done += [
130
+ "hooks merged into ~/.claude/settings.json",
131
+ "global core.hooksPath → ~/.gitvow/git-hooks",
132
+ f"global notes.displayRef / notes.rewriteRef → {NOTES_GLOB}",
133
+ ]
98
134
  return done
99
135
 
100
136
 
@@ -107,6 +143,7 @@ def uninstall_user(home: str, purge_policy: bool = False, purge_ledger: bool = F
107
143
  git(["config", "--global", "--unset", "core.hooksPath"], home)
108
144
  done.append("global core.hooksPath unset")
109
145
  shutil.rmtree(os.path.join(base, "git-hooks"), ignore_errors=True)
146
+ _unset_notes_config(home, ["--global"])
110
147
  if purge_policy and os.path.exists(os.path.join(base, "policy.json")):
111
148
  os.remove(os.path.join(base, "policy.json"))
112
149
  done.append("policy removed")
@@ -128,10 +165,12 @@ def install_repo(repo: str, cmd_prefix: str = "gitvow") -> list[str]:
128
165
  _write_git_hook(os.path.join(base, "git-hooks"))
129
166
  merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix)
130
167
  git(["config", "core.hooksPath", ".gitvow/git-hooks"], repo)
168
+ _set_notes_config(repo, [])
131
169
  return [
132
170
  f"policy → {pol}",
133
171
  "hooks merged into .claude/settings.json",
134
172
  "core.hooksPath → .gitvow/git-hooks",
173
+ f"notes.displayRef / notes.rewriteRef → {NOTES_GLOB}",
135
174
  "commit .gitvow/ and .claude/settings.json to share; teammates run: git config core.hooksPath .gitvow/git-hooks",
136
175
  ]
137
176
 
@@ -144,6 +183,7 @@ def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
144
183
  git(["config", "--unset", "core.hooksPath"], repo)
145
184
  done.append("core.hooksPath unset")
146
185
  shutil.rmtree(os.path.join(repo, ".gitvow"), ignore_errors=True)
186
+ _unset_notes_config(repo, [])
147
187
  rc, gd, _ = git(["rev-parse", "--git-dir"], repo)
148
188
  if rc == 0:
149
189
  gd = gd if os.path.isabs(gd) else os.path.join(repo, gd)
@@ -152,7 +192,9 @@ def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
152
192
  if os.path.exists(p):
153
193
  os.remove(p)
154
194
  if purge_notes:
155
- git(["update-ref", "-d", "refs/notes/sessions"], repo)
156
- done.append("local refs/notes/sessions deleted (remote copies untouched)")
195
+ _, refs, _ = git(["for-each-ref", "--format=%(refname)", "refs/notes/sessions", "refs/notes/gitvow/"], repo)
196
+ for ref in refs.split():
197
+ git(["update-ref", "-d", ref], repo)
198
+ done.append(f"{len(refs.split())} local session note ref(s) deleted (remote copies untouched)")
157
199
  done.append(".gitvow removed; commit trailers already in history remain")
158
200
  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.1
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
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/gitvow/__init__.py
5
+ src/gitvow/__main__.py
5
6
  src/gitvow/cli.py
6
7
  src/gitvow/collect.py
7
8
  src/gitvow/default_policy.json
@@ -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())