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.
- {gitvow-0.26.1/src/gitvow.egg-info → gitvow-0.28.0}/PKG-INFO +1 -1
- {gitvow-0.26.1 → gitvow-0.28.0}/pyproject.toml +1 -1
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/cli.py +26 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/default_policy.json +33 -4
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/policy.py +92 -13
- gitvow-0.28.0/src/gitvow/serve.py +416 -0
- gitvow-0.28.0/src/gitvow/targets.py +126 -0
- {gitvow-0.26.1 → gitvow-0.28.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow.egg-info/SOURCES.txt +4 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_command_position.py +1 -1
- gitvow-0.28.0/tests/test_serve.py +141 -0
- gitvow-0.28.0/tests/test_targets.py +116 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/LICENSE +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/README.md +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/setup.cfg +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/adapters.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/claims.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/decisions.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/digest.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/export.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/hooks/__init__.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/install.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/pack.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/paths.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/pricing.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/proposals.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/recall.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/report.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/rules.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/safewrite.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/scan.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/selftest.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/state.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/status.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/sync.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_adapters.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_claims.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_collect.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_cost.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_decisions.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_digest.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_e2e_globalopt.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_export.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_external_adapter.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_hardening.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_hooks.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_pack.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_packaging.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_policy.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_proposals.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_providers.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_recall.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_redact.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_report.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_rules.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_scan.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_snapshots.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_status.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_sync.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_sync_http.py +0 -0
- {gitvow-0.26.1 → gitvow-0.28.0}/tests/test_tiers.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
"
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
return
|
|
142
|
+
out.append((prog, args))
|
|
143
|
+
return out
|
|
131
144
|
|
|
132
145
|
|
|
133
|
-
def match_rule(
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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):
|