gitvow 0.24.0__tar.gz → 0.26.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitvow-0.24.0/src/gitvow.egg-info → gitvow-0.26.0}/PKG-INFO +1 -1
- {gitvow-0.24.0 → gitvow-0.26.0}/pyproject.toml +1 -1
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/claims.py +6 -3
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/cli.py +14 -2
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/decisions.py +2 -2
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/hooks/__init__.py +75 -11
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/install.py +21 -21
- gitvow-0.26.0/src/gitvow/paths.py +113 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/rules.py +6 -3
- gitvow-0.26.0/src/gitvow/safewrite.py +76 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/snapshots.py +12 -1
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/status.py +50 -1
- {gitvow-0.24.0 → gitvow-0.26.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow.egg-info/SOURCES.txt +3 -0
- gitvow-0.26.0/tests/test_hardening.py +117 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_hooks.py +64 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_tiers.py +1 -4
- {gitvow-0.24.0 → gitvow-0.26.0}/LICENSE +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/README.md +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/setup.cfg +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/adapters.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/digest.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/export.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/pack.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/policy.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/pricing.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/proposals.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/recall.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/report.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/scan.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/selftest.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/state.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/sync.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_adapters.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_claims.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_collect.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_cost.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_decisions.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_digest.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_e2e_globalopt.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_export.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_external_adapter.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_pack.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_packaging.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_policy.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_proposals.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_providers.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_recall.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_redact.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_report.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_rules.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_scan.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_snapshots.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_status.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_sync.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_sync_http.py +0 -0
- {gitvow-0.24.0 → gitvow-0.26.0}/tests/test_transcript_formats.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.0
|
|
4
4
|
Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
|
|
5
5
|
Author-email: Nikhil Bora <nikhil@wirevow.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitvow"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.26.0"
|
|
8
8
|
description = "Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -576,7 +576,10 @@ def write_section(path: str, block: str) -> str:
|
|
|
576
576
|
action = "added"
|
|
577
577
|
else:
|
|
578
578
|
return "nothing to write"
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
579
|
+
from .safewrite import check_target, write_if_changed
|
|
580
|
+
from .state import toplevel
|
|
581
|
+
|
|
582
|
+
target = check_target(path, toplevel(os.path.dirname(path) or "."))
|
|
583
|
+
if not write_if_changed(target, new):
|
|
584
|
+
return f"managed claims section in {path} already current"
|
|
582
585
|
return f"{action} managed claims section in {path}"
|
|
@@ -115,8 +115,14 @@ def cmd_install(a: argparse.Namespace) -> int:
|
|
|
115
115
|
lines: list[str] = []
|
|
116
116
|
if detected:
|
|
117
117
|
lines.append(f"agents found here: {', '.join(agents)} (use --agent to pick one)")
|
|
118
|
+
from .safewrite import UnsafeTargetError
|
|
119
|
+
|
|
118
120
|
for agent in agents:
|
|
119
|
-
|
|
121
|
+
try:
|
|
122
|
+
lines += install_user(home, agent=agent) if a.user else install_repo(repo, agent=agent)
|
|
123
|
+
except UnsafeTargetError as e:
|
|
124
|
+
print(f"refused: {e}", file=sys.stderr)
|
|
125
|
+
return 1
|
|
120
126
|
seen: set[str] = set()
|
|
121
127
|
for ln in lines:
|
|
122
128
|
if ln not in seen:
|
|
@@ -212,8 +218,11 @@ def cmd_coverage(a: argparse.Namespace) -> int:
|
|
|
212
218
|
|
|
213
219
|
def cmd_status(a: argparse.Namespace) -> int:
|
|
214
220
|
"""Is the record actually being written here? Reports what is missing and the line that fixes it."""
|
|
215
|
-
from .status import build, render
|
|
221
|
+
from .status import badge, build, render
|
|
216
222
|
|
|
223
|
+
if a.line:
|
|
224
|
+
print(badge(os.getcwd()))
|
|
225
|
+
return 0
|
|
217
226
|
checks = build(os.getcwd(), os.path.expanduser("~"))
|
|
218
227
|
if a.json:
|
|
219
228
|
print(json.dumps([{"state": s, "what": w, "fix": f} for s, w, f in checks], indent=1))
|
|
@@ -890,6 +899,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
890
899
|
s.set_defaults(f=cmd_coverage)
|
|
891
900
|
s = sub.add_parser("status", help="check the install is live: hooks reachable, policy loads, git hooks in place")
|
|
892
901
|
s.add_argument("--json", action="store_true")
|
|
902
|
+
s.add_argument(
|
|
903
|
+
"--line", action="store_true", help="one line for a status bar: what this repository's session recorded"
|
|
904
|
+
)
|
|
893
905
|
s.set_defaults(f=cmd_status)
|
|
894
906
|
s = sub.add_parser("sync", help="export the record and deliver it to the configured sinks; never the session")
|
|
895
907
|
s.add_argument("--sink", help="deliver to this sink only")
|
|
@@ -379,8 +379,8 @@ def _elsewhere_line(elsewhere: dict[str, Any]) -> str:
|
|
|
379
379
|
total = sum(int(v.get("count", 0)) for v in elsewhere.values())
|
|
380
380
|
names = ", ".join(sorted(elsewhere)[:4])
|
|
381
381
|
return (
|
|
382
|
-
f"Edits outside this repository: {total} in {names}.
|
|
383
|
-
f"recorded in this repository's note
|
|
382
|
+
f"Edits outside this repository: {total} in {names}. Each was gated by that repository's own policy and "
|
|
383
|
+
f"recorded in its state and notes; this repository's note names them too.\n"
|
|
384
384
|
)
|
|
385
385
|
|
|
386
386
|
|
|
@@ -10,6 +10,7 @@ from typing import Any
|
|
|
10
10
|
|
|
11
11
|
from .. import decisions as dec
|
|
12
12
|
from .. import snapshots
|
|
13
|
+
from ..paths import is_credential_path
|
|
13
14
|
from ..policy import PolicyError, confirm_message, evaluate, load_policy, message_for
|
|
14
15
|
from ..pricing import estimate
|
|
15
16
|
from ..redact import RedactionError, load_rules, redact
|
|
@@ -110,10 +111,58 @@ def _rules_or_none(cwd: str, home: str | None) -> tuple[list[tuple[str, str]] |
|
|
|
110
111
|
return None, f"gitvow: {e}. {REDACTION_UNAVAILABLE}."
|
|
111
112
|
|
|
112
113
|
|
|
114
|
+
_GIT_C_RE = re.compile(r"\bgit\b[^|;&\n]*?\s-C\s+(\"[^\"]+\"|'[^']+'|\S+)")
|
|
115
|
+
_CD_RE = re.compile(r"^\s*cd\s+(\"[^\"]+\"|'[^']+'|\S+)\s*(?:&&|;)")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def target_cwd(tool: str, inp: dict[str, Any], cwd: str) -> str:
|
|
119
|
+
"""The repository a tool call is about, which is not always the one the session started in.
|
|
120
|
+
|
|
121
|
+
The record lives in the repository the change is about. A session opened in one checkout that edits a file
|
|
122
|
+
in another, or runs `git -C ../other commit` or `cd other && git commit`, is gated by that other repository's
|
|
123
|
+
policy and recorded in its state, notes and trailers; the replay of three engineers' sessions found 28% of
|
|
124
|
+
edits landing outside the session's own checkout, and until 0.25 every one of those was recorded against the
|
|
125
|
+
wrong repository or not at all. Anything that is not inside another repository falls back to the session's cwd.
|
|
126
|
+
"""
|
|
127
|
+
cand: str | None = None
|
|
128
|
+
if tool in EDIT_TOOLS:
|
|
129
|
+
fp = str(inp.get("file_path") or inp.get("notebook_path") or "")
|
|
130
|
+
if fp:
|
|
131
|
+
cand = os.path.dirname(fp if os.path.isabs(fp) else os.path.join(cwd, fp))
|
|
132
|
+
elif tool == "Bash":
|
|
133
|
+
cmd = inp.get("command") or ""
|
|
134
|
+
m = _GIT_C_RE.search(cmd) or _CD_RE.match(cmd)
|
|
135
|
+
if m:
|
|
136
|
+
p = os.path.expanduser(m.group(1).strip("'\""))
|
|
137
|
+
cand = p if os.path.isabs(p) else os.path.join(cwd, p)
|
|
138
|
+
while cand and not os.path.isdir(cand):
|
|
139
|
+
parent = os.path.dirname(cand)
|
|
140
|
+
cand = parent if parent != cand else None
|
|
141
|
+
if not cand:
|
|
142
|
+
return cwd
|
|
143
|
+
top, here = toplevel(cand), toplevel(cwd)
|
|
144
|
+
if not top or (here and os.path.realpath(top) == os.path.realpath(here)):
|
|
145
|
+
return cwd
|
|
146
|
+
return top
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _note_touched(session_cwd: str, target: str) -> None:
|
|
150
|
+
"""The session's own state remembers every other repository it reached, so the ledger can list them."""
|
|
151
|
+
if os.path.realpath(session_cwd) == os.path.realpath(target):
|
|
152
|
+
return
|
|
153
|
+
st = load_state(session_cwd)
|
|
154
|
+
touched = st.setdefault("repos_touched", [])
|
|
155
|
+
if target not in touched:
|
|
156
|
+
touched.append(target)
|
|
157
|
+
save_state(session_cwd, st)
|
|
158
|
+
|
|
159
|
+
|
|
113
160
|
def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
114
|
-
|
|
161
|
+
session_cwd = h.get("cwd") or os.getcwd()
|
|
115
162
|
tool = h.get("tool_name", "") or ""
|
|
116
163
|
inp = h.get("tool_input") or {}
|
|
164
|
+
cwd = target_cwd(tool, inp, session_cwd)
|
|
165
|
+
_note_touched(session_cwd, cwd)
|
|
117
166
|
try:
|
|
118
167
|
pol = load_policy(cwd, home)
|
|
119
168
|
except PolicyError as e:
|
|
@@ -233,6 +282,18 @@ def _record_prompt_approval(cwd: str, inp: dict[str, Any], home: str | None) ->
|
|
|
233
282
|
log_event(cwd, "prompt_approval_recorded", {"finding": n, "reason": d.reason[:120]})
|
|
234
283
|
|
|
235
284
|
|
|
285
|
+
def _count_elsewhere(session_cwd: str, other_top: str, abs_path: str) -> None:
|
|
286
|
+
"""Count an edit that landed in another repository against this session, by that repository's name."""
|
|
287
|
+
st = load_state(session_cwd)
|
|
288
|
+
key = os.path.basename(other_top.rstrip("/")) or other_top
|
|
289
|
+
ent = st.setdefault("edits_elsewhere", {}).setdefault(key, {"count": 0, "paths": []})
|
|
290
|
+
ent["count"] += 1
|
|
291
|
+
p = os.path.relpath(os.path.realpath(abs_path), os.path.realpath(other_top))
|
|
292
|
+
if p not in ent["paths"] and len(ent["paths"]) < 20:
|
|
293
|
+
ent["paths"].append(p)
|
|
294
|
+
save_state(session_cwd, st)
|
|
295
|
+
|
|
296
|
+
|
|
236
297
|
def _record_agent_blob(cwd: str, file_path: str) -> None:
|
|
237
298
|
"""After an agent edit, remember the blob id of what the agent wrote (repo-relative path -> blob).
|
|
238
299
|
|
|
@@ -250,14 +311,11 @@ def _record_agent_blob(cwd: str, file_path: str) -> None:
|
|
|
250
311
|
if rel.startswith(".."):
|
|
251
312
|
other = toplevel(os.path.dirname(abs_path))
|
|
252
313
|
if other and os.path.realpath(other) != os.path.realpath(top):
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if p not in ent["paths"] and len(ent["paths"]) < 20:
|
|
259
|
-
ent["paths"].append(p)
|
|
260
|
-
save_state(cwd, st)
|
|
314
|
+
_count_elsewhere(cwd, other, abs_path)
|
|
315
|
+
return
|
|
316
|
+
if is_credential_path(rel):
|
|
317
|
+
# the path says credential store: attribution loses one file, the object store never holds its content
|
|
318
|
+
log_event(cwd, "blob_skipped", {"reason": "credential-store path", "path": rel})
|
|
261
319
|
return
|
|
262
320
|
rc, blob, _ = git(["hash-object", "-w", "--", abs_path], top)
|
|
263
321
|
if rc != 0:
|
|
@@ -314,13 +372,18 @@ def _attribution(cwd: str, head: str, agent_blobs: dict[str, str], transcript_wr
|
|
|
314
372
|
|
|
315
373
|
|
|
316
374
|
def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
317
|
-
|
|
318
|
-
tool = h.get("tool_name")
|
|
375
|
+
tool = h.get("tool_name") or ""
|
|
319
376
|
inp = h.get("tool_input") or {}
|
|
377
|
+
session_cwd = h.get("cwd") or os.getcwd()
|
|
378
|
+
cwd = target_cwd(tool, inp, session_cwd)
|
|
320
379
|
if tool in EDIT_TOOLS:
|
|
321
380
|
fp = str(inp.get("file_path") or inp.get("notebook_path") or "")
|
|
322
381
|
if fp:
|
|
323
382
|
_record_agent_blob(cwd, fp)
|
|
383
|
+
if os.path.realpath(cwd) != os.path.realpath(session_cwd):
|
|
384
|
+
# gated and recorded in the other repository (0.25), and still counted here so this
|
|
385
|
+
# repository's card and note say the session reached elsewhere
|
|
386
|
+
_count_elsewhere(session_cwd, cwd, fp if os.path.isabs(fp) else os.path.join(session_cwd, fp))
|
|
324
387
|
try:
|
|
325
388
|
pol = load_policy(cwd, home)
|
|
326
389
|
except PolicyError:
|
|
@@ -414,6 +477,7 @@ def stop(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
|
414
477
|
"assistant_turns": summ["turns"],
|
|
415
478
|
"tool_calls": summ["tool_calls"],
|
|
416
479
|
"commits_during_session": commits,
|
|
480
|
+
"repos_touched": st.get("repos_touched") or [],
|
|
417
481
|
"last_stated_plan": summ["last_assistant_text"],
|
|
418
482
|
"usage": estimate(summ["usage"], _policy_or_empty(cwd, home)),
|
|
419
483
|
"subagents": summ["subagents"],
|
|
@@ -8,6 +8,7 @@ import shutil
|
|
|
8
8
|
import sys
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
from .safewrite import check_target, write_if_changed
|
|
11
12
|
from .state import git
|
|
12
13
|
|
|
13
14
|
GIT_HOOK = """#!/bin/sh
|
|
@@ -263,7 +264,7 @@ def _external_files(info: dict[str, Any]) -> tuple[str, str]:
|
|
|
263
264
|
)
|
|
264
265
|
|
|
265
266
|
|
|
266
|
-
def merge_external_settings(path: str, info: dict[str, Any], cmd_prefix: str) -> None:
|
|
267
|
+
def merge_external_settings(path: str, info: dict[str, Any], cmd_prefix: str, root: str | None = None) -> None:
|
|
267
268
|
inst = info.get("install") or {}
|
|
268
269
|
cur: dict[str, Any] = {}
|
|
269
270
|
if os.path.exists(path):
|
|
@@ -276,9 +277,12 @@ def merge_external_settings(path: str, info: dict[str, Any], cmd_prefix: str) ->
|
|
|
276
277
|
rendered = json.loads(json.dumps(entries).replace("__GITVOW__", cmd_prefix))
|
|
277
278
|
kept = [x for x in hooks.get(ev, []) if not _is_ours(x)]
|
|
278
279
|
hooks[ev] = kept + rendered
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
280
|
+
_write_json(path, cur, root)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _write_json(path: str, data: dict[str, Any], root: str | None) -> None:
|
|
284
|
+
target = check_target(path, root)
|
|
285
|
+
write_if_changed(target, json.dumps(data, indent=2))
|
|
282
286
|
|
|
283
287
|
|
|
284
288
|
def _is_ours(entry: dict[str, Any]) -> bool:
|
|
@@ -287,7 +291,7 @@ def _is_ours(entry: dict[str, Any]) -> bool:
|
|
|
287
291
|
return any(MARKER in (h.get("command") or "") for h in entry.get("hooks", []))
|
|
288
292
|
|
|
289
293
|
|
|
290
|
-
def merge_agent_settings(path: str, agent: str, cmd_prefix: str) -> None:
|
|
294
|
+
def merge_agent_settings(path: str, agent: str, cmd_prefix: str, root: str | None = None) -> None:
|
|
291
295
|
cur: dict[str, Any] = {}
|
|
292
296
|
if os.path.exists(path):
|
|
293
297
|
with open(path) as fh:
|
|
@@ -298,9 +302,7 @@ def merge_agent_settings(path: str, agent: str, cmd_prefix: str) -> None:
|
|
|
298
302
|
for ev, entries in _agent_entries(agent, cmd_prefix).items():
|
|
299
303
|
kept = [x for x in hooks.get(ev, []) if not _is_ours(x)]
|
|
300
304
|
hooks[ev] = kept + entries
|
|
301
|
-
|
|
302
|
-
with open(path, "w") as fh:
|
|
303
|
-
json.dump(cur, fh, indent=2)
|
|
305
|
+
_write_json(path, cur, root)
|
|
304
306
|
|
|
305
307
|
|
|
306
308
|
def unmerge_agent_settings(path: str) -> None:
|
|
@@ -322,7 +324,7 @@ def unmerge_agent_settings(path: str) -> None:
|
|
|
322
324
|
os.remove(path)
|
|
323
325
|
|
|
324
326
|
|
|
325
|
-
def merge_settings(path: str, cmd_prefix: str) -> None:
|
|
327
|
+
def merge_settings(path: str, cmd_prefix: str, root: str | None = None) -> None:
|
|
326
328
|
cur: dict[str, Any] = {}
|
|
327
329
|
if os.path.exists(path):
|
|
328
330
|
with open(path) as fh:
|
|
@@ -331,9 +333,7 @@ def merge_settings(path: str, cmd_prefix: str) -> None:
|
|
|
331
333
|
for ev, entries in _hook_entries(cmd_prefix).items():
|
|
332
334
|
kept = [x for x in hooks.get(ev, []) if not any(MARKER in (h.get("command") or "") for h in x.get("hooks", []))]
|
|
333
335
|
hooks[ev] = kept + entries
|
|
334
|
-
|
|
335
|
-
with open(path, "w") as fh:
|
|
336
|
-
json.dump(cur, fh, indent=2)
|
|
336
|
+
_write_json(path, cur, root)
|
|
337
337
|
|
|
338
338
|
|
|
339
339
|
def unmerge_settings(path: str) -> None:
|
|
@@ -497,13 +497,13 @@ def stale_git_hooks(dirpath: str) -> list[str]:
|
|
|
497
497
|
return out
|
|
498
498
|
|
|
499
499
|
|
|
500
|
-
def _write_git_hook(dirpath: str) -> str:
|
|
500
|
+
def _write_git_hook(dirpath: str, root: str | None = None) -> str:
|
|
501
|
+
"""Write the four hooks. Bytes that are already current are left alone, so a rerun does not move the
|
|
502
|
+
install stamp `status` reads; inside a repository every target is preflighted (safewrite)."""
|
|
501
503
|
os.makedirs(dirpath, exist_ok=True)
|
|
502
504
|
for name, body in GIT_HOOK_BODIES.items():
|
|
503
|
-
p = os.path.join(dirpath, name)
|
|
504
|
-
|
|
505
|
-
fh.write(body)
|
|
506
|
-
os.chmod(p, 0o755) # noqa: S103 # nosec B103 - git runs hooks as the invoking user; must be executable
|
|
505
|
+
p = check_target(os.path.join(dirpath, name), root)
|
|
506
|
+
write_if_changed(p, body, mode=0o755) # nosec B103 - git runs hooks as the invoking user
|
|
507
507
|
return os.path.join(dirpath, "prepare-commit-msg")
|
|
508
508
|
|
|
509
509
|
|
|
@@ -588,15 +588,15 @@ def install_repo(repo: str, cmd_prefix: str = "gitvow", agent: str = "claude") -
|
|
|
588
588
|
pol = os.path.join(base, "policy.json")
|
|
589
589
|
if not os.path.exists(pol):
|
|
590
590
|
shutil.copy(DEFAULT_POLICY_PATH, pol)
|
|
591
|
-
_write_git_hook(os.path.join(base, "git-hooks"))
|
|
591
|
+
_write_git_hook(os.path.join(base, "git-hooks"), root=repo)
|
|
592
592
|
ext = None if agent in AGENT_FILES else _external(agent)
|
|
593
593
|
repo_rel = AGENT_FILES[agent][1] if agent in AGENT_FILES else _external_files(ext[1])[1] # type: ignore[index]
|
|
594
594
|
if agent == "claude":
|
|
595
|
-
merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix)
|
|
595
|
+
merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix, root=repo)
|
|
596
596
|
elif ext:
|
|
597
|
-
merge_external_settings(os.path.join(repo, repo_rel), ext[1], cmd_prefix)
|
|
597
|
+
merge_external_settings(os.path.join(repo, repo_rel), ext[1], cmd_prefix, root=repo)
|
|
598
598
|
else:
|
|
599
|
-
merge_agent_settings(os.path.join(repo, repo_rel), agent, cmd_prefix)
|
|
599
|
+
merge_agent_settings(os.path.join(repo, repo_rel), agent, cmd_prefix, root=repo)
|
|
600
600
|
git(["config", "core.hooksPath", ".gitvow/git-hooks"], repo)
|
|
601
601
|
_set_notes_config(repo, [])
|
|
602
602
|
_exclude_local_sinks(repo)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Paths that hold credentials, kept out of everything gitvow stores.
|
|
2
|
+
|
|
3
|
+
Redaction works on values and is best effort by construction. This list works on paths and is exact: a file
|
|
4
|
+
whose path says it is a credential store is never snapshotted, never kept as an attribution blob, and never
|
|
5
|
+
quoted, whatever its content. It is loaded *after* the repository's own settings and only ever adds
|
|
6
|
+
exclusions, so a policy can extend it and cannot switch it off; a repository that wants a checked-in
|
|
7
|
+
`.env.example` captured renames it, which is the honest fix.
|
|
8
|
+
|
|
9
|
+
Classification is by path, never by content: a secret in `deploy/prod-values.yaml` is not covered here and is
|
|
10
|
+
the redaction layer's problem. Public halves (`.crt`, `.cer`, `.pub`) are deliberately not listed, and source
|
|
11
|
+
under `internal/secrets/` stays visible because only configuration-shaped files under a `secrets/` or
|
|
12
|
+
`credentials/` directory are excluded.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import fnmatch
|
|
18
|
+
import posixpath
|
|
19
|
+
|
|
20
|
+
# basenames, matched case-insensitively at any depth
|
|
21
|
+
BASENAMES: tuple[str, ...] = (
|
|
22
|
+
".env",
|
|
23
|
+
".env.*",
|
|
24
|
+
".envrc",
|
|
25
|
+
".npmrc",
|
|
26
|
+
".netrc",
|
|
27
|
+
"_netrc",
|
|
28
|
+
".pgpass",
|
|
29
|
+
".htpasswd",
|
|
30
|
+
".pypirc",
|
|
31
|
+
".dockercfg",
|
|
32
|
+
".boto",
|
|
33
|
+
".git-credentials",
|
|
34
|
+
"credentials",
|
|
35
|
+
"credentials.json",
|
|
36
|
+
"credentials.yml",
|
|
37
|
+
"credentials.yaml",
|
|
38
|
+
"credentials.ini",
|
|
39
|
+
"credentials.toml",
|
|
40
|
+
"credentials.tfrc.json",
|
|
41
|
+
"application_default_credentials.json",
|
|
42
|
+
"secrets.json",
|
|
43
|
+
"secrets.yml",
|
|
44
|
+
"secrets.yaml",
|
|
45
|
+
"secrets.ini",
|
|
46
|
+
"secrets.toml",
|
|
47
|
+
"id_rsa",
|
|
48
|
+
"id_dsa",
|
|
49
|
+
"id_ecdsa",
|
|
50
|
+
"id_ed25519",
|
|
51
|
+
)
|
|
52
|
+
# suffixes for key material and encrypted stores
|
|
53
|
+
SUFFIXES: tuple[str, ...] = (
|
|
54
|
+
".pem",
|
|
55
|
+
".key",
|
|
56
|
+
".pfx",
|
|
57
|
+
".p12",
|
|
58
|
+
".pkcs12",
|
|
59
|
+
".jks",
|
|
60
|
+
".keystore",
|
|
61
|
+
".truststore",
|
|
62
|
+
".ppk",
|
|
63
|
+
".kdbx",
|
|
64
|
+
".asc",
|
|
65
|
+
".gpg",
|
|
66
|
+
)
|
|
67
|
+
# well-known relative locations, matched as the trailing path components
|
|
68
|
+
LOCATIONS: tuple[str, ...] = (".docker/config.json", ".kube/config", ".aws/credentials")
|
|
69
|
+
# configuration-shaped files under a directory named secrets/ or credentials/, at any depth
|
|
70
|
+
SECRET_DIRS: tuple[str, ...] = ("secrets", "credentials")
|
|
71
|
+
CONFIG_SUFFIXES: tuple[str, ...] = (
|
|
72
|
+
".yaml",
|
|
73
|
+
".yml",
|
|
74
|
+
".json",
|
|
75
|
+
".ini",
|
|
76
|
+
".toml",
|
|
77
|
+
".cfg",
|
|
78
|
+
".conf",
|
|
79
|
+
".properties",
|
|
80
|
+
".txt",
|
|
81
|
+
".enc",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_credential_path(path: str) -> bool:
|
|
86
|
+
"""True when `path` (repository-relative or absolute, any separator) names a credential store."""
|
|
87
|
+
p = path.replace("\\", "/").strip("/").lower()
|
|
88
|
+
if not p:
|
|
89
|
+
return False
|
|
90
|
+
base = posixpath.basename(p)
|
|
91
|
+
if any(fnmatch.fnmatchcase(base, pat) for pat in BASENAMES):
|
|
92
|
+
return True
|
|
93
|
+
if base.endswith(SUFFIXES):
|
|
94
|
+
return True
|
|
95
|
+
if any(p == loc or p.endswith("/" + loc) for loc in LOCATIONS):
|
|
96
|
+
return True
|
|
97
|
+
parts = p.split("/")
|
|
98
|
+
return len(parts) > 1 and any(d in SECRET_DIRS for d in parts[:-1]) and base.endswith(CONFIG_SUFFIXES)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def pathspec_excludes() -> list[str]:
|
|
102
|
+
"""The same list as git pathspec globs, for snapshots. Basenames and suffixes at any depth."""
|
|
103
|
+
out: list[str] = []
|
|
104
|
+
for pat in BASENAMES:
|
|
105
|
+
out += [pat, f"**/{pat}"]
|
|
106
|
+
for suf in SUFFIXES:
|
|
107
|
+
out += [f"*{suf}", f"**/*{suf}"]
|
|
108
|
+
for loc in LOCATIONS:
|
|
109
|
+
out += [loc, f"**/{loc}"]
|
|
110
|
+
for d in SECRET_DIRS:
|
|
111
|
+
for suf in CONFIG_SUFFIXES:
|
|
112
|
+
out += [f"{d}/**/*{suf}", f"**/{d}/**/*{suf}", f"{d}/*{suf}", f"**/{d}/*{suf}"]
|
|
113
|
+
return out
|
|
@@ -490,7 +490,10 @@ def write_section(path: str, block: str) -> str:
|
|
|
490
490
|
action = "added"
|
|
491
491
|
else:
|
|
492
492
|
return "nothing to write"
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
493
|
+
from .safewrite import check_target, write_if_changed
|
|
494
|
+
from .state import toplevel
|
|
495
|
+
|
|
496
|
+
target = check_target(path, toplevel(os.path.dirname(path) or "."))
|
|
497
|
+
if not write_if_changed(target, new):
|
|
498
|
+
return f"managed section in {path} already current"
|
|
496
499
|
return f"{action} managed section in {path}"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Writes into a repository that a hostile checkout cannot redirect.
|
|
2
|
+
|
|
3
|
+
`install`, the rules block and the claims block all write files a repository can pre-seed: `.claude/settings.json`,
|
|
4
|
+
`.gitvow/git-hooks/*`, `CLAUDE.md`, `AGENTS.md`. A committed symlink at one of those paths could aim the write at
|
|
5
|
+
`.git/config`, a hook, or a file outside the checkout. Every such write goes through `check_target` first, and
|
|
6
|
+
through `write_if_changed`, so a rerun that changes nothing leaves the file's bytes and its mtime alone (the
|
|
7
|
+
install stamp `status` reads is a hook's mtime, so a rewrite would hide agent commits made before the rerun).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsafeTargetError(Exception):
|
|
17
|
+
"""The write was refused; the message says why and nothing was written."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _real(p: str) -> str:
|
|
21
|
+
return os.path.realpath(p)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _inside(child: str, parent: str) -> bool:
|
|
25
|
+
child, parent = _real(child), _real(parent)
|
|
26
|
+
return child == parent or child.startswith(parent.rstrip(os.sep) + os.sep)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _git_dir_of(root: str) -> str | None:
|
|
30
|
+
from .state import git_dir
|
|
31
|
+
|
|
32
|
+
return git_dir(root)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_target(path: str, root: str | None = None) -> str:
|
|
36
|
+
"""Refuse a target that is not an ordinary file location inside `root` (when given).
|
|
37
|
+
|
|
38
|
+
Refused: a path whose resolution leaves `root`; a target inside the repository's git directory (recognised
|
|
39
|
+
through `git rev-parse --git-dir`, so a relocated one counts); a target that exists and is not a regular
|
|
40
|
+
file; a regular file with more than one name (hard link), because an inode's other names cannot be read back
|
|
41
|
+
and one of them may be `.git/config`. Returns the resolved path to write.
|
|
42
|
+
"""
|
|
43
|
+
resolved = _real(path)
|
|
44
|
+
if root is not None:
|
|
45
|
+
if not _inside(resolved, root):
|
|
46
|
+
raise UnsafeTargetError(f"{path} resolves outside {root}; not written")
|
|
47
|
+
gd = _git_dir_of(root)
|
|
48
|
+
if gd and _inside(resolved, gd):
|
|
49
|
+
raise UnsafeTargetError(f"{path} resolves into the git directory ({gd}); not written")
|
|
50
|
+
try:
|
|
51
|
+
st = os.lstat(resolved)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
return resolved
|
|
54
|
+
if not stat.S_ISREG(st.st_mode):
|
|
55
|
+
raise UnsafeTargetError(f"{path} exists and is not a regular file; not written")
|
|
56
|
+
if st.st_nlink > 1:
|
|
57
|
+
raise UnsafeTargetError(f"{path} has {st.st_nlink} names (hard link); not written")
|
|
58
|
+
return resolved
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def write_if_changed(path: str, content: str, mode: int | None = None) -> bool:
|
|
62
|
+
"""Write `content` to `path` only when the bytes differ. Returns True when a write happened."""
|
|
63
|
+
try:
|
|
64
|
+
with open(path) as fh:
|
|
65
|
+
if fh.read() == content:
|
|
66
|
+
if mode is not None and (os.stat(path).st_mode & 0o777) != mode:
|
|
67
|
+
os.chmod(path, mode)
|
|
68
|
+
return False
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
72
|
+
with open(path, "w") as fh:
|
|
73
|
+
fh.write(content)
|
|
74
|
+
if mode is not None:
|
|
75
|
+
os.chmod(path, mode)
|
|
76
|
+
return True
|
|
@@ -22,8 +22,19 @@ DEFAULTS: dict[str, Any] = {
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def settings(pol: dict[str, Any] | None) -> dict[str, Any]:
|
|
25
|
+
"""Policy settings over the defaults. `exclude` is a union: the built-in credential-store paths (paths.py)
|
|
26
|
+
and the defaults are always excluded, and a policy can only add to them."""
|
|
27
|
+
from .paths import pathspec_excludes
|
|
28
|
+
|
|
25
29
|
out = dict(DEFAULTS)
|
|
26
|
-
|
|
30
|
+
user = dict((pol or {}).get("snapshots") or {})
|
|
31
|
+
extra = user.pop("exclude", None) or []
|
|
32
|
+
out.update(user)
|
|
33
|
+
seen: list[str] = []
|
|
34
|
+
for pat in [*DEFAULTS["exclude"], *pathspec_excludes(), *extra]:
|
|
35
|
+
if pat not in seen:
|
|
36
|
+
seen.append(pat)
|
|
37
|
+
out["exclude"] = seen
|
|
27
38
|
return out
|
|
28
39
|
|
|
29
40
|
|
|
@@ -22,7 +22,7 @@ from .install import (
|
|
|
22
22
|
)
|
|
23
23
|
from .policy import PolicyError, load_policy
|
|
24
24
|
from .redact import RedactionError, load_rules
|
|
25
|
-
from .state import git, git_dir, toplevel
|
|
25
|
+
from .state import git, git_dir, load_state, toplevel
|
|
26
26
|
|
|
27
27
|
Check = tuple[str, str, str] # (state: ok|fail|note, what, fix or detail)
|
|
28
28
|
|
|
@@ -222,3 +222,52 @@ def render(checks: list[Check]) -> str:
|
|
|
222
222
|
else:
|
|
223
223
|
lines += ["", f"The record is live here.{tail}"]
|
|
224
224
|
return "\n".join(lines) + "\n"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def badge(cwd: str) -> str:
|
|
228
|
+
"""One line for an agent's status bar: what the record holds for the session active in this repository.
|
|
229
|
+
|
|
230
|
+
Read from the repository's session state and hook log only, so it costs one file read and one scan and
|
|
231
|
+
makes no network call. Counts are labelled for what they are: `decided` is findings a person answered,
|
|
232
|
+
`recorded` is findings the gate put on the card or observed without asking anyone, `open` is findings
|
|
233
|
+
still waiting for an answer, `asked` is the questions a person actually got, `denied` is refused calls.
|
|
234
|
+
A repository with no session says so instead of printing zeros that look like a clean record.
|
|
235
|
+
"""
|
|
236
|
+
from . import __version__
|
|
237
|
+
|
|
238
|
+
top = toplevel(cwd)
|
|
239
|
+
if not top:
|
|
240
|
+
return f"[gitvow {__version__}] not a repository"
|
|
241
|
+
st = load_state(top)
|
|
242
|
+
sid = st.get("session_id")
|
|
243
|
+
if not sid:
|
|
244
|
+
return f"[gitvow {__version__}] no session recorded here"
|
|
245
|
+
findings = list(st.get("findings") or []) + list((st.get("last_commit") or {}).get("findings") or [])
|
|
246
|
+
decided = sum(1 for f in findings if f.get("decision"))
|
|
247
|
+
open_ = sum(1 for f in findings if not f.get("decision") and not f.get("observe") and not f.get("immediate"))
|
|
248
|
+
asked = denied = recorded = 0
|
|
249
|
+
gd = git_dir(top)
|
|
250
|
+
log = os.path.join(gd, "gitvow-hooks.log") if gd else ""
|
|
251
|
+
if log and os.path.exists(log):
|
|
252
|
+
import json
|
|
253
|
+
|
|
254
|
+
with open(log) as fh:
|
|
255
|
+
for ln in fh:
|
|
256
|
+
try:
|
|
257
|
+
ev = json.loads(ln)
|
|
258
|
+
except ValueError:
|
|
259
|
+
continue
|
|
260
|
+
if ev.get("session_id") not in (None, sid):
|
|
261
|
+
continue
|
|
262
|
+
k = ev.get("kind")
|
|
263
|
+
if k in ("confirm_required", "card"):
|
|
264
|
+
asked += 1
|
|
265
|
+
elif k in ("finding", "observed"):
|
|
266
|
+
recorded += 1
|
|
267
|
+
elif k == "blocked":
|
|
268
|
+
denied += 1
|
|
269
|
+
parts = [f"{decided} decided", f"{recorded} recorded", f"{open_} open", f"{asked} asked", f"{denied} denied"]
|
|
270
|
+
touched = len(st.get("repos_touched") or [])
|
|
271
|
+
if touched:
|
|
272
|
+
parts.append(f"{touched} other repo{'s' if touched != 1 else ''}")
|
|
273
|
+
return f"[gitvow {__version__}] " + " · ".join(parts)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.0
|
|
4
4
|
Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
|
|
5
5
|
Author-email: Nikhil Bora <nikhil@wirevow.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -13,6 +13,7 @@ src/gitvow/digest.py
|
|
|
13
13
|
src/gitvow/export.py
|
|
14
14
|
src/gitvow/install.py
|
|
15
15
|
src/gitvow/pack.py
|
|
16
|
+
src/gitvow/paths.py
|
|
16
17
|
src/gitvow/policy.py
|
|
17
18
|
src/gitvow/pricing.py
|
|
18
19
|
src/gitvow/proposals.py
|
|
@@ -21,6 +22,7 @@ src/gitvow/recall.py
|
|
|
21
22
|
src/gitvow/redact.py
|
|
22
23
|
src/gitvow/report.py
|
|
23
24
|
src/gitvow/rules.py
|
|
25
|
+
src/gitvow/safewrite.py
|
|
24
26
|
src/gitvow/scan.py
|
|
25
27
|
src/gitvow/selftest.py
|
|
26
28
|
src/gitvow/snapshots.py
|
|
@@ -44,6 +46,7 @@ tests/test_digest.py
|
|
|
44
46
|
tests/test_e2e_globalopt.py
|
|
45
47
|
tests/test_export.py
|
|
46
48
|
tests/test_external_adapter.py
|
|
49
|
+
tests/test_hardening.py
|
|
47
50
|
tests/test_hooks.py
|
|
48
51
|
tests/test_install_cli.py
|
|
49
52
|
tests/test_pack.py
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""0.26: credential-store paths, safe writes, write-if-changed, the status badge."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from gitvow import paths, snapshots
|
|
10
|
+
from gitvow.hooks import post_tool_use, pre_tool_use, session_start
|
|
11
|
+
from gitvow.install import _write_git_hook, install_repo
|
|
12
|
+
from gitvow.safewrite import UnsafeTargetError, check_target, write_if_changed
|
|
13
|
+
from gitvow.status import badge
|
|
14
|
+
|
|
15
|
+
FORCE_PUSH = "git push " + "--force" # split so the gate reading this repository's own tool calls does not trip
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize(
|
|
19
|
+
"p",
|
|
20
|
+
[
|
|
21
|
+
".env",
|
|
22
|
+
"app/.env.production",
|
|
23
|
+
"deploy/.npmrc",
|
|
24
|
+
"~/.aws/credentials",
|
|
25
|
+
"ops/credentials.json",
|
|
26
|
+
"k8s/secrets/db.yaml",
|
|
27
|
+
"infra/credentials/prod.toml",
|
|
28
|
+
"certs/server.pem",
|
|
29
|
+
"keys/id_ed25519",
|
|
30
|
+
".docker/config.json",
|
|
31
|
+
"home/.kube/config",
|
|
32
|
+
"vault.kdbx",
|
|
33
|
+
],
|
|
34
|
+
)
|
|
35
|
+
def test_credential_paths_are_recognised(p):
|
|
36
|
+
assert paths.is_credential_path(p)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize(
|
|
40
|
+
"p", ["deploy/prod-values.yaml", "internal/secrets/store.go", "certs/server.crt", "id_ed25519.pub", "README.md", ""]
|
|
41
|
+
)
|
|
42
|
+
def test_ordinary_paths_are_not(p):
|
|
43
|
+
assert not paths.is_credential_path(p)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_snapshot_exclusions_are_a_union_the_policy_cannot_shrink():
|
|
47
|
+
cfg = snapshots.settings({"snapshots": {"exclude": ["*.sqlite"]}})
|
|
48
|
+
assert "*.sqlite" in cfg["exclude"] and ".env*" in cfg["exclude"] and "**/*.pem" in cfg["exclude"]
|
|
49
|
+
assert snapshots.settings({"snapshots": {"exclude": []}})["exclude"] == snapshots.settings(None)["exclude"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_agent_blob_is_never_kept_for_a_credential_path(repo, home, payload):
|
|
53
|
+
session_start(payload("SessionStart"), str(home))
|
|
54
|
+
(repo / ".npmrc").write_text("//registry/:_authToken=abc\n")
|
|
55
|
+
(repo / "b.txt").write_text("b\n")
|
|
56
|
+
post_tool_use(payload("PostToolUse", "Write", {"file_path": str(repo / ".npmrc")}), str(home))
|
|
57
|
+
post_tool_use(payload("PostToolUse", "Write", {"file_path": str(repo / "b.txt")}), str(home))
|
|
58
|
+
st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
|
|
59
|
+
assert set(st["agent_blobs"]) == {"b.txt"}
|
|
60
|
+
assert '"kind": "blob_skipped"' in (repo / ".git" / "gitvow-hooks.log").read_text()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_check_target_refuses_escapes_git_dir_and_hard_links(repo, tmp_path):
|
|
64
|
+
outside = tmp_path / "outside.json"
|
|
65
|
+
outside.write_text("{}")
|
|
66
|
+
(repo / ".claude").mkdir()
|
|
67
|
+
os.symlink(outside, repo / ".claude" / "settings.json")
|
|
68
|
+
with pytest.raises(UnsafeTargetError, match="outside"):
|
|
69
|
+
check_target(str(repo / ".claude" / "settings.json"), str(repo))
|
|
70
|
+
os.remove(repo / ".claude" / "settings.json")
|
|
71
|
+
os.symlink(repo / ".git" / "config", repo / ".claude" / "settings.json")
|
|
72
|
+
with pytest.raises(UnsafeTargetError, match="git directory"):
|
|
73
|
+
check_target(str(repo / ".claude" / "settings.json"), str(repo))
|
|
74
|
+
os.remove(repo / ".claude" / "settings.json")
|
|
75
|
+
os.link(repo / ".git" / "config", repo / "CLAUDE.md")
|
|
76
|
+
with pytest.raises(UnsafeTargetError, match="hard link"):
|
|
77
|
+
check_target(str(repo / "CLAUDE.md"), str(repo))
|
|
78
|
+
os.remove(repo / "CLAUDE.md")
|
|
79
|
+
(repo / "adir").mkdir()
|
|
80
|
+
with pytest.raises(UnsafeTargetError, match="not a regular file"):
|
|
81
|
+
check_target(str(repo / "adir"), str(repo))
|
|
82
|
+
assert check_target(str(repo / "AGENTS.md"), str(repo)) == os.path.realpath(repo / "AGENTS.md")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_repo_install_refuses_a_redirected_settings_file(repo, home, tmp_path):
|
|
86
|
+
outside = tmp_path / "elsewhere.json"
|
|
87
|
+
outside.write_text("{}")
|
|
88
|
+
(repo / ".claude").mkdir()
|
|
89
|
+
os.symlink(outside, repo / ".claude" / "settings.json")
|
|
90
|
+
with pytest.raises(UnsafeTargetError):
|
|
91
|
+
install_repo(str(repo))
|
|
92
|
+
assert outside.read_text() == "{}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_rerun_leaves_unchanged_hooks_untouched(repo, home):
|
|
96
|
+
d = str(repo / ".gitvow" / "git-hooks")
|
|
97
|
+
stamp = _write_git_hook(d, root=str(repo))
|
|
98
|
+
before = os.stat(stamp).st_mtime_ns
|
|
99
|
+
time.sleep(0.01)
|
|
100
|
+
_write_git_hook(d, root=str(repo))
|
|
101
|
+
assert os.stat(stamp).st_mtime_ns == before
|
|
102
|
+
assert oct(os.stat(stamp).st_mode & 0o777) == "0o755"
|
|
103
|
+
assert write_if_changed(str(repo / "x.txt"), "a") and not write_if_changed(str(repo / "x.txt"), "a")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_badge_reads_the_session_state_and_log(repo, home, payload, tmp_path):
|
|
107
|
+
assert badge(str(tmp_path)).endswith("not a repository")
|
|
108
|
+
assert badge(str(repo)).endswith("no session recorded here")
|
|
109
|
+
session_start(payload("SessionStart"), str(home))
|
|
110
|
+
(repo / "values" / "production-in").mkdir(parents=True)
|
|
111
|
+
target = repo / "values" / "production-in" / "app.yaml"
|
|
112
|
+
target.write_text("replicas: 1\n")
|
|
113
|
+
pre_tool_use(payload("PreToolUse", "Edit", {"file_path": str(target)}), str(home))
|
|
114
|
+
code, _ = pre_tool_use(payload("PreToolUse", "Bash", {"command": FORCE_PUSH}), str(home))
|
|
115
|
+
assert code == 2
|
|
116
|
+
line = badge(str(repo))
|
|
117
|
+
assert "0 decided" in line and "1 recorded" in line and "1 open" in line and "1 denied" in line
|
|
@@ -262,3 +262,67 @@ def test_plumbing_that_merely_starts_with_commit_is_not_a_commit():
|
|
|
262
262
|
|
|
263
263
|
for cmd in ("git commit-tree $t", "git commit-graph write", "git log --format=%H", "gitk commitish"):
|
|
264
264
|
assert not COMMIT_RE.search(cmd), cmd
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _other_repo(tmp_path, home, name="other"):
|
|
268
|
+
o = tmp_path / name
|
|
269
|
+
o.mkdir()
|
|
270
|
+
git(o, "init", "-q")
|
|
271
|
+
git(o, "config", "user.email", "t@test")
|
|
272
|
+
git(o, "config", "user.name", "t")
|
|
273
|
+
(o / "b.txt").write_text("b\n")
|
|
274
|
+
git(o, "add", "b.txt")
|
|
275
|
+
git(o, "commit", "-qm", "init")
|
|
276
|
+
_commit_hooked(o, home)
|
|
277
|
+
return o
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_edit_into_another_repository_is_gated_and_recorded_there(repo, home, payload, tmp_path):
|
|
281
|
+
other = _other_repo(tmp_path, home)
|
|
282
|
+
(other / "values" / "production-in").mkdir(parents=True)
|
|
283
|
+
target = other / "values" / "production-in" / "app.yaml"
|
|
284
|
+
target.write_text("replicas: 1\n")
|
|
285
|
+
session_start(payload("SessionStart"), str(home))
|
|
286
|
+
code, _ = pre_tool_use(payload("PreToolUse", "Edit", {"file_path": str(target)}), str(home))
|
|
287
|
+
assert code == 0 # a commit-tier finding: recorded, the edit runs
|
|
288
|
+
there = json.loads((other / ".git" / "gitvow-session.json").read_text())
|
|
289
|
+
assert [f["reason"] for f in there["findings"]] == ["edits production GitOps values"]
|
|
290
|
+
here = json.loads((repo / ".git" / "gitvow-session.json").read_text())
|
|
291
|
+
assert not here.get("findings") and here["repos_touched"] == [str(other)]
|
|
292
|
+
post_tool_use(payload("PostToolUse", "Edit", {"file_path": str(target)}), str(home))
|
|
293
|
+
assert (
|
|
294
|
+
"values/production-in/app.yaml"
|
|
295
|
+
in json.loads((other / ".git" / "gitvow-session.json").read_text())["agent_blobs"]
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_git_dash_c_commit_in_another_repository_gets_trailers_and_note(repo, home, payload, tmp_path, transcript):
|
|
300
|
+
other = _other_repo(tmp_path, home)
|
|
301
|
+
session_start(payload("SessionStart"), str(home))
|
|
302
|
+
cmd = {"command": f"git -C {other} commit -am x"}
|
|
303
|
+
assert pre_tool_use(payload("PreToolUse", "Bash", cmd, transcript), str(home))[0] == 0
|
|
304
|
+
assert "pending_commit" in json.loads((other / ".git" / "gitvow-session.json").read_text())
|
|
305
|
+
assert "pending_commit" not in json.loads((repo / ".git" / "gitvow-session.json").read_text())
|
|
306
|
+
(other / "b.txt").write_text("changed\n")
|
|
307
|
+
git(other, "commit", "-qam", "feature")
|
|
308
|
+
assert "Gitvow-Session: sess-1" in git(other, "log", "-1", "--format=%B")
|
|
309
|
+
code, msg = post_tool_use(payload("PostToolUse", "Bash", cmd, transcript), str(home))
|
|
310
|
+
assert code == 0 and "session note attached" in msg
|
|
311
|
+
assert git(other, "notes", "--ref=gitvow/sess-1", "show", "HEAD").startswith("gitvow-session")
|
|
312
|
+
assert git(repo, "for-each-ref", "refs/notes/") == ""
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_cd_then_commit_targets_that_repository(repo, home, payload, tmp_path):
|
|
316
|
+
other = _other_repo(tmp_path, home)
|
|
317
|
+
session_start(payload("SessionStart"), str(home))
|
|
318
|
+
pre_tool_use(payload("PreToolUse", "Bash", {"command": f"cd {other} && git commit -m x"}), str(home))
|
|
319
|
+
assert "pending_commit" in json.loads((other / ".git" / "gitvow-session.json").read_text())
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_paths_outside_any_repository_fall_back_to_the_session(repo, home, payload, tmp_path):
|
|
323
|
+
from gitvow.hooks import target_cwd
|
|
324
|
+
|
|
325
|
+
assert target_cwd("Edit", {"file_path": str(tmp_path / "nowhere" / "x.txt")}, str(repo)) == str(repo)
|
|
326
|
+
assert target_cwd("Edit", {"file_path": str(repo / "sub" / "new.txt")}, str(repo)) == str(repo)
|
|
327
|
+
assert target_cwd("Bash", {"command": "git commit -m x"}, str(repo)) == str(repo)
|
|
328
|
+
assert target_cwd("Bash", {"command": f"git -C {repo} commit -m x"}, str(repo)) == str(repo)
|
|
@@ -212,10 +212,7 @@ def test_edits_in_another_checkout_are_counted_and_named(repo, home, payload, tr
|
|
|
212
212
|
assert st["edits_elsewhere"] == {"other-repo": {"count": 2, "paths": ["svc/main.go"]}}
|
|
213
213
|
assert "here.txt" in st["agent_blobs"] and "svc/main.go" not in st["agent_blobs"]
|
|
214
214
|
card = dec.card(str(repo), for_agent=False)
|
|
215
|
-
assert
|
|
216
|
-
"Edits outside this repository: 2 in other-repo" in card
|
|
217
|
-
and "recorded in this repository's note, not in theirs" in card
|
|
218
|
-
)
|
|
215
|
+
assert "Edits outside this repository: 2 in other-repo" in card and "gated by that repository's own policy" in card
|
|
219
216
|
_hooks(repo)
|
|
220
217
|
git(repo, "add", "here.txt")
|
|
221
218
|
pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|