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.
- {gitvow-0.1.2/src/gitvow.egg-info → gitvow-0.2.1}/PKG-INFO +1 -1
- {gitvow-0.1.2 → gitvow-0.2.1}/pyproject.toml +1 -1
- gitvow-0.2.1/src/gitvow/__main__.py +7 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/cli.py +25 -3
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/collect.py +11 -1
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/default_policy.json +3 -3
- gitvow-0.2.1/src/gitvow/hooks/__init__.py +228 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/install.py +48 -6
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/redact.py +51 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/selftest.py +16 -5
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/state.py +5 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/transcript.py +6 -4
- {gitvow-0.1.2 → gitvow-0.2.1/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow.egg-info/SOURCES.txt +1 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/tests/test_collect.py +3 -1
- gitvow-0.2.1/tests/test_hooks.py +195 -0
- gitvow-0.2.1/tests/test_install_cli.py +167 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/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.1}/LICENSE +0 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/README.md +0 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/setup.cfg +0 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/__init__.py +0 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow/policy.py +0 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.1.2 → gitvow-0.2.1}/tests/test_packaging.py +0 -0
- {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
|
|
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
|
|
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"}
|
|
@@ -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}
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(["
|
|
156
|
-
|
|
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
|
-
|
|
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.1
|
|
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
|
|
@@ -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())
|