gitvow 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """gitvow: provenance and policy gate for AI-agent coding sessions."""
2
+
3
+ __version__ = "0.1.0"
gitvow/cli.py ADDED
@@ -0,0 +1,135 @@
1
+ """gitvow command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ from . import __version__
11
+ from .collect import collect, summarize_text
12
+ from .hooks import HANDLERS
13
+ from .install import install_repo, install_user, uninstall_repo, uninstall_user
14
+ from .policy import PolicyError, evaluate, load_policy
15
+ from .state import git
16
+
17
+
18
+ def cmd_hook(a: argparse.Namespace) -> int:
19
+ try:
20
+ payload = json.load(sys.stdin)
21
+ except json.JSONDecodeError:
22
+ payload = {}
23
+ handler = HANDLERS.get(a.event)
24
+ if not handler:
25
+ print(f"unknown hook event {a.event}", file=sys.stderr)
26
+ return 1
27
+ code, msg = handler(payload)
28
+ if msg:
29
+ print(msg, file=sys.stderr)
30
+ return code
31
+
32
+
33
+ def cmd_install(a: argparse.Namespace) -> int:
34
+ done = install_user(os.path.expanduser("~")) if a.user else install_repo(os.path.abspath(a.repo))
35
+ print("\n".join(done))
36
+ return 0
37
+
38
+
39
+ def cmd_uninstall(a: argparse.Namespace) -> int:
40
+ done = (
41
+ uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger)
42
+ if a.user
43
+ else uninstall_repo(os.path.abspath(a.repo), a.purge_notes)
44
+ )
45
+ print("\n".join(done))
46
+ return 0
47
+
48
+
49
+ def cmd_check(a: argparse.Namespace) -> int:
50
+ """Dry-run the policy against a command, path or MCP tool name."""
51
+ try:
52
+ pol = load_policy(os.getcwd())
53
+ except PolicyError as e:
54
+ print(f"policy error: {e}", file=sys.stderr)
55
+ return 2
56
+ if a.path:
57
+ d = evaluate(pol, "Edit", {"file_path": a.path})
58
+ elif a.mcp:
59
+ d = evaluate(pol, a.mcp, {})
60
+ else:
61
+ d = evaluate(pol, "Bash", {"command": " ".join(a.command)})
62
+ print(d.outcome.upper() + (f": {d.reason}" if d.reason else ""))
63
+ return 2 if d.blocks else 0
64
+
65
+
66
+ def cmd_show(a: argparse.Namespace) -> int:
67
+ """Print a commit's trailers and session note."""
68
+ rc, msg, _ = git(["log", "-1", "--format=%H%n%s%n%b", a.commit], os.getcwd())
69
+ if rc != 0:
70
+ print(f"no such commit: {a.commit}", file=sys.stderr)
71
+ return 1
72
+ 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
+ return 0
76
+
77
+
78
+ def cmd_collect(a: argparse.Namespace) -> int:
79
+ w = collect(os.path.expanduser("~"), a.out)
80
+ print(summarize_text(w))
81
+ print(f"collected into {w} (redacted at write time; review before sending)")
82
+ return 0
83
+
84
+
85
+ def cmd_summarize(a: argparse.Namespace) -> int:
86
+ print(summarize_text(a.dir))
87
+ return 0
88
+
89
+
90
+ def cmd_selftest(a: argparse.Namespace) -> int:
91
+ from .selftest import run
92
+
93
+ return run()
94
+
95
+
96
+ def main(argv: list[str] | None = None) -> int:
97
+ p = argparse.ArgumentParser(prog="gitvow", description="Provenance and policy gate for agent coding sessions.")
98
+ p.add_argument("--version", action="version", version=f"gitvow {__version__}")
99
+ sub = p.add_subparsers(dest="cmd", required=True)
100
+ s = sub.add_parser("hook", help="run as a Claude Code hook (reads JSON on stdin)")
101
+ s.add_argument("event")
102
+ s.set_defaults(f=cmd_hook)
103
+ s = sub.add_parser("install", help="install per user (--user) or into a repo")
104
+ s.add_argument("repo", nargs="?", default=".")
105
+ s.add_argument("--user", action="store_true")
106
+ s.set_defaults(f=cmd_install)
107
+ s = sub.add_parser("uninstall", help="remove what install added")
108
+ s.add_argument("repo", nargs="?", default=".")
109
+ s.add_argument("--user", action="store_true")
110
+ s.add_argument("--purge-notes", action="store_true")
111
+ s.add_argument("--purge-policy", action="store_true")
112
+ s.add_argument("--purge-ledger", action="store_true")
113
+ s.set_defaults(f=cmd_uninstall)
114
+ s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
115
+ s.add_argument("command", nargs="*")
116
+ s.add_argument("--path")
117
+ s.add_argument("--mcp")
118
+ s.set_defaults(f=cmd_check)
119
+ s = sub.add_parser("show", help="print a commit's trailers and session note")
120
+ s.add_argument("commit", nargs="?", default="HEAD")
121
+ s.set_defaults(f=cmd_show)
122
+ s = sub.add_parser("collect", help="gather ledger, logs, trailers and notes into one directory")
123
+ s.add_argument("--out", default=os.path.expanduser("~/Desktop"))
124
+ s.set_defaults(f=cmd_collect)
125
+ s = sub.add_parser("summarize", help="metrics from a collected directory")
126
+ s.add_argument("dir")
127
+ s.set_defaults(f=cmd_summarize)
128
+ s = sub.add_parser("selftest", help="prove the hooks work here without touching a real repo")
129
+ s.set_defaults(f=cmd_selftest)
130
+ a = p.parse_args(argv)
131
+ return a.f(a)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ sys.exit(main())
gitvow/collect.py ADDED
@@ -0,0 +1,103 @@
1
+ """Gather what a trial produced on this machine into one redacted directory, and compute trial metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import glob
7
+ import json
8
+ import os
9
+ import shutil
10
+ import time
11
+ from typing import Any
12
+
13
+ from .state import git
14
+
15
+
16
+ def collect(home: str, out_dir: str) -> str:
17
+ stamp = time.strftime("%Y%m%d-%H%M%S")
18
+ w = os.path.join(out_dir, f"gitvow-{stamp}")
19
+ os.makedirs(os.path.join(w, "ledger"), exist_ok=True)
20
+ os.makedirs(os.path.join(w, "repos"), exist_ok=True)
21
+ led = os.path.join(home, ".gitvow", "ledger")
22
+ repos: set[str] = set()
23
+ for f in glob.glob(os.path.join(led, "*.json")):
24
+ shutil.copy(f, os.path.join(w, "ledger"))
25
+ try:
26
+ with open(f) as fh:
27
+ r = json.load(fh).get("repo")
28
+ if r:
29
+ repos.add(r)
30
+ except (OSError, json.JSONDecodeError):
31
+ pass
32
+ for r in sorted(repos):
33
+ if not os.path.isdir(r):
34
+ continue
35
+ d = os.path.join(w, "repos", os.path.basename(r.rstrip("/")))
36
+ os.makedirs(d, exist_ok=True)
37
+ rc, gd, _ = git(["rev-parse", "--git-dir"], r)
38
+ if rc != 0:
39
+ continue
40
+ gd = gd if os.path.isabs(gd) else os.path.join(r, gd)
41
+ lp = os.path.join(gd, "gitvow-hooks.log")
42
+ if os.path.exists(lp):
43
+ shutil.copy(lp, os.path.join(d, "gitvow-hooks.log"))
44
+ for name, args in (
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:"]),
47
+ ("remote.txt", ["remote", "get-url", "origin"]),
48
+ ):
49
+ _, out, _ = git(args, r)
50
+ with open(os.path.join(d, name), "w") as fh:
51
+ fh.write(out)
52
+ with open(os.path.join(w, "SUMMARY.txt"), "w") as fh:
53
+ fh.write(summarize_text(w))
54
+ return w
55
+
56
+
57
+ def summarize(w: str) -> dict[str, Any]:
58
+ led = [json.load(open(f)) for f in glob.glob(os.path.join(w, "ledger", "*.json"))] # noqa: SIM115
59
+ tools: collections.Counter[str] = collections.Counter(t.get("tool") for s in led for t in s.get("tool_calls", []))
60
+ kinds: collections.Counter[str] = collections.Counter()
61
+ reasons: collections.Counter[tuple[str, str]] = collections.Counter()
62
+ trailered = notes = 0
63
+ for lp in glob.glob(os.path.join(w, "repos", "*", "gitvow-hooks.log")):
64
+ with open(lp) as fh:
65
+ for line in fh:
66
+ try:
67
+ e = json.loads(line)
68
+ except json.JSONDecodeError:
69
+ continue
70
+ kinds[e.get("kind", "?")] += 1
71
+ if e.get("kind") in ("blocked", "confirm_required"):
72
+ reasons[(e.get("kind", "?"), e.get("reason", "?"))] += 1
73
+ for f in glob.glob(os.path.join(w, "repos", "*", "commits-with-trailers.txt")):
74
+ with open(f) as fh:
75
+ trailered += sum(1 for ln in fh if ln.strip())
76
+ for f in glob.glob(os.path.join(w, "repos", "*", "notes.txt")):
77
+ with open(f) as fh:
78
+ notes += fh.read().count("gitvow-session")
79
+ return {
80
+ "sessions": len(led),
81
+ "repos": len({s.get("repo") for s in led}),
82
+ "tool_calls": sum(tools.values()),
83
+ "by_tool": dict(tools.most_common(8)),
84
+ "commits_during_sessions": sum(len(s.get("commits_during_session", [])) for s in led),
85
+ "hook_decisions": dict(kinds),
86
+ "commits_with_trailers": trailered,
87
+ "notes_attached": notes,
88
+ "gate_fired_on": [{"kind": k, "reason": r, "n": n} for (k, r), n in reasons.most_common(20)],
89
+ }
90
+
91
+
92
+ def summarize_text(w: str) -> str:
93
+ s = summarize(w)
94
+ lines = [
95
+ f"sessions: {s['sessions']} repos touched: {s['repos']}",
96
+ f"tool calls: {s['tool_calls']} | by tool: {s['by_tool']}",
97
+ f"commits during sessions: {s['commits_during_sessions']}",
98
+ f"hook decisions: {s['hook_decisions']}",
99
+ f"commits with trailers: {s['commits_with_trailers']} | notes attached: {s['notes_attached']}",
100
+ "gate fired on:",
101
+ ]
102
+ lines += [f" {g['kind']:<17} {g['n']:>4} {g['reason']}" for g in s["gate_fired_on"]] or [" (never)"]
103
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,127 @@
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 redact
13
+ from ..state import git, load_state, log_event, save_state
14
+ from ..transcript import summarize
15
+
16
+ NOTES_REF = "sessions"
17
+ COMMIT_RE = re.compile(r"\bgit\s+commit\b")
18
+
19
+
20
+ def session_start(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
21
+ cwd = h.get("cwd") or os.getcwd()
22
+ st = load_state(cwd)
23
+ st.update(
24
+ {
25
+ "session_id": h.get("session_id"),
26
+ "transcript_path": h.get("transcript_path"),
27
+ "started": time.strftime("%Y-%m-%dT%H:%M:%S"),
28
+ "steps": st.get("steps", 0),
29
+ }
30
+ )
31
+ save_state(cwd, st)
32
+ log_event(cwd, "session_start", {"session_id": h.get("session_id")})
33
+ return 0, ""
34
+
35
+
36
+ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
37
+ cwd = h.get("cwd") or os.getcwd()
38
+ tool = h.get("tool_name", "") or ""
39
+ inp = h.get("tool_input") or {}
40
+ try:
41
+ pol = load_policy(cwd, home)
42
+ except PolicyError as e:
43
+ return (
44
+ 2,
45
+ f"BLOCKED: tool policy could not be loaded ({e}); refusing all tool calls until the policy is restored.",
46
+ )
47
+ d = evaluate(pol, tool, inp)
48
+ if d.blocks:
49
+ log_event(
50
+ cwd,
51
+ "blocked" if d.outcome == "deny" else "confirm_required",
52
+ {"tool": tool, "reason": d.reason, "detail": redact(d.detail)[:200], "session_id": h.get("session_id")},
53
+ )
54
+ return 2, message_for(d)
55
+ if tool == "Bash" and COMMIT_RE.search(inp.get("command", "")):
56
+ st = load_state(cwd)
57
+ st["session_id"] = h.get("session_id") or st.get("session_id")
58
+ st["transcript_path"] = h.get("transcript_path") or st.get("transcript_path")
59
+ st["steps"] = st.get("steps", 0) + 1
60
+ save_state(cwd, st)
61
+ log_event(
62
+ cwd, "allowed", {"tool": tool, "detail": redact(inp.get("command") or inp.get("file_path") or tool)[:160]}
63
+ )
64
+ return 0, ""
65
+
66
+
67
+ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
68
+ cwd = h.get("cwd") or os.getcwd()
69
+ if h.get("tool_name") != "Bash" or not COMMIT_RE.search((h.get("tool_input") or {}).get("command", "")):
70
+ return 0, ""
71
+ rc, head, _ = git(["rev-parse", "HEAD"], cwd)
72
+ if rc != 0:
73
+ return 0, ""
74
+ st = load_state(cwd)
75
+ summ = summarize(h.get("transcript_path") or st.get("transcript_path"))
76
+ _, files, _ = git(["show", "--stat", "--format=", "HEAD"], cwd)
77
+ _, diffstat, _ = git(["show", "--numstat", "--format=", "HEAD"], cwd)
78
+ changed = [ln.split("\t")[-1] for ln in diffstat.splitlines() if ln.strip()]
79
+ agent_written = sorted(f for f in changed if any(f.endswith(w) or w.endswith(f) for w in summ["files_written"]))
80
+ note = {
81
+ "session_id": h.get("session_id") or st.get("session_id"),
82
+ "step": st.get("steps"),
83
+ "committed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
84
+ "assistant_turns_so_far": summ["turns"],
85
+ "tool_calls_so_far": len(summ["tool_calls"]),
86
+ "tools_used": sorted({t["tool"] for t in summ["tool_calls"] if t.get("tool")}),
87
+ "last_stated_plan": summ["last_assistant_text"],
88
+ "files_in_commit": [ln.strip() for ln in files.splitlines()[:-1]][:50] if files else [],
89
+ "files_written_by_agent_this_session": agent_written[:50],
90
+ "attribution": {"files_in_commit": len(changed), "touched_by_agent": len(agent_written)},
91
+ "transcript": "kept local; see ledger",
92
+ "redaction": "secrets/PII patterns and high-entropy tokens replaced at write time",
93
+ }
94
+ body = "gitvow-session\n" + json.dumps(note, indent=1)
95
+ git(["notes", f"--ref={NOTES_REF}", "add", "-f", "-m", body, head], cwd)
96
+ log_event(cwd, "note_added", {"commit": head[:12], "session_id": note["session_id"], "step": note["step"]})
97
+ return 0, f"session note attached to {head[:12]} (refs/notes/{NOTES_REF})"
98
+
99
+
100
+ def stop(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
101
+ cwd = h.get("cwd") or os.getcwd()
102
+ home = home or os.path.expanduser("~")
103
+ st = load_state(cwd)
104
+ summ = summarize(h.get("transcript_path") or st.get("transcript_path"))
105
+ led = os.path.join(home, ".gitvow", "ledger")
106
+ os.makedirs(led, exist_ok=True)
107
+ commits: list[str] = []
108
+ if st.get("started"):
109
+ rc, out, _ = git(["log", "--format=%H", f"--since={st['started']}"], cwd)
110
+ commits = out.split() if rc == 0 else []
111
+ rec = {
112
+ "session_id": h.get("session_id"),
113
+ "repo": cwd,
114
+ "started": st.get("started"),
115
+ "ended": time.strftime("%Y-%m-%dT%H:%M:%S"),
116
+ "assistant_turns": summ["turns"],
117
+ "tool_calls": summ["tool_calls"],
118
+ "commits_during_session": commits,
119
+ "last_stated_plan": summ["last_assistant_text"],
120
+ }
121
+ with open(os.path.join(led, f"{h.get('session_id') or 'unknown'}.json"), "w") as fh:
122
+ json.dump(rec, fh, indent=1)
123
+ log_event(cwd, "session_stop", {"session_id": h.get("session_id"), "tool_calls": len(summ["tool_calls"])})
124
+ return 0, ""
125
+
126
+
127
+ HANDLERS = {"SessionStart": session_start, "PreToolUse": pre_tool_use, "PostToolUse": post_tool_use, "Stop": stop}
gitvow/install.py ADDED
@@ -0,0 +1,158 @@
1
+ """Install/uninstall into Claude Code settings and git hooks, per user or per repo. Idempotent; removes only what it added."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ from typing import Any
9
+
10
+ from .state import git
11
+
12
+ GIT_HOOK = """#!/bin/sh
13
+ # gitvow: append session trailers when a session is active in this repo; chain to the repo's own hook if present.
14
+ GD="$(git rev-parse --git-dir)"; STATE="$GD/gitvow-session.json"
15
+ if [ -f "$STATE" ]; then
16
+ SID=$(python3 -c "import json;print(json.load(open('$STATE')).get('session_id') or '')" 2>/dev/null)
17
+ STEP=$(python3 -c "import json;print(json.load(open('$STATE')).get('steps') or 0)" 2>/dev/null)
18
+ if [ -n "$SID" ] && ! grep -q "^Gitvow-Session:" "$1"; then printf "\\nGitvow-Session: %s\\nGitvow-Step: %s\\n" "$SID" "$STEP" >> "$1"; fi
19
+ fi
20
+ SELF="$(cd "$(dirname "$0")" && pwd)"; REPOHOOKS="$(cd "$GD/hooks" 2>/dev/null && pwd || true)"
21
+ [ -x "$GD/hooks/prepare-commit-msg" ] && [ "$SELF" != "$REPOHOOKS" ] && exec "$GD/hooks/prepare-commit-msg" "$@"
22
+ exit 0
23
+ """
24
+ MARKER = "gitvow hook "
25
+
26
+
27
+ def _hook_entries(cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
28
+ def entry(event: str, matcher: str | None) -> dict[str, Any]:
29
+ e: dict[str, Any] = {"hooks": [{"type": "command", "command": f"{cmd_prefix} hook {event}"}]}
30
+ if matcher:
31
+ e["matcher"] = matcher
32
+ return e
33
+
34
+ return {
35
+ "SessionStart": [entry("SessionStart", None)],
36
+ "PreToolUse": [entry("PreToolUse", "Bash|Edit|Write|MultiEdit|NotebookEdit|mcp__.*")],
37
+ "PostToolUse": [entry("PostToolUse", "Bash")],
38
+ "Stop": [entry("Stop", None)],
39
+ }
40
+
41
+
42
+ def merge_settings(path: str, cmd_prefix: str) -> None:
43
+ cur: dict[str, Any] = {}
44
+ if os.path.exists(path):
45
+ with open(path) as fh:
46
+ cur = json.load(fh)
47
+ hooks = cur.setdefault("hooks", {})
48
+ for ev, entries in _hook_entries(cmd_prefix).items():
49
+ kept = [x for x in hooks.get(ev, []) if not any(MARKER in (h.get("command") or "") for h in x.get("hooks", []))]
50
+ hooks[ev] = kept + entries
51
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
52
+ with open(path, "w") as fh:
53
+ json.dump(cur, fh, indent=2)
54
+
55
+
56
+ def unmerge_settings(path: str) -> None:
57
+ if not os.path.exists(path):
58
+ return
59
+ with open(path) as fh:
60
+ cur = json.load(fh)
61
+ hooks = cur.get("hooks", {})
62
+ for ev in list(hooks):
63
+ hooks[ev] = [x for x in hooks[ev] if not any(MARKER in (h.get("command") or "") for h in x.get("hooks", []))]
64
+ if not hooks[ev]:
65
+ del hooks[ev]
66
+ if not hooks:
67
+ cur.pop("hooks", None)
68
+ if cur:
69
+ with open(path, "w") as fh:
70
+ json.dump(cur, fh, indent=2)
71
+ else:
72
+ os.remove(path)
73
+
74
+
75
+ def _write_git_hook(dirpath: str) -> str:
76
+ os.makedirs(dirpath, exist_ok=True)
77
+ p = os.path.join(dirpath, "prepare-commit-msg")
78
+ with open(p, "w") as fh:
79
+ fh.write(GIT_HOOK)
80
+ os.chmod(p, 0o755) # noqa: S103 # nosec B103 - git runs hooks as the invoking user; must be executable
81
+ return p
82
+
83
+
84
+ def install_user(home: str, cmd_prefix: str = "gitvow") -> list[str]:
85
+ base = os.path.join(home, ".gitvow")
86
+ os.makedirs(base, exist_ok=True)
87
+ done = []
88
+ pol = os.path.join(base, "policy.json")
89
+ if not os.path.exists(pol):
90
+ from .policy import DEFAULT_POLICY_PATH
91
+
92
+ shutil.copy(DEFAULT_POLICY_PATH, pol)
93
+ done.append(f"default policy → {pol}")
94
+ _write_git_hook(os.path.join(base, "git-hooks"))
95
+ merge_settings(os.path.join(home, ".claude", "settings.json"), cmd_prefix)
96
+ 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"]
98
+ return done
99
+
100
+
101
+ def uninstall_user(home: str, purge_policy: bool = False, purge_ledger: bool = False) -> list[str]:
102
+ base = os.path.join(home, ".gitvow")
103
+ done = []
104
+ unmerge_settings(os.path.join(home, ".claude", "settings.json"))
105
+ rc, cur, _ = git(["config", "--global", "--get", "core.hooksPath"], home)
106
+ if rc == 0 and cur == os.path.join(base, "git-hooks"):
107
+ git(["config", "--global", "--unset", "core.hooksPath"], home)
108
+ done.append("global core.hooksPath unset")
109
+ shutil.rmtree(os.path.join(base, "git-hooks"), ignore_errors=True)
110
+ if purge_policy and os.path.exists(os.path.join(base, "policy.json")):
111
+ os.remove(os.path.join(base, "policy.json"))
112
+ done.append("policy removed")
113
+ if purge_ledger:
114
+ shutil.rmtree(os.path.join(base, "ledger"), ignore_errors=True)
115
+ done.append("ledger removed")
116
+ done.append("hook entries removed from ~/.claude/settings.json")
117
+ return done
118
+
119
+
120
+ def install_repo(repo: str, cmd_prefix: str = "gitvow") -> list[str]:
121
+ base = os.path.join(repo, ".gitvow")
122
+ os.makedirs(base, exist_ok=True)
123
+ from .policy import DEFAULT_POLICY_PATH
124
+
125
+ pol = os.path.join(base, "policy.json")
126
+ if not os.path.exists(pol):
127
+ shutil.copy(DEFAULT_POLICY_PATH, pol)
128
+ _write_git_hook(os.path.join(base, "git-hooks"))
129
+ merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix)
130
+ git(["config", "core.hooksPath", ".gitvow/git-hooks"], repo)
131
+ return [
132
+ f"policy → {pol}",
133
+ "hooks merged into .claude/settings.json",
134
+ "core.hooksPath → .gitvow/git-hooks",
135
+ "commit .gitvow/ and .claude/settings.json to share; teammates run: git config core.hooksPath .gitvow/git-hooks",
136
+ ]
137
+
138
+
139
+ def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
140
+ done = []
141
+ unmerge_settings(os.path.join(repo, ".claude", "settings.json"))
142
+ rc, cur, _ = git(["config", "--get", "core.hooksPath"], repo)
143
+ if rc == 0 and cur == ".gitvow/git-hooks":
144
+ git(["config", "--unset", "core.hooksPath"], repo)
145
+ done.append("core.hooksPath unset")
146
+ shutil.rmtree(os.path.join(repo, ".gitvow"), ignore_errors=True)
147
+ rc, gd, _ = git(["rev-parse", "--git-dir"], repo)
148
+ if rc == 0:
149
+ gd = gd if os.path.isabs(gd) else os.path.join(repo, gd)
150
+ for f in ("gitvow-session.json", "gitvow-hooks.log"):
151
+ p = os.path.join(gd, f)
152
+ if os.path.exists(p):
153
+ os.remove(p)
154
+ if purge_notes:
155
+ git(["update-ref", "-d", "refs/notes/sessions"], repo)
156
+ done.append("local refs/notes/sessions deleted (remote copies untouched)")
157
+ done.append(".gitvow removed; commit trailers already in history remain")
158
+ return done
gitvow/policy.py ADDED
@@ -0,0 +1,120 @@
1
+ """Tool-call policy: deny → confirm → allow, evaluated on Bash command text, edited file paths, and MCP tool names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import subprocess
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ DEFAULT_POLICY_PATH = os.path.join(os.path.dirname(__file__), "default_policy.json")
14
+
15
+
16
+ class PolicyError(Exception):
17
+ """Raised when no valid policy can be loaded. Callers must fail closed."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Decision:
22
+ outcome: str # "allow" | "deny" | "confirm"
23
+ reason: str = ""
24
+ detail: str = ""
25
+
26
+ @property
27
+ def blocks(self) -> bool:
28
+ return self.outcome != "allow"
29
+
30
+
31
+ def load_policy(cwd: str | None = None, home: str | None = None) -> dict[str, Any]:
32
+ """Precedence: <repo>/.gitvow/policy.json → ~/.gitvow/policy.json → package default."""
33
+ cwd = cwd or os.getcwd()
34
+ home = home or os.path.expanduser("~")
35
+ for p in (
36
+ os.path.join(cwd, ".gitvow", "policy.json"),
37
+ os.path.join(home, ".gitvow", "policy.json"),
38
+ DEFAULT_POLICY_PATH,
39
+ ):
40
+ if os.path.exists(p):
41
+ try:
42
+ with open(p) as fh:
43
+ pol = json.load(fh)
44
+ except (OSError, json.JSONDecodeError) as e:
45
+ raise PolicyError(f"{p}: {e}") from e
46
+ _validate(pol, p)
47
+ return pol
48
+ raise PolicyError("no policy.json found")
49
+
50
+
51
+ def _validate(pol: dict[str, Any], path: str) -> None:
52
+ for key in ("bash_deny", "bash_confirm", "path_confirm"):
53
+ for rule in pol.get(key, []):
54
+ if not isinstance(rule, dict) or "pattern" not in rule:
55
+ raise PolicyError(f"{path}: {key} entries need a 'pattern'")
56
+ try:
57
+ re.compile(rule["pattern"])
58
+ except re.error as e:
59
+ raise PolicyError(f"{path}: bad regex in {key}: {rule['pattern']} ({e})") from e
60
+ for key in ("mcp_allow", "mcp_deny"):
61
+ for pat in pol.get(key, []):
62
+ try:
63
+ re.compile(pat)
64
+ except re.error as e:
65
+ raise PolicyError(f"{path}: bad regex in {key}: {pat} ({e})") from e
66
+
67
+
68
+ def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any]) -> Decision:
69
+ text = tool_input.get("command", "") if tool == "Bash" else ""
70
+ path = tool_input.get("file_path", "") or tool_input.get("notebook_path", "")
71
+ if tool == "Bash":
72
+ for r in pol.get("bash_deny", []):
73
+ if re.search(r["pattern"], text):
74
+ return Decision("deny", r.get("reason", "denied"), text)
75
+ for r in pol.get("bash_confirm", []):
76
+ if re.search(r["pattern"], text):
77
+ return Decision("confirm", r.get("reason", "needs confirmation"), text)
78
+ if tool in ("Edit", "Write", "MultiEdit", "NotebookEdit") and path:
79
+ for r in pol.get("path_confirm", []):
80
+ if re.search(r["pattern"], path):
81
+ return Decision("confirm", r.get("reason", "sensitive path"), path)
82
+ if tool.startswith("mcp__"):
83
+ if any(re.fullmatch(p, tool) for p in pol.get("mcp_deny", [])):
84
+ return Decision("deny", "MCP tool on deny list", tool)
85
+ allow = pol.get("mcp_allow", [])
86
+ if allow and not any(re.fullmatch(p, tool) for p in allow):
87
+ return Decision("confirm", "MCP tool not on allow list", tool)
88
+ clf = pol.get("llm_classifier") or {}
89
+ if clf.get("enabled") and clf.get("command"):
90
+ return _classify(clf["command"], tool, tool_input, text or path or tool)
91
+ return Decision("allow")
92
+
93
+
94
+ def _classify(command: str, tool: str, tool_input: dict[str, Any], detail: str) -> Decision:
95
+ payload = json.dumps({"tool_name": tool, "tool_input": tool_input})
96
+ try:
97
+ argv = shlex.split(command) if isinstance(command, str) else [str(a) for a in command]
98
+ if not argv:
99
+ return Decision("confirm", "classifier command is empty", detail)
100
+ # No shell: the policy file names a program and its arguments, so policy text
101
+ # can never become shell syntax.
102
+ r = subprocess.run(argv, input=payload, capture_output=True, text=True, timeout=30, check=False)
103
+ except (OSError, subprocess.TimeoutExpired):
104
+ return Decision("confirm", "classifier unavailable", detail)
105
+ parts = r.stdout.strip().split(maxsplit=1) or ["ALLOW"]
106
+ verdict, reason = parts[0].upper(), (parts[1] if len(parts) > 1 else "")
107
+ if verdict == "DENY":
108
+ return Decision("deny", "classifier: " + reason, detail)
109
+ if verdict == "CONFIRM":
110
+ return Decision("confirm", "classifier: " + reason, detail)
111
+ return Decision("allow")
112
+
113
+
114
+ def message_for(d: Decision) -> str:
115
+ if d.outcome == "deny":
116
+ return f"BLOCKED by policy ({d.reason})."
117
+ return (
118
+ f"CONFIRMATION REQUIRED ({d.reason}). Ask the user explicitly before doing this; "
119
+ "if they confirm, tell them to re-run with the policy exception or perform it manually."
120
+ )
gitvow/redact.py ADDED
@@ -0,0 +1,71 @@
1
+ """Redaction of secrets and personal data at write time. Best effort by design; see SECURITY.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import re
9
+ from collections import Counter
10
+ from collections.abc import Callable, Iterable
11
+
12
+ _EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
13
+
14
+
15
+ def _email_token(m: re.Match[str]) -> str:
16
+ return "[email:" + hashlib.sha256(m.group(0).lower().encode()).hexdigest()[:8] + "]"
17
+
18
+
19
+ PATTERNS: list[tuple[re.Pattern[str], str | Callable[[re.Match[str]], str]]] = [
20
+ (re.compile(r"AKIA[0-9A-Z]{16}"), "[aws-access-key]"),
21
+ (re.compile(r"(?i)(aws_secret_access_key|secret_access_key)\s*[:=]\s*\S+"), r"\1=[redacted]"),
22
+ (re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}"), "[jwt]"),
23
+ (re.compile(r"\b(gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}"), "[github-token]"),
24
+ (re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"), "[api-key]"),
25
+ (re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), "[slack-token]"),
26
+ (re.compile(r"\bAIza[0-9A-Za-z_-]{35}"), "[google-api-key]"),
27
+ (
28
+ re.compile(
29
+ r"(?i)(password|passwd|pwd|token|secret|api[_-]?key|authorization|bearer)\s*[:=]\s*[\"']?[^\s\"']{6,}"
30
+ ),
31
+ r"\1=[redacted]",
32
+ ),
33
+ (re.compile(r"(?i)\b(mysql|postgres(?:ql)?|redis|mongodb(?:\+srv)?|amqp)://[^\s\"']+"), r"\1://[redacted-dsn]"),
34
+ (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"), "[private-key]"),
35
+ (_EMAIL, _email_token),
36
+ (re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[ssn-like]"),
37
+ (re.compile(r"\b\d(?:[ -]?\d){12,18}\b(?![ -]?\d)"), "[card-like-number]"),
38
+ (re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "[ip]"),
39
+ ]
40
+
41
+ _ENTROPY_TOKEN = re.compile(r"[A-Za-z0-9+/=_-]{20,}")
42
+
43
+
44
+ def shannon_entropy(s: str) -> float:
45
+ if not s:
46
+ return 0.0
47
+ counts = Counter(s)
48
+ n = len(s)
49
+ return -sum((c / n) * math.log2(c / n) for c in counts.values())
50
+
51
+
52
+ def redact_high_entropy(s: str, threshold: float = 4.5, min_len: int = 20) -> str:
53
+ """Replace long alphanumeric runs whose Shannon entropy exceeds the threshold. Catches unknown secret formats."""
54
+
55
+ def repl(m: re.Match[str]) -> str:
56
+ tok = m.group(0)
57
+ if len(tok) >= min_len and shannon_entropy(tok) > threshold and not tok.startswith(("http", "/")):
58
+ return "[high-entropy]"
59
+ return tok
60
+
61
+ return _ENTROPY_TOKEN.sub(repl, s)
62
+
63
+
64
+ def redact(value: object, custom: Iterable[tuple[str, str]] = ()) -> str:
65
+ """Redact a string (or JSON-serialisable value). Custom rules are (regex, replacement) pairs applied first."""
66
+ s = value if isinstance(value, str) else json.dumps(value, default=str)
67
+ for pat, custom_rep in custom:
68
+ s = re.sub(pat, custom_rep, s)
69
+ for rx, rep in PATTERNS:
70
+ s = rx.sub(rep, s)
71
+ return redact_high_entropy(s)
gitvow/selftest.py ADDED
@@ -0,0 +1,95 @@
1
+ """Self-check: drives every hook in a throwaway repository and reports pass/fail. Never touches a real repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+
10
+ from .hooks import post_tool_use, pre_tool_use, session_start, stop
11
+ from .install import _write_git_hook
12
+
13
+
14
+ def run() -> int:
15
+ home = tempfile.mkdtemp(prefix="gitvow-home-")
16
+ repo = tempfile.mkdtemp(prefix="gitvow-repo-")
17
+ results: list[tuple[bool, str]] = []
18
+
19
+ def g(*a: str) -> str:
20
+ return subprocess.run(["git", *a], cwd=repo, capture_output=True, text=True).stdout.strip()
21
+
22
+ try:
23
+ g("init", "-q")
24
+ g("config", "user.email", "selftest@local")
25
+ g("config", "user.name", "selftest")
26
+ with open(os.path.join(repo, "a.txt"), "w") as fh:
27
+ fh.write("a\n")
28
+ g("add", "a.txt")
29
+ g("commit", "-qm", "init")
30
+ base = {"session_id": "selftest-session", "transcript_path": "", "cwd": repo}
31
+ session_start(base, home)
32
+ results.append((os.path.exists(os.path.join(repo, ".git", "gitvow-session.json")), "session recorded in .git"))
33
+ results.append(
34
+ (
35
+ pre_tool_use(
36
+ {**base, "tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}}, home
37
+ )[0]
38
+ == 2,
39
+ "deny: force push blocked",
40
+ )
41
+ )
42
+ results.append(
43
+ (
44
+ pre_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "git push origin feat"}}, home)[0]
45
+ == 2,
46
+ "confirm: git push requires asking",
47
+ )
48
+ )
49
+ results.append(
50
+ (
51
+ pre_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "ls -la"}}, home)[0] == 0,
52
+ "allow: harmless command",
53
+ )
54
+ )
55
+ results.append(
56
+ (
57
+ pre_tool_use({**base, "tool_name": "Edit", "tool_input": {"file_path": "x/authz_rules.py"}}, home)[0]
58
+ == 2,
59
+ "confirm: gate-bearing file edit",
60
+ )
61
+ )
62
+ results.append(
63
+ (
64
+ pre_tool_use({**base, "tool_name": "mcp__x__delete_thing", "tool_input": {}}, home)[0] == 2,
65
+ "deny: destructive MCP tool",
66
+ )
67
+ )
68
+ pre_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, home)
69
+ hooks_dir = os.path.join(repo, ".gitvow", "git-hooks")
70
+ _write_git_hook(hooks_dir)
71
+ g("config", "core.hooksPath", ".gitvow/git-hooks")
72
+ with open(os.path.join(repo, "a.txt"), "a") as fh:
73
+ fh.write("b\n")
74
+ g("commit", "-qam", "selftest commit")
75
+ results.append(("Gitvow-Session:" in g("log", "-1", "--format=%B"), "commit trailer added"))
76
+ post_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, home)
77
+ results.append(
78
+ (
79
+ g("notes", "--ref=sessions", "list").count("\n") + (1 if g("notes", "--ref=sessions", "list") else 0)
80
+ == 1,
81
+ "session note attached",
82
+ )
83
+ )
84
+ stop(base, home)
85
+ results.append(
86
+ (os.path.exists(os.path.join(home, ".gitvow", "ledger", "selftest-session.json")), "ledger written")
87
+ )
88
+ finally:
89
+ shutil.rmtree(home, ignore_errors=True)
90
+ shutil.rmtree(repo, ignore_errors=True)
91
+ for ok, name in results:
92
+ print((" ok " if ok else " FAIL ") + name)
93
+ failed = sum(1 for ok, _ in results if not ok)
94
+ print(f"\nselftest: {len(results) - failed} passed, {failed} failed")
95
+ return 1 if failed else 0
gitvow/state.py ADDED
@@ -0,0 +1,52 @@
1
+ """Per-repository state kept inside .git (never in the tree) and the append-only hook log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import time
9
+ from typing import Any
10
+
11
+
12
+ def git(args: list[str], cwd: str) -> tuple[int, str, str]:
13
+ r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
14
+ return r.returncode, r.stdout.strip(), r.stderr.strip()
15
+
16
+
17
+ def git_dir(cwd: str) -> str | None:
18
+ rc, out, _ = git(["rev-parse", "--git-dir"], cwd)
19
+ if rc != 0:
20
+ return None
21
+ return out if os.path.isabs(out) else os.path.join(cwd, out)
22
+
23
+
24
+ def state_path(cwd: str) -> str | None:
25
+ gd = git_dir(cwd)
26
+ return os.path.join(gd, "gitvow-session.json") if gd else None
27
+
28
+
29
+ def load_state(cwd: str) -> dict[str, Any]:
30
+ p = state_path(cwd)
31
+ if p and os.path.exists(p):
32
+ try:
33
+ with open(p) as fh:
34
+ return json.load(fh)
35
+ except (OSError, json.JSONDecodeError):
36
+ return {}
37
+ return {}
38
+
39
+
40
+ def save_state(cwd: str, st: dict[str, Any]) -> None:
41
+ p = state_path(cwd)
42
+ if p:
43
+ with open(p, "w") as fh:
44
+ json.dump(st, fh, indent=1)
45
+
46
+
47
+ def log_event(cwd: str, kind: str, payload: dict[str, Any]) -> None:
48
+ gd = git_dir(cwd)
49
+ if not gd:
50
+ return
51
+ with open(os.path.join(gd, "gitvow-hooks.log"), "a") as fh:
52
+ fh.write(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "kind": kind, **payload}) + "\n")
gitvow/transcript.py ADDED
@@ -0,0 +1,47 @@
1
+ """Structural, redacted summary of an agent transcript (Claude Code JSONL). Tool output is never read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+ from .redact import redact
10
+
11
+
12
+ def summarize(path: str | None, max_tools: int = 500) -> dict[str, Any]:
13
+ out: dict[str, Any] = {"turns": 0, "tool_calls": [], "last_assistant_text": "", "files_written": []}
14
+ if not path or not os.path.exists(path):
15
+ return out
16
+ written: set[str] = set()
17
+ with open(path, errors="ignore") as fh:
18
+ for line in fh:
19
+ try:
20
+ ev = json.loads(line)
21
+ except json.JSONDecodeError:
22
+ continue
23
+ msg = ev.get("message") or {}
24
+ role = msg.get("role") or ev.get("type")
25
+ content = msg.get("content")
26
+ if role != "assistant":
27
+ continue
28
+ out["turns"] += 1
29
+ if isinstance(content, str):
30
+ if content.strip():
31
+ out["last_assistant_text"] = redact(content)[:600]
32
+ continue
33
+ if not isinstance(content, list):
34
+ continue
35
+ for c in content:
36
+ if not isinstance(c, dict):
37
+ continue
38
+ if c.get("type") == "tool_use" and len(out["tool_calls"]) < max_tools:
39
+ inp = c.get("input") or {}
40
+ 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]})
42
+ if c.get("name") in ("Edit", "Write", "MultiEdit", "NotebookEdit") and inp.get("file_path"):
43
+ written.add(str(inp["file_path"]))
44
+ elif c.get("type") == "text" and str(c.get("text", "")).strip():
45
+ out["last_assistant_text"] = redact(c["text"])[:600]
46
+ out["files_written"] = sorted(written)
47
+ return out
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitvow
3
+ Version: 0.1.0
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
+ Author-email: Nikhil Bora <nikhil@wirevow.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://wirevow.dev/gitvow
8
+ Project-URL: Documentation, https://wirevow.dev/gitvow
9
+ Project-URL: Repository, https://github.com/wirevow/gitvow
10
+ Project-URL: Security, https://github.com/wirevow/gitvow/blob/main/SECURITY.md
11
+ Keywords: git,provenance,ai-agents,claude-code,hooks,policy
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Version Control :: Git
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8; extra == "dev"
22
+ Requires-Dist: pytest-cov>=5; extra == "dev"
23
+ Requires-Dist: ruff>=0.6; extra == "dev"
24
+ Requires-Dist: bandit>=1.7; extra == "dev"
25
+ Requires-Dist: pip-audit>=2.7; extra == "dev"
26
+ Requires-Dist: mypy>=1.10; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # gitvow
30
+
31
+ Provenance and a policy gate for AI-agent coding sessions, stored in the git you already have.
32
+
33
+ [![ci](https://github.com/wirevow/gitvow/actions/workflows/ci.yml/badge.svg)](https://github.com/wirevow/gitvow/actions/workflows/ci.yml)
34
+ [![codeql](https://github.com/wirevow/gitvow/actions/workflows/codeql.yml/badge.svg)](https://github.com/wirevow/gitvow/actions/workflows/codeql.yml)
35
+ ![python](https://img.shields.io/badge/python-3.9%E2%80%933.12-blue) ![deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen) ![license](https://img.shields.io/badge/license-Apache--2.0-blue)
36
+
37
+ **Who made this change, what were they trying to do, and was it allowed?** For code written with AI agents, git alone cannot answer. gitvow makes it answer.
38
+
39
+ - Commits made during an agent session carry the session id and a step number as **trailers**.
40
+ - Each such commit gets a **session note**: the agent's stated plan, the tools it used, the files it touched, how much of the commit it wrote. Redacted, stored as a git note, never in the tree.
41
+ - Every tool call passes a **gate** first: destructive commands are refused, risky ones require asking you, edits to gate-bearing files need a human. The rules are a JSON file you own. Missing policy fails closed.
42
+ - A **ledger** of the whole session stays in your home directory. Nothing leaves the machine unless you push it.
43
+
44
+ Standard-library Python and git. No runtime dependencies, no network calls, no telemetry.
45
+
46
+ ## Quick start
47
+
48
+ ```sh
49
+ pip install gitvow
50
+ gitvow install --user
51
+ gitvow selftest
52
+ ```
53
+
54
+ Work in Claude Code as usual. When the agent commits:
55
+
56
+ ```sh
57
+ $ git log -1 --format=%B
58
+ Fix week-start cache key
59
+
60
+ Gitvow-Session: 8f3d5c71-574a-4eec-8903-9425e3a8335b
61
+ Gitvow-Step: 4
62
+
63
+ $ gitvow show HEAD
64
+ ...
65
+ gitvow-session
66
+ {
67
+ "step": 4,
68
+ "tools_used": ["Bash", "Edit", "Read"],
69
+ "last_stated_plan": "Change the cache key to include week start so per-org settings do not collide...",
70
+ "files_in_commit": ["query-engine/.../QueryCacheHelper.java | 4 +++-"],
71
+ "files_written_by_agent_this_session": ["query-engine/.../QueryCacheHelper.java"],
72
+ "attribution": {"files_in_commit": 1, "touched_by_agent": 1}
73
+ }
74
+ ```
75
+
76
+ Try the gate by hand:
77
+
78
+ ```sh
79
+ gitvow check -- git push --force # DENY: force push
80
+ gitvow check -- kubectl apply -f x.yaml # CONFIRM: cluster apply
81
+ gitvow check --path core/authz_rules.go # CONFIRM: edits an authorization or gate file
82
+ ```
83
+
84
+ Remove everything:
85
+
86
+ ```sh
87
+ gitvow uninstall --user
88
+ ```
89
+
90
+ ## Documentation
91
+
92
+ The docs site is the source of truth: **https://wirevow.dev/gitvow** (built from `docs/`).
93
+
94
+ - [Quick start](docs/quickstart.md)
95
+ - Concepts: [Sessions, steps and notes](docs/concepts/sessions.md) · [The gate](docs/concepts/gate.md) · [What stays out of git](docs/concepts/storage.md)
96
+ - Guides: [Install per user or per repo](docs/guides/install.md) · [Write a policy](docs/guides/policy.md) · [Read a commit's session](docs/guides/reading.md) · [Run a trial](docs/guides/trial.md) · [Redaction](docs/guides/redaction.md)
97
+ - Reference: [CLI](docs/reference/cli.md) · [Hook payloads](docs/reference/hooks.md) · [Note schema](docs/reference/note.md) · [Policy schema](docs/reference/policy.md)
98
+ - [Security](docs/security.md) · [Roadmap](docs/roadmap.md) · [FAQ](docs/faq.md)
99
+
100
+ ## How it works
101
+
102
+ ```
103
+ Claude Code ──hook──▶ gitvow hook PreToolUse ──▶ policy ──▶ allow / confirm / deny (exit 0 / 2 / 2)
104
+ ──hook──▶ gitvow hook PostToolUse ─▶ on `git commit`: read transcript → redact → git notes add
105
+ git commit ──prepare-commit-msg──▶ Gitvow-Session / Gitvow-Step trailers (from .git/gitvow-session.json)
106
+ Claude Code ──hook──▶ gitvow hook Stop ─────────▶ ~/.gitvow/ledger/<session>.json
107
+ ```
108
+
109
+ | Data | Where | Enters git? |
110
+ |---|---|---|
111
+ | session id, step | commit trailers | yes |
112
+ | session note (structure, redacted plan, attribution) | `refs/notes/sessions` | as a note; local until pushed |
113
+ | ledger, hook log, session state | `~/.gitvow/`, `<repo>/.git/` | no |
114
+ | transcript | untouched | never |
115
+
116
+ ## Status
117
+
118
+ 0.1.0. Used in a small internal trial; the [roadmap](docs/roadmap.md) lists what comes next and what is deliberately not planned. Claude Code is the only agent supported today; the hook payload is [documented](docs/reference/hooks.md) so adapters are straightforward.
119
+
120
+ ## Contributing and security
121
+
122
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). Reproductions of redaction gaps must use synthetic secrets.
123
+
124
+ Apache-2.0.
@@ -0,0 +1,16 @@
1
+ gitvow/__init__.py,sha256=2ucnFWzCsC7eNPXs7KSaA5NNOZzE7XCtuLOtxrGPYRw,94
2
+ gitvow/cli.py,sha256=YdsHTZ4RTIo6SseT3-guciqxIKbKyAknoy_PefchqPI,4656
3
+ gitvow/collect.py,sha256=5cyLpRDYvuC1mHOjgCDUlR4_22jyj-RS2U8u14JW4f8,4279
4
+ gitvow/install.py,sha256=kbfxAg3jmD_xuX9bBXBwwgg2qUWgPG-Sdf0CphtGbt4,6513
5
+ gitvow/policy.py,sha256=KEawRpYGPZYw-sEO20Qc-AzVUbpFu_Wx5B1QAe7I0IU,4986
6
+ gitvow/redact.py,sha256=K3KgpqXuhi5oRSDasVy_-qzJ0moqoBEZacMroc4400U,2864
7
+ gitvow/selftest.py,sha256=8JJ06e5JId34iLWyiAub0jlU3c7MGjBwjj8oL-izsDU,3731
8
+ gitvow/state.py,sha256=cyxI47N4WsMYiDl5hGLiny2QAbgGsEbSRzgOrvzDF2k,1456
9
+ gitvow/transcript.py,sha256=f6RpBlokbm9wBjFfSwfdVHA3M1wBvFYGwAnHdDOQjIA,1993
10
+ gitvow/hooks/__init__.py,sha256=cs8E4VuA2mVEBVoAXz3j3dSof0RAz8sXhcr2Q1NASEQ,5399
11
+ gitvow-0.1.0.dist-info/licenses/LICENSE,sha256=w3ZN927TGUiCY6tMBF4jHIthy-WAF27Tnwmr6e1eTkE,748
12
+ gitvow-0.1.0.dist-info/METADATA,sha256=vhnPaOWfZqsdecRag5vxhphbq2vTTbo75mg-9YgERuk,5788
13
+ gitvow-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
14
+ gitvow-0.1.0.dist-info/entry_points.txt,sha256=sU94uRlBBoLDyNMOAiT912qlUEWbIq0Gj0IczuVPLJY,43
15
+ gitvow-0.1.0.dist-info/top_level.txt,sha256=MnguFlgJkvcXr6GvxMJeKddQbW581wRuh5NC6tDeqEw,7
16
+ gitvow-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitvow = gitvow.cli:main
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 gitvow contributors
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1 @@
1
+ gitvow