gitvow 0.26.0__tar.gz → 0.26.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {gitvow-0.26.0/src/gitvow.egg-info → gitvow-0.26.1}/PKG-INFO +1 -1
  2. {gitvow-0.26.0 → gitvow-0.26.1}/pyproject.toml +1 -1
  3. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/policy.py +104 -4
  4. {gitvow-0.26.0 → gitvow-0.26.1/src/gitvow.egg-info}/PKG-INFO +1 -1
  5. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow.egg-info/SOURCES.txt +1 -0
  6. gitvow-0.26.1/tests/test_command_position.py +67 -0
  7. {gitvow-0.26.0 → gitvow-0.26.1}/LICENSE +0 -0
  8. {gitvow-0.26.0 → gitvow-0.26.1}/README.md +0 -0
  9. {gitvow-0.26.0 → gitvow-0.26.1}/setup.cfg +0 -0
  10. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/__init__.py +0 -0
  11. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/__main__.py +0 -0
  12. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/adapters.py +0 -0
  13. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/claims.py +0 -0
  14. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/cli.py +0 -0
  15. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/collect.py +0 -0
  16. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/decisions.py +0 -0
  17. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/default_policy.json +0 -0
  18. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/digest.py +0 -0
  19. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/export.py +0 -0
  20. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/hooks/__init__.py +0 -0
  21. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/install.py +0 -0
  22. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/pack.py +0 -0
  23. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/paths.py +0 -0
  24. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/pricing.py +0 -0
  25. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/proposals.py +0 -0
  26. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/providers.py +0 -0
  27. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/recall.py +0 -0
  28. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/redact.py +0 -0
  29. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/report.py +0 -0
  30. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/rules.py +0 -0
  31. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/safewrite.py +0 -0
  32. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/scan.py +0 -0
  33. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/selftest.py +0 -0
  34. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/snapshots.py +0 -0
  35. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/state.py +0 -0
  36. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/status.py +0 -0
  37. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/sync.py +0 -0
  38. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/transcript.py +0 -0
  39. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow.egg-info/dependency_links.txt +0 -0
  40. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow.egg-info/entry_points.txt +0 -0
  41. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow.egg-info/requires.txt +0 -0
  42. {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow.egg-info/top_level.txt +0 -0
  43. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_adapters.py +0 -0
  44. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_claims.py +0 -0
  45. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_collect.py +0 -0
  46. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_cost.py +0 -0
  47. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_decisions.py +0 -0
  48. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_digest.py +0 -0
  49. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_e2e_globalopt.py +0 -0
  50. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_export.py +0 -0
  51. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_external_adapter.py +0 -0
  52. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_hardening.py +0 -0
  53. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_hooks.py +0 -0
  54. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_install_cli.py +0 -0
  55. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_pack.py +0 -0
  56. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_packaging.py +0 -0
  57. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_policy.py +0 -0
  58. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_proposals.py +0 -0
  59. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_providers.py +0 -0
  60. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_recall.py +0 -0
  61. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_redact.py +0 -0
  62. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_report.py +0 -0
  63. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_rules.py +0 -0
  64. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_scan.py +0 -0
  65. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_snapshots.py +0 -0
  66. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_status.py +0 -0
  67. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_sync.py +0 -0
  68. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_sync_http.py +0 -0
  69. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_tiers.py +0 -0
  70. {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_transcript_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.26.0
3
+ Version: 0.26.1
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitvow"
7
- version = "0.26.0"
7
+ version = "0.26.1"
8
8
  description = "Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -41,6 +41,104 @@ def rule_pattern(rule: dict[str, Any]) -> str:
41
41
  return rf"\b(?:{'|'.join(re.escape(p) for p in progs)}){OPTS}\s+(?:{verbs})\b"
42
42
 
43
43
 
44
+ # Words that precede the program without being it, and shells whose `-c` argument is a command line of its own.
45
+ _PREFIXES = frozenset({"sudo", "env", "time", "nohup", "exec", "command", "nice", "doas", "xargs"})
46
+ _PREFIX_WITH_ARG = frozenset({"timeout"}) # `timeout 30 kubectl …`
47
+ _SHELLS = frozenset({"sh", "bash", "zsh", "dash", "ksh"})
48
+ _HEREDOC = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?[^\n]*\n.*?\n\1[ \t]*(?=\n|$)", re.S)
49
+ _OPERATORS = frozenset({";", "&&", "||", "|", "&", "(", ")", "\n"})
50
+
51
+
52
+ def _segments(text: str) -> list[list[str]] | None:
53
+ """Each simple command in a shell line as its argv, quotes resolved, heredoc bodies dropped.
54
+
55
+ None when the line cannot be tokenised (an unbalanced quote): the caller then falls back to matching the
56
+ whole text, which fails toward asking.
57
+ """
58
+ body = _HEREDOC.sub("", text)
59
+ lex = shlex.shlex(body, posix=True, punctuation_chars=";&|()")
60
+ lex.whitespace_split = True
61
+ lex.commenters = ""
62
+ try:
63
+ tokens = list(lex)
64
+ except ValueError:
65
+ return None
66
+ out: list[list[str]] = []
67
+ cur: list[str] = []
68
+ for tok in tokens:
69
+ if tok in _OPERATORS or set(tok) <= set(";&|()"):
70
+ if cur:
71
+ out.append(cur)
72
+ cur = []
73
+ else:
74
+ cur.append(tok)
75
+ if cur:
76
+ out.append(cur)
77
+ return out
78
+
79
+
80
+ def _program_and_args(argv: list[str]) -> tuple[str, list[str]]:
81
+ """Strip `VAR=x` assignments and prefixes such as sudo, env, time, timeout N; return (program, arguments)."""
82
+ i = 0
83
+ while i < len(argv):
84
+ w = argv[i]
85
+ if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", w) or w in _PREFIXES:
86
+ i += 1
87
+ elif w in _PREFIX_WITH_ARG:
88
+ i += 2
89
+ elif w.startswith("-") and i > 0 and argv[i - 1] in _PREFIXES | _PREFIX_WITH_ARG:
90
+ i += 1 # an option to the prefix itself, e.g. `sudo -u root`, `env -i`
91
+ else:
92
+ break
93
+ if i >= len(argv):
94
+ return "", []
95
+ return os.path.basename(argv[i]), argv[i + 1 :]
96
+
97
+
98
+ def match_program_rule(rule: dict[str, Any], text: str) -> str | None:
99
+ """The program a `{program, verbs}` rule matches in **command position**, or None.
100
+
101
+ Until 0.26.1 these rules were a regex over the whole command line, so `gh issue create --body "see kubectl
102
+ exec"` was a cluster mutation and a commit message that mentioned `terraform apply` was an infrastructure
103
+ change. Now the line is split into simple commands and the program must be the word that runs: after any
104
+ `VAR=x`, `sudo`, `env`, `time`, `timeout N`, `nohup` or `xargs`, and inside a `sh -c "…"` argument, which is
105
+ a command line of its own. Quoted arguments of some other program, and heredoc bodies, are not commands and
106
+ are not matched. A line that cannot be tokenised falls back to the whole-text regex, failing toward asking.
107
+ """
108
+ progs = rule["program"] if isinstance(rule["program"], list) else [rule["program"]]
109
+ verbs = "|".join(rule["verbs"])
110
+ head = re.compile(rf"^{OPTS}\s+(?:{verbs})\b")
111
+ segs = _segments(text)
112
+ if segs is None:
113
+ m = re.search(rule_pattern(rule), text)
114
+ return re.split(r"\s", text[m.start() :].lstrip(), maxsplit=1)[0] if m else None
115
+ stack = list(segs)
116
+ while stack:
117
+ prog, args = _program_and_args(stack.pop())
118
+ if not prog:
119
+ continue
120
+ if prog in _SHELLS and "-c" in args:
121
+ inner = _segments(args[args.index("-c") + 1]) if args.index("-c") + 1 < len(args) else None
122
+ if inner is None:
123
+ if re.search(rule_pattern(rule), " ".join(args)):
124
+ return progs[0]
125
+ else:
126
+ stack.extend(inner)
127
+ continue
128
+ if prog in progs and head.match(" " + " ".join(args)):
129
+ return prog
130
+ return None
131
+
132
+
133
+ def match_rule(rule: dict[str, Any], text: str) -> tuple[str | None, re.Match[str] | None]:
134
+ """(program, match) for whichever form the rule takes. `pattern` rules are plain regex over the whole line."""
135
+ if "pattern" in rule:
136
+ m = re.search(rule["pattern"], text)
137
+ return (None, m) if m else (None, None)
138
+ prog = match_program_rule(rule, text)
139
+ return (prog, None) if prog else (None, None)
140
+
141
+
44
142
  @dataclass(frozen=True)
45
143
  class Decision:
46
144
  outcome: str # "allow" | "deny" | "confirm"
@@ -208,14 +306,16 @@ def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any], cwd: st
208
306
  findings: list[dict[str, Any]] = []
209
307
  if tool == "Bash":
210
308
  for r in pol.get("bash_deny", []):
211
- if re.search(rule_pattern(r), text):
309
+ prog, m = match_rule(r, text)
310
+ if prog or m:
212
311
  return Decision("deny", r.get("reason", "denied"), text)
213
312
  for r in pol.get("bash_confirm", []):
214
- m = re.search(rule_pattern(r), text)
215
- if m:
313
+ prog, m = match_rule(r, text)
314
+ if prog or m:
216
315
  when = r.get("when", "immediate")
217
316
  reason = r.get("reason", "needs confirmation")
218
- f = _finding("command", _command_subject(text, m, reason), "", reason, [text.strip()[:200]])
317
+ subject = f"{prog} ({reason})"[:120] if prog else _command_subject(text, m, reason) # type: ignore[arg-type]
318
+ f = _finding("command", subject, "", reason, [text.strip()[:200]])
219
319
  if when == "immediate":
220
320
  # The finding travels with the confirm so the hook can record it and a person can answer
221
321
  # it once for the session; the outcome is still an immediate stop.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.26.0
3
+ Version: 0.26.1
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -40,6 +40,7 @@ src/gitvow/hooks/__init__.py
40
40
  tests/test_adapters.py
41
41
  tests/test_claims.py
42
42
  tests/test_collect.py
43
+ tests/test_command_position.py
43
44
  tests/test_cost.py
44
45
  tests/test_decisions.py
45
46
  tests/test_digest.py
@@ -0,0 +1,67 @@
1
+ """0.26.1: `{program, verbs}` rules match the program in command position, not anywhere in the line."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from gitvow.policy import DEFAULT_POLICY_PATH, evaluate, match_program_rule
9
+
10
+ K = "kubectl"
11
+ T = "terraform"
12
+
13
+
14
+ @pytest.fixture
15
+ def pol():
16
+ return json.loads(Path(DEFAULT_POLICY_PATH).read_text())
17
+
18
+
19
+ @pytest.mark.parametrize(
20
+ "cmd",
21
+ [
22
+ f'gh issue create --title "sizing" --body "how to size: {K} exec into the pod, then {K} get --raw /metrics"',
23
+ f'git commit -m "docs: mention {K} delete for the runbook"',
24
+ f"echo '{T} apply is described in README' > notes.txt",
25
+ f"grep -rn '{K} exec' docs/",
26
+ f"cat <<'EOF' > runbook.md\n{K} delete pod x\n{T} destroy\nEOF",
27
+ f"python3 -c \"print('{K} scale')\"",
28
+ f"kubectl get pods # not: {K} delete",
29
+ ],
30
+ )
31
+ def test_mentions_inside_arguments_are_not_commands(pol, cmd):
32
+ assert evaluate(pol, "Bash", {"command": cmd}).outcome == "allow", cmd
33
+
34
+
35
+ @pytest.mark.parametrize(
36
+ "cmd,outcome",
37
+ [
38
+ (f"{K} delete pod x", "deny"),
39
+ (f"{K} --context prod -n ns delete pod x", "deny"),
40
+ (f"sudo {K} delete pod x", "deny"),
41
+ (f"KUBECONFIG=/tmp/k {K} delete pod x", "deny"),
42
+ (f"timeout 30 {K} delete pod x", "deny"),
43
+ (f"bash -c '{K} delete pod x'", "deny"),
44
+ (f'sh -c "cd /x && {K} --context prod delete pod y"', "deny"),
45
+ (f"echo pod x | xargs {K} delete pod", "deny"),
46
+ (f"ls; {K} delete pod x", "deny"),
47
+ (f"make build && {T} apply -auto-approve", "deny"),
48
+ (f"{K} apply -f a.yaml", "confirm"),
49
+ (f"{K} exec -it pod -- sh", "confirm"),
50
+ ("env FOO=1 helm upgrade rel chart", "confirm"),
51
+ ],
52
+ )
53
+ def test_commands_in_command_position_still_match(pol, cmd, outcome):
54
+ assert evaluate(pol, "Bash", {"command": cmd}).outcome == outcome, cmd
55
+
56
+
57
+ def test_unbalanced_quote_falls_back_to_the_whole_line(pol):
58
+ assert evaluate(pol, "Bash", {"command": f'{K} delete pod "x'}).outcome == "deny"
59
+ assert evaluate(pol, "Bash", {"command": f'gh issue create --body "{K} exec'}).outcome == "confirm"
60
+
61
+
62
+ def test_program_rule_names_the_program(pol):
63
+ rule = next(r for r in pol["bash_confirm"] if r.get("program") == K)
64
+ assert match_program_rule(rule, f"sudo {K} apply -f x") == K
65
+ assert match_program_rule(rule, f'gh issue create --body "{K} apply"') is None
66
+ d = evaluate(pol, "Bash", {"command": f"{K} --context prod apply -f x"})
67
+ assert d.findings[0]["finding"] == f"run {K} (cluster mutation)"
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
File without changes