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.
- {gitvow-0.26.0/src/gitvow.egg-info → gitvow-0.26.1}/PKG-INFO +1 -1
- {gitvow-0.26.0 → gitvow-0.26.1}/pyproject.toml +1 -1
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/policy.py +104 -4
- {gitvow-0.26.0 → gitvow-0.26.1/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow.egg-info/SOURCES.txt +1 -0
- gitvow-0.26.1/tests/test_command_position.py +67 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/LICENSE +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/README.md +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/setup.cfg +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/__init__.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/__main__.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/adapters.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/claims.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/cli.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/collect.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/decisions.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/digest.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/export.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/hooks/__init__.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/install.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/pack.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/paths.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/pricing.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/proposals.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/providers.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/recall.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/redact.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/report.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/rules.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/safewrite.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/scan.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/selftest.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/state.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/status.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/sync.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow/transcript.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_adapters.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_claims.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_collect.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_cost.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_decisions.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_digest.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_e2e_globalopt.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_export.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_external_adapter.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_hardening.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_hooks.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_install_cli.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_pack.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_packaging.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_policy.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_proposals.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_providers.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_recall.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_redact.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_report.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_rules.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_scan.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_snapshots.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_status.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_sync.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_sync_http.py +0 -0
- {gitvow-0.26.0 → gitvow-0.26.1}/tests/test_tiers.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|