gitvow 0.12.2__tar.gz → 0.12.4__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.
Potentially problematic release.
This version of gitvow might be problematic. Click here for more details.
- {gitvow-0.12.2/src/gitvow.egg-info → gitvow-0.12.4}/PKG-INFO +1 -1
- {gitvow-0.12.2 → gitvow-0.12.4}/pyproject.toml +1 -1
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/adapters.py +35 -3
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/cli.py +11 -1
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/decisions.py +2 -1
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/hooks/__init__.py +3 -4
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/transcript.py +48 -10
- {gitvow-0.12.2 → gitvow-0.12.4/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_adapters.py +89 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_transcript_formats.py +59 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/LICENSE +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/README.md +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/setup.cfg +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/__init__.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/__main__.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/collect.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/digest.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/install.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/policy.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/pricing.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/providers.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/recall.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/redact.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/report.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/rules.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/selftest.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/state.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow.egg-info/SOURCES.txt +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_collect.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_cost.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_decisions.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_digest.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_external_adapter.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_hooks.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_install_cli.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_packaging.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_policy.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_providers.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_recall.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_redact.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_report.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_rules.py +0 -0
- {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_snapshots.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.4
|
|
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.12.
|
|
7
|
+
version = "0.12.4"
|
|
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"}
|
|
@@ -10,6 +10,8 @@ import shutil
|
|
|
10
10
|
import subprocess
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
+
from .decisions import CARD_HEADER
|
|
14
|
+
|
|
13
15
|
AGENTS = ("claude", "codex", "gemini", "cursor", "copilot", "factory")
|
|
14
16
|
|
|
15
17
|
# agent event -> gitvow handler
|
|
@@ -216,9 +218,15 @@ def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str
|
|
|
216
218
|
)
|
|
217
219
|
]
|
|
218
220
|
if event == "beforeMCPExecution":
|
|
219
|
-
name = tool
|
|
221
|
+
name = _cursor_mcp(tool, payload)
|
|
220
222
|
return [(gv_event, {**base, "tool_name": name, "tool_input": inp})]
|
|
223
|
+
# generic preToolUse: Cursor fires this alongside the specific hook for shells and MCP calls,
|
|
224
|
+
# which would gate and log the same call twice; the specific hook carries the command, so it wins.
|
|
225
|
+
if tool in ("Shell", "shell", "run_terminal_cmd") and event == "preToolUse":
|
|
226
|
+
return []
|
|
221
227
|
# generic preToolUse: Cursor's own tool names
|
|
228
|
+
if tool.startswith("MCP:") or tool.startswith("mcp__"):
|
|
229
|
+
return [(gv_event, {**base, "tool_name": _cursor_mcp(tool, payload), "tool_input": inp})]
|
|
222
230
|
mapped = {
|
|
223
231
|
"Shell": "Bash",
|
|
224
232
|
"shell": "Bash",
|
|
@@ -227,23 +235,47 @@ def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str
|
|
|
227
235
|
"write_file": "Write",
|
|
228
236
|
"Edit": "Edit",
|
|
229
237
|
"Write": "Write",
|
|
238
|
+
"Delete": "Edit", # deleting an authorization file is as consequential as editing one
|
|
230
239
|
}.get(tool, tool)
|
|
231
240
|
if mapped == "Bash" and "command" not in inp and payload.get("command"):
|
|
232
241
|
inp["command"] = payload["command"]
|
|
242
|
+
if mapped in ("Write", "Edit") and "new_content" in inp:
|
|
243
|
+
# Cursor names the whole new file `new_content`; the gate and providers read `content`
|
|
244
|
+
inp.setdefault("content", inp["new_content"])
|
|
233
245
|
return [(gv_event, {**base, "tool_name": mapped, "tool_input": inp})]
|
|
234
246
|
return [(gv_event, {**base, "tool_name": tool, "tool_input": inp})]
|
|
235
247
|
|
|
236
248
|
|
|
249
|
+
def _cursor_mcp(tool: str, payload: dict[str, Any]) -> str:
|
|
250
|
+
"""Cursor names MCP tools `MCP:<tool>` in preToolUse and passes the server separately."""
|
|
251
|
+
if tool.startswith("mcp__"):
|
|
252
|
+
return tool
|
|
253
|
+
name = tool[4:] if tool.startswith("MCP:") else tool
|
|
254
|
+
server = str(payload.get("mcp_server_name") or "server")
|
|
255
|
+
return f"mcp__{server}__{name}"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _refuse(msg: str) -> bool:
|
|
259
|
+
"""Agents with a native permission prompt must refuse, not ask, for these.
|
|
260
|
+
|
|
261
|
+
A denial is never negotiable, and the decision card is not a question about running the command:
|
|
262
|
+
it asks the agent to record the person's answers with `gitvow decide` and run the commit again.
|
|
263
|
+
Delivered as `ask`, a click on the agent's own approve button would run the commit with the
|
|
264
|
+
findings still open and no decision recorded anywhere.
|
|
265
|
+
"""
|
|
266
|
+
return msg.startswith("BLOCKED") or msg.startswith(CARD_HEADER)
|
|
267
|
+
|
|
268
|
+
|
|
237
269
|
def respond(agent: str, code: int, msg: str) -> tuple[int, str, str]:
|
|
238
270
|
"""(exit code, stdout, stderr) in the agent's form. gitvow's code 2 means blocked; the message says DENY or CONFIRM."""
|
|
239
271
|
if agent == "cursor":
|
|
240
272
|
if code == 2:
|
|
241
|
-
perm = "deny" if msg
|
|
273
|
+
perm = "deny" if _refuse(msg) else "ask"
|
|
242
274
|
return 0, json.dumps({"permission": perm, "user_message": msg, "agent_message": msg}), ""
|
|
243
275
|
return 0, json.dumps({"permission": "allow"}), msg
|
|
244
276
|
if agent == "copilot":
|
|
245
277
|
if code == 2:
|
|
246
|
-
perm = "deny" if msg
|
|
278
|
+
perm = "deny" if _refuse(msg) else "ask"
|
|
247
279
|
return 0, json.dumps({"permissionDecision": perm, "permissionDecisionReason": msg}), ""
|
|
248
280
|
return 0, json.dumps({"permissionDecision": "allow"}), msg
|
|
249
281
|
return code, "", msg
|
|
@@ -44,7 +44,17 @@ def cmd_hook(a: argparse.Namespace) -> int:
|
|
|
44
44
|
print(str(e), file=sys.stderr)
|
|
45
45
|
return 1
|
|
46
46
|
if not calls:
|
|
47
|
-
|
|
47
|
+
# An event this adapter does not use. Agents whose contract is a JSON permission object treat
|
|
48
|
+
# a silent hook as a failed hook, and gitvow installs its hooks fail-closed, so say "allow"
|
|
49
|
+
# explicitly rather than printing nothing.
|
|
50
|
+
try:
|
|
51
|
+
exit_code, out, err = external_respond(exe, 0, "") if exe else respond(agent, 0, "")
|
|
52
|
+
except AdapterError as e:
|
|
53
|
+
print(f"BLOCKED: agent adapter failed ({e}).", file=sys.stderr)
|
|
54
|
+
return 2
|
|
55
|
+
if out:
|
|
56
|
+
print(out)
|
|
57
|
+
return exit_code
|
|
48
58
|
worst, messages = 0, []
|
|
49
59
|
for gv_event, p in calls:
|
|
50
60
|
handler = HANDLERS.get(gv_event)
|
|
@@ -15,6 +15,7 @@ from .state import git, load_state, save_state
|
|
|
15
15
|
|
|
16
16
|
TRAILER_RE = re.compile(r"^Gitvow-(Accepted|Declined|Open):\s*(.+?)(?: by (\S+))?(?: scope=(\S+))?(?:: (.*))?$", re.M)
|
|
17
17
|
REVISITS_RE = re.compile(r"^Gitvow-Revisits:\s*([0-9a-f]{7,40})\b", re.M)
|
|
18
|
+
CARD_HEADER = "DECISIONS REQUIRED"
|
|
18
19
|
MAX_EVIDENCE = 8
|
|
19
20
|
HISTORY_COMMITS = 3000
|
|
20
21
|
|
|
@@ -218,7 +219,7 @@ def card(
|
|
|
218
219
|
lines = []
|
|
219
220
|
if for_agent:
|
|
220
221
|
lines += [
|
|
221
|
-
f"
|
|
222
|
+
f"{CARD_HEADER} before this commit: {len(pending)} finding{'s' if len(pending) != 1 else ''} from this session.",
|
|
222
223
|
"Put this card to the user. Record each answer with",
|
|
223
224
|
' gitvow decide <n> accept|decline [--scope <env-or-branch>] [--reason "<phrase>"]',
|
|
224
225
|
"then run the commit again.",
|
|
@@ -125,10 +125,9 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
|
125
125
|
pending = dec.undecided(cwd)
|
|
126
126
|
if pending:
|
|
127
127
|
st = load_state(cwd)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
]
|
|
128
|
+
tp = h.get("transcript_path") or st.get("transcript_path")
|
|
129
|
+
if rules is not None and tp and st.get("card_user_turns") is None:
|
|
130
|
+
st["card_user_turns"] = summarize(tp, rules=rules)["user_turns"]
|
|
132
131
|
st["card_shown_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
133
132
|
save_state(cwd, st)
|
|
134
133
|
proposed = dec.mark_proposals(cwd, pol)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
from collections.abc import Iterable
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
@@ -40,8 +41,6 @@ def _text_of(content: Any) -> str:
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def _patch_files(text: str) -> list[str]:
|
|
43
|
-
import re
|
|
44
|
-
|
|
45
44
|
return re.findall(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", text or "", re.M)
|
|
46
45
|
|
|
47
46
|
|
|
@@ -167,13 +166,50 @@ def _finish_usage(usage: dict[str, Any]) -> None:
|
|
|
167
166
|
)
|
|
168
167
|
|
|
169
168
|
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
INJECTED_RE = re.compile(r"^\s*<[a-z_]+>") # Codex prepends <recommended_plugins>, <environment_context> and the like
|
|
170
|
+
PERSON_TEXT = ("text", "input_text")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _person_text(content: Any) -> str:
|
|
172
174
|
if isinstance(content, str):
|
|
173
|
-
return
|
|
175
|
+
return content
|
|
174
176
|
if isinstance(content, list):
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
+
return "\n".join(
|
|
178
|
+
str(c.get("text", ""))
|
|
179
|
+
for c in content
|
|
180
|
+
if isinstance(c, dict) and c.get("type") in PERSON_TEXT and str(c.get("text", "")).strip()
|
|
181
|
+
)
|
|
182
|
+
return ""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _is_person_text(content: Any) -> bool:
|
|
186
|
+
"""A user message typed by a person: not a tool result, and not a block the harness injected."""
|
|
187
|
+
text = _person_text(content).strip()
|
|
188
|
+
return bool(text) and not INJECTED_RE.match(text)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
TOOLS_CALL_RE = re.compile(r"tools\.([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _codex_unwrap(text: str) -> list[tuple[str, Any]]:
|
|
195
|
+
"""Codex 0.15 wraps every tool call in a JavaScript snippet: `await tools.exec_command({...})`.
|
|
196
|
+
|
|
197
|
+
Returns (function name, decoded argument) pairs; the argument is whatever JSON follows the bracket.
|
|
198
|
+
"""
|
|
199
|
+
out: list[tuple[str, Any]] = []
|
|
200
|
+
dec = json.JSONDecoder()
|
|
201
|
+
for m in TOOLS_CALL_RE.finditer(text or ""):
|
|
202
|
+
i = m.end()
|
|
203
|
+
while i < len(text) and text[i] in " \t\n\r":
|
|
204
|
+
i += 1
|
|
205
|
+
arg: Any = ""
|
|
206
|
+
if i < len(text) and text[i] in '{"[':
|
|
207
|
+
try:
|
|
208
|
+
arg, _ = dec.raw_decode(text, i)
|
|
209
|
+
except ValueError:
|
|
210
|
+
arg = ""
|
|
211
|
+
out.append((m.group(1), arg))
|
|
212
|
+
return out
|
|
177
213
|
|
|
178
214
|
|
|
179
215
|
def _add_tool(out: dict[str, Any], tool: str, brief: str, rules: list[tuple[str, str]], max_tools: int) -> None:
|
|
@@ -256,9 +292,11 @@ def _codex_event(
|
|
|
256
292
|
elif pt in ("function_call", "custom_tool_call", "local_shell_call"):
|
|
257
293
|
name = str(p.get("name") or ("local_shell" if pt == "local_shell_call" else ""))
|
|
258
294
|
arguments = p.get("arguments") if pt == "function_call" else (p.get("input") or p.get("action") or "")
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
295
|
+
inner = _codex_unwrap(arguments) if isinstance(arguments, str) and "tools." in arguments else []
|
|
296
|
+
for fn, arg in inner or [(name, arguments)]:
|
|
297
|
+
tool, brief, files = _codex_tool(fn, arg)
|
|
298
|
+
_add_tool(out, tool, brief, rules, max_tools)
|
|
299
|
+
written.update(files)
|
|
262
300
|
elif t == "turn_context":
|
|
263
301
|
if p.get("model"):
|
|
264
302
|
out["_codex_model"] = str(p["model"])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.4
|
|
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
|
|
@@ -369,3 +369,92 @@ def test_copilot_factory_cli_and_install(repo, home, monkeypatch, capsys):
|
|
|
369
369
|
assert (repo / ".github" / "hooks" / "gitvow.json").exists()
|
|
370
370
|
uninstall_repo(str(repo), agent="copilot")
|
|
371
371
|
assert not (repo / ".github" / "hooks" / "gitvow.json").exists()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_cursor_pretooluse_matches_the_documented_payload(repo, home):
|
|
375
|
+
"""Cursor sends Write with new_content for file edits and MCP:<tool> for MCP calls."""
|
|
376
|
+
from gitvow.adapters import normalize
|
|
377
|
+
|
|
378
|
+
ev, p = normalize(
|
|
379
|
+
"cursor",
|
|
380
|
+
"preToolUse",
|
|
381
|
+
{
|
|
382
|
+
"conversation_id": "cu",
|
|
383
|
+
"workspace_roots": [str(repo)],
|
|
384
|
+
"tool_name": "Write",
|
|
385
|
+
"tool_input": {"file_path": "core/authz_rules.go", "new_content": 'var Public = []string{"/v1/x"}'},
|
|
386
|
+
},
|
|
387
|
+
)[0]
|
|
388
|
+
assert ev == "PreToolUse" and p["tool_name"] == "Write"
|
|
389
|
+
# the gate and the providers read `content`; Cursor's own key is preserved alongside it
|
|
390
|
+
assert p["tool_input"]["content"] == 'var Public = []string{"/v1/x"}'
|
|
391
|
+
assert p["tool_input"]["new_content"] == p["tool_input"]["content"]
|
|
392
|
+
ev, p = normalize(
|
|
393
|
+
"cursor",
|
|
394
|
+
"preToolUse",
|
|
395
|
+
{
|
|
396
|
+
"conversation_id": "cu",
|
|
397
|
+
"workspace_roots": [str(repo)],
|
|
398
|
+
"tool_name": "Delete",
|
|
399
|
+
"tool_input": {"file_path": "core/authz_rules.go"},
|
|
400
|
+
},
|
|
401
|
+
)[0]
|
|
402
|
+
assert p["tool_name"] == "Edit" # a deleted gate-bearing file still reaches the gate
|
|
403
|
+
for payload, expected in (
|
|
404
|
+
({"tool_name": "MCP:drop_table", "mcp_server_name": "postgres"}, "mcp__postgres__drop_table"),
|
|
405
|
+
({"tool_name": "MCP:read_rows"}, "mcp__server__read_rows"),
|
|
406
|
+
({"tool_name": "mcp__already__mapped"}, "mcp__already__mapped"),
|
|
407
|
+
):
|
|
408
|
+
ev, p = normalize("cursor", "preToolUse", {"conversation_id": "cu", "workspace_roots": [str(repo)], **payload})[
|
|
409
|
+
0
|
|
410
|
+
]
|
|
411
|
+
assert p["tool_name"] == expected
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def test_the_card_is_refused_not_asked(repo, home):
|
|
415
|
+
"""On agents with their own approve button, the card must not be clickable past."""
|
|
416
|
+
from gitvow.adapters import respond
|
|
417
|
+
from gitvow.decisions import CARD_HEADER
|
|
418
|
+
|
|
419
|
+
card = f"{CARD_HEADER} before this commit: 1 finding from this session."
|
|
420
|
+
for agent, key in (("cursor", "permission"), ("copilot", "permissionDecision")):
|
|
421
|
+
assert json.loads(respond(agent, 2, card)[1])[key] == "deny"
|
|
422
|
+
assert json.loads(respond(agent, 2, "BLOCKED by policy (force push).")[1])[key] == "deny"
|
|
423
|
+
# an immediate confirm rule is a real question for the person, so it stays their prompt
|
|
424
|
+
assert json.loads(respond(agent, 2, "CONFIRMATION REQUIRED (pushing to a remote).")[1])[key] == "ask"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_cursor_shell_is_gated_once(repo, home):
|
|
428
|
+
"""Cursor fires preToolUse and beforeShellExecution for the same command."""
|
|
429
|
+
from gitvow.adapters import normalize
|
|
430
|
+
|
|
431
|
+
base = {"conversation_id": "cu", "workspace_roots": [str(repo)]}
|
|
432
|
+
assert (
|
|
433
|
+
normalize("cursor", "preToolUse", {**base, "tool_name": "Shell", "tool_input": {"command": "git commit -m x"}})
|
|
434
|
+
== []
|
|
435
|
+
)
|
|
436
|
+
calls = normalize("cursor", "beforeShellExecution", {**base, "command": "git commit -m x"})
|
|
437
|
+
assert len(calls) == 1 and calls[0][1]["tool_input"]["command"] == "git commit -m x"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_unused_events_still_answer_in_the_agents_form(repo, home, monkeypatch, capsys):
|
|
441
|
+
"""gitvow's hooks are installed fail-closed; on Cursor and Copilot a silent hook is a failed hook."""
|
|
442
|
+
import io
|
|
443
|
+
import sys
|
|
444
|
+
|
|
445
|
+
from gitvow import cli
|
|
446
|
+
|
|
447
|
+
monkeypatch.chdir(repo)
|
|
448
|
+
payload = {
|
|
449
|
+
"conversation_id": "cu",
|
|
450
|
+
"workspace_roots": [str(repo)],
|
|
451
|
+
"tool_name": "Shell",
|
|
452
|
+
"tool_input": {"command": "git commit -m x"},
|
|
453
|
+
}
|
|
454
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
455
|
+
assert cli.main(["hook", "--agent", "cursor", "preToolUse"]) == 0
|
|
456
|
+
assert json.loads(capsys.readouterr().out)["permission"] == "allow"
|
|
457
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps({**payload, "hook_event_name": "beforeReadFile"})))
|
|
458
|
+
assert cli.main(["hook", "--agent", "copilot", "sessionStart"]) == 0
|
|
459
|
+
out = capsys.readouterr().out
|
|
460
|
+
assert not out or json.loads(out).get("permissionDecision") in (None, "allow")
|
|
@@ -126,3 +126,62 @@ def test_claude_format_still_default(transcript):
|
|
|
126
126
|
def test_redaction_applies_to_all_formats(tmp_path):
|
|
127
127
|
s = summarize(_write(tmp_path, "rollout.jsonl", CODEX[:4]))
|
|
128
128
|
assert "[github-token]" in s["last_assistant_text"] and "ghp_" not in s["last_assistant_text"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Codex 0.15 shape, captured from a real session: one `exec` custom tool whose input is a JavaScript
|
|
132
|
+
# snippet calling tools.exec_command / tools.apply_patch, and user text as input_text parts.
|
|
133
|
+
CODEX_WRAPPED = [
|
|
134
|
+
{"timestamp": "t", "type": "session_meta", "payload": {"id": "c2", "cwd": "/r"}},
|
|
135
|
+
{
|
|
136
|
+
"timestamp": "t",
|
|
137
|
+
"type": "response_item",
|
|
138
|
+
"payload": {
|
|
139
|
+
"type": "message",
|
|
140
|
+
"role": "user",
|
|
141
|
+
"content": [{"type": "input_text", "text": "<recommended_plugins>\nAirtable\n</recommended_plugins>"}],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"timestamp": "t",
|
|
146
|
+
"type": "response_item",
|
|
147
|
+
"payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "fix add"}]},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"timestamp": "t",
|
|
151
|
+
"type": "response_item",
|
|
152
|
+
"payload": {
|
|
153
|
+
"type": "custom_tool_call",
|
|
154
|
+
"name": "exec",
|
|
155
|
+
"input": 'const r = await tools.exec_command({"cmd":"git status","workdir":"/r"});\nconsole.log(r);',
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"timestamp": "t",
|
|
160
|
+
"type": "response_item",
|
|
161
|
+
"payload": {
|
|
162
|
+
"type": "custom_tool_call",
|
|
163
|
+
"name": "exec",
|
|
164
|
+
"input": 'await tools.apply_patch("*** Begin Patch\\n*** Update File: /r/calc.py\\n+a + b\\n*** End Patch\\n");',
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"timestamp": "t",
|
|
169
|
+
"type": "response_item",
|
|
170
|
+
"payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Fixed."}]},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"timestamp": "t",
|
|
174
|
+
"type": "response_item",
|
|
175
|
+
"payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "accept it"}]},
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_codex_wrapped_tool_calls_and_user_turns(tmp_path):
|
|
181
|
+
s = summarize(_write(tmp_path, "rollout.jsonl", CODEX_WRAPPED))
|
|
182
|
+
assert s["format"] == "codex"
|
|
183
|
+
assert [t["tool"] for t in s["tool_calls"]] == ["Bash", "Edit"]
|
|
184
|
+
assert s["tool_calls"][0]["arg"] == "git status" and s["tool_calls"][1]["arg"] == "/r/calc.py"
|
|
185
|
+
assert s["files_written"] == ["/r/calc.py"]
|
|
186
|
+
# the injected <recommended_plugins> block is not a person speaking; the two real prompts are
|
|
187
|
+
assert s["user_turns"] == 2
|
|
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
|