gitvow 0.26.1__tar.gz → 0.28.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.
Files changed (74) hide show
  1. {gitvow-0.26.1/src/gitvow.egg-info → gitvow-0.28.0}/PKG-INFO +1 -1
  2. {gitvow-0.26.1 → gitvow-0.28.0}/pyproject.toml +1 -1
  3. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/cli.py +26 -0
  4. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/default_policy.json +33 -4
  5. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/policy.py +92 -13
  6. gitvow-0.28.0/src/gitvow/serve.py +416 -0
  7. gitvow-0.28.0/src/gitvow/targets.py +126 -0
  8. {gitvow-0.26.1 → gitvow-0.28.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  9. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow.egg-info/SOURCES.txt +4 -0
  10. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_command_position.py +1 -1
  11. gitvow-0.28.0/tests/test_serve.py +141 -0
  12. gitvow-0.28.0/tests/test_targets.py +116 -0
  13. {gitvow-0.26.1 → gitvow-0.28.0}/LICENSE +0 -0
  14. {gitvow-0.26.1 → gitvow-0.28.0}/README.md +0 -0
  15. {gitvow-0.26.1 → gitvow-0.28.0}/setup.cfg +0 -0
  16. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/__init__.py +0 -0
  17. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/__main__.py +0 -0
  18. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/adapters.py +0 -0
  19. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/claims.py +0 -0
  20. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/collect.py +0 -0
  21. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/decisions.py +0 -0
  22. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/digest.py +0 -0
  23. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/export.py +0 -0
  24. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/hooks/__init__.py +0 -0
  25. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/install.py +0 -0
  26. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/pack.py +0 -0
  27. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/paths.py +0 -0
  28. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/pricing.py +0 -0
  29. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/proposals.py +0 -0
  30. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/providers.py +0 -0
  31. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/recall.py +0 -0
  32. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/redact.py +0 -0
  33. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/report.py +0 -0
  34. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/rules.py +0 -0
  35. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/safewrite.py +0 -0
  36. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/scan.py +0 -0
  37. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/selftest.py +0 -0
  38. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/snapshots.py +0 -0
  39. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/state.py +0 -0
  40. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/status.py +0 -0
  41. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/sync.py +0 -0
  42. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/transcript.py +0 -0
  43. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  44. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  45. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow.egg-info/requires.txt +0 -0
  46. {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow.egg-info/top_level.txt +0 -0
  47. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_adapters.py +0 -0
  48. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_claims.py +0 -0
  49. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_collect.py +0 -0
  50. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_cost.py +0 -0
  51. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_decisions.py +0 -0
  52. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_digest.py +0 -0
  53. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_e2e_globalopt.py +0 -0
  54. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_export.py +0 -0
  55. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_external_adapter.py +0 -0
  56. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_hardening.py +0 -0
  57. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_hooks.py +0 -0
  58. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_install_cli.py +0 -0
  59. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_pack.py +0 -0
  60. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_packaging.py +0 -0
  61. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_policy.py +0 -0
  62. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_proposals.py +0 -0
  63. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_providers.py +0 -0
  64. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_recall.py +0 -0
  65. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_redact.py +0 -0
  66. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_report.py +0 -0
  67. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_rules.py +0 -0
  68. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_scan.py +0 -0
  69. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_snapshots.py +0 -0
  70. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_status.py +0 -0
  71. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_sync.py +0 -0
  72. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_sync_http.py +0 -0
  73. {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_tiers.py +0 -0
  74. {gitvow-0.26.1 → gitvow-0.28.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.26.1
3
+ Version: 0.28.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.26.1"
7
+ version = "0.28.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"}
@@ -231,6 +231,21 @@ def cmd_status(a: argparse.Namespace) -> int:
231
231
  return 1 if any(s == "fail" for s, _, _ in checks) else 0
232
232
 
233
233
 
234
+ def cmd_serve(a: argparse.Namespace) -> int:
235
+ """The record server. Read-only; MCP over stdio; nothing written, nothing sent."""
236
+ from .serve import config_snippets, serve
237
+ from .state import toplevel
238
+
239
+ repo = toplevel(os.path.abspath(a.repo))
240
+ if not repo:
241
+ print(f"{a.repo} is not inside a git repository", file=sys.stderr)
242
+ return 2
243
+ if a.print_config:
244
+ print(config_snippets(repo))
245
+ return 0
246
+ return serve(repo)
247
+
248
+
234
249
  def cmd_decisions(a: argparse.Namespace) -> int:
235
250
  """The card: open findings in this repository."""
236
251
  cwd = os.getcwd()
@@ -903,6 +918,17 @@ def main(argv: list[str] | None = None) -> int:
903
918
  "--line", action="store_true", help="one line for a status bar: what this repository's session recorded"
904
919
  )
905
920
  s.set_defaults(f=cmd_status)
921
+ s = sub.add_parser(
922
+ "serve",
923
+ help="the record server: answer any agent's questions about this repository's record over MCP (stdio); read-only",
924
+ )
925
+ s.add_argument("--repo", default=".", help="the repository to serve (default: the current one)")
926
+ s.add_argument(
927
+ "--print-config",
928
+ action="store_true",
929
+ help="print how to point Claude Code, Cursor and Codex at this server, and exit",
930
+ )
931
+ s.set_defaults(f=cmd_serve)
906
932
  s = sub.add_parser("sync", help="export the record and deliver it to the configured sinks; never the session")
907
933
  s.add_argument("--sink", help="deliver to this sink only")
908
934
  s.add_argument("--since", default="90d", help="window of history to carry (default 90d)")
@@ -63,7 +63,34 @@
63
63
  "scale",
64
64
  "exec"
65
65
  ],
66
- "reason": "cluster mutation"
66
+ "target": "(?i)prod|live",
67
+ "reason": "cluster mutation in production"
68
+ },
69
+ {
70
+ "program": "kubectl",
71
+ "verbs": [
72
+ "apply",
73
+ "create",
74
+ "replace",
75
+ "patch",
76
+ "rollout",
77
+ "set",
78
+ "scale",
79
+ "exec"
80
+ ],
81
+ "reason": "cluster mutation",
82
+ "when": "observe"
83
+ },
84
+ {
85
+ "program": "helm",
86
+ "verbs": [
87
+ "install",
88
+ "upgrade",
89
+ "uninstall",
90
+ "rollback"
91
+ ],
92
+ "target": "(?i)prod|live",
93
+ "reason": "helm release change in production"
67
94
  },
68
95
  {
69
96
  "program": "helm",
@@ -73,7 +100,8 @@
73
100
  "uninstall",
74
101
  "rollback"
75
102
  ],
76
- "reason": "helm release change"
103
+ "reason": "helm release change",
104
+ "when": "observe"
77
105
  },
78
106
  {
79
107
  "program": "gh",
@@ -125,13 +153,14 @@
125
153
  "rule_threshold": 3,
126
154
  "rule_decay_days": 90,
127
155
  "_doc": "rule_threshold: consistent answers by authorities before a finding becomes an earned rule; rule_decay_days: days without a new confirmation before it lapses. mode: open (the agent's commit is stopped once with the card, then goes through with Gitvow-Open for every finding nobody answered; a person's commit goes through the same way) or strict (agent and person alike: refused until every finding has a gitvow decide answer). authorities: emails, email local parts or names whose decisions count; empty means anyone with commit access. session_scope: an immediate confirm is recorded as a finding and a person's yes (gitvow decide <n> accept --scope session, or the agent's own approve button) holds for the rest of the session; the next commit carries it as a scoped decision, which is never precedent. false: ask every time, record nothing.",
128
- "session_scope": true
156
+ "session_scope": true,
157
+ "indirect_commands": "observe"
129
158
  },
130
159
  "llm_classifier": {
131
160
  "enabled": false,
132
161
  "command": "",
133
162
  "_doc": "Optional: a command that reads the hook JSON on stdin and prints ALLOW|CONFIRM|DENY <reason>. Used only for calls not matched above."
134
163
  },
135
- "_doc_commands": "A command rule is either {pattern} or {program, verbs}. The second form allows options between the program and the verb (kubectl --context prod delete), which a hand-written \\bkubectl\\s+delete silently does not; write new command rules in that form. Reads are never rules: kubectl get, aws s3 ls, SELECT. The database-write rule matches a client program on the line and a writing statement anywhere after it; grep for UPDATE in a source tree is not a write.",
164
+ "_doc_commands": "A command rule is either {pattern} or {program, verbs}. The second form matches the program in command position (not inside another program's quoted argument) and allows options between the program and the verb (kubectl --context prod delete); write new command rules in that form. It may add 'target', a regex on what the command reaches, read from the environment before it runs: for kubectl and helm the context from --context/--kube-context or the kubeconfig's current-context, plus namespace; for git push the remote URL and branch; for terraform the workspace under -chdir. A target that cannot be read matches every target rule, so an action of unknown reach is asked about. Rules are tried in order, so put the target-specific tier first and the catch-all observe tier after it. Reads are never rules: kubectl get, aws s3 ls, SELECT. The database-write rule matches a client program on the line and a writing statement anywhere after it; grep for UPDATE in a source tree is not a write. decisions.indirect_commands (allow|observe|commit|immediate, default observe) is the tier for a command whose program the gate cannot read from the text: eval, source, a variable in program position, or a shell running a script file.",
136
165
  "_doc_when": "when: immediate stops the call and asks now; commit records a finding and asks on the card at commit; observe records the finding on the commit as Gitvow-Observed and asks nobody, ever. Observe costs zero interruptions, so it is the tier to put a pattern in when you want the record to show how often it happens before anyone decides whether it deserves a question."
137
166
  }
@@ -21,6 +21,7 @@ class PolicyError(Exception):
21
21
 
22
22
  WHEN = ("immediate", "commit", "observe")
23
23
  DECISION_MODES = ("open", "strict")
24
+ INDIRECT_TIERS = ("allow", "observe", "commit", "immediate")
24
25
 
25
26
  # Options that may sit between a program and its verb: `--context prod`, `-n ns`, `--kubeconfig=x`, `-v`. Three
26
27
  # shapes: `--k=v`, `--k v` where v does not start with a dash or a shell metacharacter, and a bare flag. Rules written
@@ -105,13 +106,25 @@ def match_program_rule(rule: dict[str, Any], text: str) -> str | None:
105
106
  a command line of its own. Quoted arguments of some other program, and heredoc bodies, are not commands and
106
107
  are not matched. A line that cannot be tokenised falls back to the whole-text regex, failing toward asking.
107
108
  """
109
+ for prog, _args in program_matches(rule, text):
110
+ return prog
111
+ return None
112
+
113
+
114
+ def program_matches(rule: dict[str, Any], text: str) -> list[tuple[str, list[str]]]:
115
+ """Every simple command on the line that a `{program, verbs}` rule matches, as (program, arguments)."""
108
116
  progs = rule["program"] if isinstance(rule["program"], list) else [rule["program"]]
109
117
  verbs = "|".join(rule["verbs"])
110
118
  head = re.compile(rf"^{OPTS}\s+(?:{verbs})\b")
111
119
  segs = _segments(text)
112
120
  if segs is None:
113
121
  m = re.search(rule_pattern(rule), text)
114
- return re.split(r"\s", text[m.start() :].lstrip(), maxsplit=1)[0] if m else None
122
+ if not m:
123
+ return []
124
+ tail = text[m.start() :].lstrip()
125
+ words = tail.split()
126
+ return [(os.path.basename(words[0]), words[1:])] if words else []
127
+ out: list[tuple[str, list[str]]] = []
115
128
  stack = list(segs)
116
129
  while stack:
117
130
  prog, args = _program_and_args(stack.pop())
@@ -121,22 +134,63 @@ def match_program_rule(rule: dict[str, Any], text: str) -> str | None:
121
134
  inner = _segments(args[args.index("-c") + 1]) if args.index("-c") + 1 < len(args) else None
122
135
  if inner is None:
123
136
  if re.search(rule_pattern(rule), " ".join(args)):
124
- return progs[0]
137
+ out.append((progs[0], []))
125
138
  else:
126
139
  stack.extend(inner)
127
140
  continue
128
141
  if prog in progs and head.match(" " + " ".join(args)):
129
- return prog
130
- return None
142
+ out.append((prog, args))
143
+ return out
131
144
 
132
145
 
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."""
146
+ def match_rule(
147
+ rule: dict[str, Any], text: str, cwd: str | None = None
148
+ ) -> tuple[str | None, re.Match[str] | None, str | None]:
149
+ """(program, match, target) for whichever form the rule takes.
150
+
151
+ `pattern` rules are a plain regex over the whole line. `{program, verbs}` rules match in command position, and
152
+ when the rule also names a `target` regex, the command's target (targets.py: the kube context, the push
153
+ remote and branch, the terraform workspace) is read and must match; a target that cannot be read matches, so
154
+ an action of unknown reach is asked about rather than waved through.
155
+ """
135
156
  if "pattern" in rule:
136
157
  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)
158
+ return (None, m, None) if m else (None, None, None)
159
+ from . import targets
160
+
161
+ for prog, args in program_matches(rule, text):
162
+ target = targets.resolve(prog, args, cwd) if rule.get("target") else None
163
+ if targets.target_matches(rule, target):
164
+ return prog, None, target
165
+ return None, None, None
166
+
167
+
168
+ # Command shapes whose program the gate cannot read: the text names an indirection, not a program.
169
+ _INDIRECT_VAR = re.compile(r"^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$")
170
+
171
+
172
+ def indirect_commands(text: str) -> list[str]:
173
+ """Descriptions of every simple command on the line whose program cannot be read from the text: `eval`,
174
+ `source` / `.`, a variable in program position, or a shell running a script file rather than `-c`."""
175
+ segs = _segments(text)
176
+ if not segs:
177
+ return []
178
+ out: list[str] = []
179
+ for seg in segs:
180
+ prog, args = _program_and_args(seg)
181
+ if not prog:
182
+ continue
183
+ if prog == "eval":
184
+ out.append("eval")
185
+ elif prog in ("source", "."):
186
+ out.append(f"source {args[0]}" if args else "source")
187
+ elif _INDIRECT_VAR.match(prog):
188
+ out.append(f"{prog} (a variable in program position)")
189
+ elif prog in _SHELLS and "-c" not in args:
190
+ script = next((a for a in args if not a.startswith("-")), None)
191
+ if script:
192
+ out.append(f"{prog} {script}")
193
+ return out
140
194
 
141
195
 
142
196
  @dataclass(frozen=True)
@@ -226,6 +280,13 @@ def _validate(pol: dict[str, Any], path: str) -> None:
226
280
  raise PolicyError(f"{path}: bad regex in {key}: {pat} ({e})") from e
227
281
  if "when" in rule and rule["when"] not in WHEN:
228
282
  raise PolicyError(f"{path}: {key} 'when' must be one of {WHEN}")
283
+ if "target" in rule:
284
+ if key == "path_confirm" or "pattern" in rule:
285
+ raise PolicyError(f"{path}: {key} 'target' is only for rules written as program and verbs")
286
+ try:
287
+ re.compile(rule["target"])
288
+ except (re.error, TypeError) as e:
289
+ raise PolicyError(f"{path}: bad regex in {key} target: {rule['target']!r} ({e})") from e
229
290
  dec = pol.get("decisions", {})
230
291
  if not isinstance(dec, dict):
231
292
  raise PolicyError(f"{path}: decisions must be an object")
@@ -240,6 +301,8 @@ def _validate(pol: dict[str, Any], path: str) -> None:
240
301
  raise PolicyError(f"{path}: decisions.{key} must be a positive integer")
241
302
  if not isinstance(dec.get("session_scope", True), bool):
242
303
  raise PolicyError(f"{path}: decisions.session_scope must be true or false")
304
+ if dec.get("indirect_commands", "observe") not in INDIRECT_TIERS:
305
+ raise PolicyError(f"{path}: decisions.indirect_commands must be one of {INDIRECT_TIERS}")
243
306
  for key in ("mcp_allow", "mcp_deny"):
244
307
  for pat in pol.get(key, []):
245
308
  try:
@@ -306,22 +369,38 @@ def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any], cwd: st
306
369
  findings: list[dict[str, Any]] = []
307
370
  if tool == "Bash":
308
371
  for r in pol.get("bash_deny", []):
309
- prog, m = match_rule(r, text)
372
+ prog, m, _t = match_rule(r, text, cwd)
310
373
  if prog or m:
311
374
  return Decision("deny", r.get("reason", "denied"), text)
312
375
  for r in pol.get("bash_confirm", []):
313
- prog, m = match_rule(r, text)
376
+ prog, m, target = match_rule(r, text, cwd)
314
377
  if prog or m:
315
378
  when = r.get("when", "immediate")
316
379
  reason = r.get("reason", "needs confirmation")
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]])
380
+ if prog:
381
+ # The target is part of the subject, so standing accrues per place reached: three answers
382
+ # for context=staging say nothing about context=prod.
383
+ subject = f"{prog} ({reason})" + (f" @ {target}" if target else "")
384
+ subject = subject[:160]
385
+ else:
386
+ subject = _command_subject(text, m, reason) # type: ignore[arg-type]
387
+ evidence = [text.strip()[:200]] + ([f"target: {target}"] if target else [])
388
+ f = _finding("command", subject, "", reason, evidence)
319
389
  if when == "immediate":
320
390
  # The finding travels with the confirm so the hook can record it and a person can answer
321
391
  # it once for the session; the outcome is still an immediate stop.
322
392
  return Decision("confirm", reason, text, "immediate", (f,))
323
393
  f["observe"] = when == "observe"
324
394
  findings.append(f)
395
+ tier = (pol.get("decisions") or {}).get("indirect_commands", "observe")
396
+ if tier != "allow":
397
+ for desc in indirect_commands(text):
398
+ reason = "runs a command the gate cannot read"
399
+ f = _finding("command", f"{desc} ({reason})"[:160], "", reason, [text.strip()[:200]])
400
+ if tier == "immediate":
401
+ return Decision("confirm", reason, text, "immediate", (f,))
402
+ f["observe"] = tier == "observe"
403
+ findings.append(f)
325
404
  if tool in ("Edit", "Write", "MultiEdit", "NotebookEdit") and path:
326
405
  for r in pol.get("path_confirm", []):
327
406
  if re.search(r["pattern"], path):