conduct-cli 0.7.10__tar.gz → 0.7.11__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 (42) hide show
  1. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/base.py +2 -0
  4. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/posttooluse.py +54 -7
  5. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  6. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/SOURCES.txt +1 -1
  7. conduct_cli-0.7.11/tests/test_blast_radius.py +44 -0
  8. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_journal_drain.py +31 -34
  9. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_signed_policy.py +65 -77
  10. conduct_cli-0.7.10/tests/test_hook_syntax.py +0 -19
  11. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/README.md +0 -0
  12. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/setup.cfg +0 -0
  13. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/setup.py +0 -0
  14. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/__init__.py +0 -0
  15. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/api.py +0 -0
  16. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/guard.py +0 -0
  17. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/guardmcp.py +0 -0
  18. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/__init__.py +0 -0
  19. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/precompact.py +0 -0
  20. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/pretooluse.py +0 -0
  21. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/session_parser.py +0 -0
  22. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/session_report_push.py +0 -0
  23. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/session_start.py +0 -0
  24. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/stop.py +0 -0
  25. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/log_util.py +0 -0
  26. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/main.py +0 -0
  27. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/mcp_server.py +0 -0
  28. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/memory.py +0 -0
  29. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/paxel.py +0 -0
  30. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  31. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  32. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/requires.txt +0 -0
  33. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/top_level.txt +0 -0
  34. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_advisory_and_hook.py +0 -0
  35. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_bash_operator_signature.py +0 -0
  36. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_command_word_matcher.py +0 -0
  37. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_guard_policy.py +0 -0
  38. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_guard_savings.py +0 -0
  39. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_hook_dispatch.py +0 -0
  40. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_log_util.py +0 -0
  41. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_proxy_env.py +0 -0
  42. {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.10
3
+ Version: 0.7.11
4
4
  Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.7.10"
7
+ version = "0.7.11"
8
8
  description = "CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -211,6 +211,7 @@ def post_event(
211
211
  session_id: Optional[str] = None,
212
212
  *,
213
213
  drain_via: Optional[Path] = None,
214
+ blast_radius: "dict | None" = None,
214
215
  ) -> None:
215
216
  """Post one guard event via the journal/drain pattern. Never raises.
216
217
 
@@ -235,6 +236,7 @@ def post_event(
235
236
  "hook_session_id": session_id,
236
237
  "os_info": _os_info,
237
238
  "hostname": _platform.node(),
239
+ "blast_radius": blast_radius,
238
240
  })
239
241
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
240
242
  journal_append(payload, api_url)
@@ -247,7 +247,51 @@ def _scan_codex_tokens(transcript_path: str):
247
247
  return 0, 0
248
248
 
249
249
 
250
- def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms) -> None:
250
+ def _compute_blast_radius(tool_name: str, tool_input: dict, tool_response: str) -> "dict | None":
251
+ """Classify blast radius after tool execution. Returns None for read-only tools."""
252
+ import re as _re
253
+
254
+ READ_ONLY = {"read", "ls", "glob", "search", "grep", "websearch", "webfetch", "computer"}
255
+ if tool_name in READ_ONLY:
256
+ return None
257
+
258
+ files = 0
259
+ symbols: "int | None" = None
260
+ tier = "local"
261
+
262
+ if tool_name in ("bash", "terminal"):
263
+ cmd = (tool_input.get("command") or "").lower()
264
+ if _re.search(r"\brm\s+.*-rf|\brm\s+-rf", cmd):
265
+ tier = "destructive"
266
+ elif _re.search(r"\bgit\s+(push|commit|merge|rebase|reset|tag)\b", cmd):
267
+ tier = "repo"
268
+ elif _re.search(r"\b(curl|wget|fetch)\b|https?://", cmd):
269
+ tier = "network"
270
+ # count path-like lines in output as proxy for files touched
271
+ lines = (tool_response or "").splitlines()
272
+ files = min(sum(1 for l in lines if "/" in l or ("." in l and len(l) < 200)), 50)
273
+
274
+ elif tool_name in ("write",):
275
+ files = 1
276
+ content = tool_input.get("content") or ""
277
+ symbols = len(content.splitlines()) or None
278
+
279
+ elif tool_name in ("edit", "str_replace_based_edit_tool", "str_replace_editor"):
280
+ files = 1
281
+ new_str = tool_input.get("new_string") or tool_input.get("new_content") or ""
282
+ symbols = len(new_str.splitlines()) or None
283
+
284
+ elif tool_name in ("multiedit",):
285
+ edits = tool_input.get("edits") or []
286
+ files = len(edits)
287
+
288
+ else:
289
+ files = 1 # unknown write-like tool
290
+
291
+ return {"files": files, "symbols": symbols, "tier": tier}
292
+
293
+
294
+ def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms, blast_radius=None) -> None:
251
295
  """Fire-and-forget POST to /guard/events/usage."""
252
296
  cfg = load_config()
253
297
  workspace_id = cfg.get("workspace_id")
@@ -261,6 +305,7 @@ def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms)
261
305
  "tokens_output": tokens_output,
262
306
  "duration_ms": duration_ms,
263
307
  "ai_tool": detect_ai_tool(),
308
+ "blast_radius": blast_radius,
264
309
  })
265
310
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
266
311
  script = (
@@ -300,7 +345,7 @@ def post_codex_main() -> None:
300
345
  transcript_path = args.get("transcript_path", "")
301
346
  tokens_in, tokens_out = _scan_codex_tokens(transcript_path)
302
347
  if tokens_in or tokens_out:
303
- _post_usage(args.get("session_id"), args.get("tool_name"), tokens_in, tokens_out, None)
348
+ _post_usage(args.get("session_id"), args.get("tool_name"), tokens_in, tokens_out, None, args.get("blast_radius"))
304
349
  sys.exit(0)
305
350
 
306
351
 
@@ -320,6 +365,10 @@ def main() -> None:
320
365
  is_codex = (tool_use_id or "").startswith("call_")
321
366
  session_id = data.get("session_id") or (f"transcript:{transcript_path}" if transcript_path else None)
322
367
 
368
+ tool_response = data.get("tool_response") or data.get("output") or ""
369
+ tool_input = data.get("tool_input") or {}
370
+ blast_radius = _compute_blast_radius(tool_name, tool_input, str(tool_response))
371
+
323
372
  if is_codex and transcript_path:
324
373
  import uuid as _uuid
325
374
  pending = GUARD_DIR / f"codex_pending_{_uuid.uuid4().hex[:8]}.json"
@@ -328,6 +377,7 @@ def main() -> None:
328
377
  "session_id": session_id,
329
378
  "tool_name": tool_name,
330
379
  "transcript_path": transcript_path,
380
+ "blast_radius": blast_radius,
331
381
  }))
332
382
  subprocess.Popen(
333
383
  [sys.executable, str(_this_file), "post-codex", str(pending)],
@@ -339,7 +389,7 @@ def main() -> None:
339
389
  pass
340
390
  elif transcript_path:
341
391
  tokens_input, tokens_output = _read_tokens_from_transcript(transcript_path, tool_use_id)
342
- _post_usage(session_id, tool_name, tokens_input, tokens_output, None)
392
+ _post_usage(session_id, tool_name, tokens_input, tokens_output, None, blast_radius)
343
393
  _, action, rule_id, message = check_policy(tool_name, {}, tokens_before=tokens_input)
344
394
  if action in ("warn", "block"):
345
395
  decision = "warned" if action == "warn" else "blocked"
@@ -348,10 +398,7 @@ def main() -> None:
348
398
  else:
349
399
  if action == "warn" and session_id and rule_id:
350
400
  _record_session_warn(session_id, rule_id)
351
- post_event(tool_name, {}, decision, rule_id, message, session_id, drain_via=_this_file)
352
-
353
- tool_response = data.get("tool_response") or data.get("output") or ""
354
- tool_input = data.get("tool_input") or {}
401
+ post_event(tool_name, {}, decision, rule_id, message, session_id, drain_via=_this_file, blast_radius=blast_radius)
355
402
  _maybe_emit_security_finding(str(tool_response), session_id, tool_name, tool_input)
356
403
 
357
404
  sys.exit(0)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.10
3
+ Version: 0.7.11
4
4
  Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -27,11 +27,11 @@ src/conduct_cli/hooks/session_start.py
27
27
  src/conduct_cli/hooks/stop.py
28
28
  tests/test_advisory_and_hook.py
29
29
  tests/test_bash_operator_signature.py
30
+ tests/test_blast_radius.py
30
31
  tests/test_command_word_matcher.py
31
32
  tests/test_guard_policy.py
32
33
  tests/test_guard_savings.py
33
34
  tests/test_hook_dispatch.py
34
- tests/test_hook_syntax.py
35
35
  tests/test_journal_drain.py
36
36
  tests/test_log_util.py
37
37
  tests/test_proxy_env.py
@@ -0,0 +1,44 @@
1
+ """Tests for _compute_blast_radius."""
2
+ from conduct_cli.hooks.posttooluse import _compute_blast_radius
3
+
4
+
5
+ def test_bash_rm_rf_is_destructive():
6
+ r = _compute_blast_radius("bash", {"command": "rm -rf apps/web/.next"}, "")
7
+ assert r["tier"] == "destructive"
8
+
9
+
10
+ def test_bash_git_push_is_repo():
11
+ r = _compute_blast_radius("bash", {"command": "git push origin main"}, "")
12
+ assert r["tier"] == "repo"
13
+
14
+
15
+ def test_bash_curl_is_network():
16
+ r = _compute_blast_radius("bash", {"command": "curl https://api.example.com"}, "")
17
+ assert r["tier"] == "network"
18
+
19
+
20
+ def test_bash_local_counts_file_lines():
21
+ output = "src/foo.py\nsrc/bar.py\nDone"
22
+ r = _compute_blast_radius("bash", {"command": "ls"}, output)
23
+ assert r["tier"] == "local"
24
+ assert r["files"] == 2
25
+
26
+
27
+ def test_write_returns_local_with_symbols():
28
+ r = _compute_blast_radius("write", {"content": "line1\nline2\nline3"}, "")
29
+ assert r["files"] == 1
30
+ assert r["symbols"] == 3
31
+ assert r["tier"] == "local"
32
+
33
+
34
+ def test_read_returns_none():
35
+ assert _compute_blast_radius("read", {}, "") is None
36
+
37
+
38
+ def test_grep_returns_none():
39
+ assert _compute_blast_radius("grep", {}, "") is None
40
+
41
+
42
+ def test_multiedit_counts_edits():
43
+ r = _compute_blast_radius("multiedit", {"edits": [{}, {}, {}]}, "")
44
+ assert r["files"] == 3
@@ -1,34 +1,32 @@
1
- """Tests for #855-B: journal append + drain daemon logic."""
1
+ """Tests for journal append + drain daemon logic (conduct_cli.hooks.base)."""
2
2
  from __future__ import annotations
3
3
  import json
4
4
  import os
5
- import sys
6
5
  import time
7
6
  from pathlib import Path
8
7
  from unittest.mock import MagicMock, patch
9
8
  import pytest
10
9
 
11
- import conduct_cli.hook_template as ht
10
+ import conduct_cli.hooks.base as base
12
11
 
13
12
 
14
- # ── helpers ──────────────────────────────────────────────────────────────────
13
+ # ── helpers ───────────────────────────────────────────────────────────────────
15
14
 
16
15
  def _patch_journal(tmp_path: Path):
17
- """Return context managers that redirect JOURNAL_DIR/PID_PATH to tmp_path."""
18
16
  journal_dir = tmp_path / "journal"
19
17
  pid_path = journal_dir / "drain.pid"
20
18
  return (
21
- patch.object(ht, "JOURNAL_DIR", journal_dir),
22
- patch.object(ht, "JOURNAL_PID_PATH", pid_path),
19
+ patch.object(base, "JOURNAL_DIR", journal_dir),
20
+ patch.object(base, "JOURNAL_PID_PATH", pid_path),
23
21
  )
24
22
 
25
23
 
26
- # ── _journal_append ───────────────────────────────────────────────────────────
24
+ # ── journal_append ────────────────────────────────────────────────────────────
27
25
 
28
26
  def test_journal_append_creates_file(tmp_path):
29
27
  p1, p2 = _patch_journal(tmp_path)
30
28
  with p1, p2:
31
- ht._journal_append('{"foo": 1}', "https://api.test")
29
+ base.journal_append('{"foo": 1}', "https://api.test")
32
30
  journal_dir = tmp_path / "journal"
33
31
  files = list(journal_dir.glob("*.json"))
34
32
  assert len(files) == 1
@@ -40,12 +38,12 @@ def test_journal_append_creates_file(tmp_path):
40
38
  def test_journal_append_no_tmp_files_left(tmp_path):
41
39
  p1, p2 = _patch_journal(tmp_path)
42
40
  with p1, p2:
43
- ht._journal_append("{}", "https://api.test")
41
+ base.journal_append("{}", "https://api.test")
44
42
  journal_dir = tmp_path / "journal"
45
43
  assert not list(journal_dir.glob("*.tmp"))
46
44
 
47
45
 
48
- # ── _run_drain_daemon ─────────────────────────────────────────────────────────
46
+ # ── run_drain_daemon ──────────────────────────────────────────────────────────
49
47
 
50
48
  def _write_journal_entry(journal_dir: Path, payload: str, api_url: str) -> Path:
51
49
  journal_dir.mkdir(parents=True, exist_ok=True)
@@ -62,12 +60,12 @@ def test_drain_posts_and_deletes_on_success(tmp_path):
62
60
 
63
61
  mock_resp = MagicMock()
64
62
  with (
65
- patch.object(ht, "JOURNAL_DIR", journal_dir),
66
- patch.object(ht, "JOURNAL_PID_PATH", pid_path),
63
+ patch.object(base, "JOURNAL_DIR", journal_dir),
64
+ patch.object(base, "JOURNAL_PID_PATH", pid_path),
67
65
  patch("urllib.request.urlopen", return_value=mock_resp),
68
- patch("time.sleep"), # skip sleeps
66
+ patch("time.sleep"),
69
67
  ):
70
- ht._run_drain_daemon()
68
+ base.run_drain_daemon()
71
69
 
72
70
  assert not entry.exists(), "file should be deleted after successful POST"
73
71
 
@@ -78,12 +76,12 @@ def test_drain_leaves_file_on_network_failure(tmp_path):
78
76
  entry = _write_journal_entry(journal_dir, '{"x":1}', "https://api.test")
79
77
 
80
78
  with (
81
- patch.object(ht, "JOURNAL_DIR", journal_dir),
82
- patch.object(ht, "JOURNAL_PID_PATH", pid_path),
79
+ patch.object(base, "JOURNAL_DIR", journal_dir),
80
+ patch.object(base, "JOURNAL_PID_PATH", pid_path),
83
81
  patch("urllib.request.urlopen", side_effect=OSError("network down")),
84
- patch("time.sleep"), # skip sleeps so daemon exits after 3 all-fail scans
82
+ patch("time.sleep"),
85
83
  ):
86
- ht._run_drain_daemon()
84
+ base.run_drain_daemon()
87
85
 
88
86
  assert entry.exists(), "file should survive a failed POST"
89
87
 
@@ -95,29 +93,29 @@ def test_drain_exits_after_three_empty_scans(tmp_path):
95
93
  sleep_calls = []
96
94
 
97
95
  with (
98
- patch.object(ht, "JOURNAL_DIR", journal_dir),
99
- patch.object(ht, "JOURNAL_PID_PATH", pid_path),
96
+ patch.object(base, "JOURNAL_DIR", journal_dir),
97
+ patch.object(base, "JOURNAL_PID_PATH", pid_path),
100
98
  patch("time.sleep", side_effect=lambda s: sleep_calls.append(s)),
101
99
  ):
102
- ht._run_drain_daemon()
100
+ base.run_drain_daemon()
103
101
 
104
102
  assert len(sleep_calls) == 3, "should sleep exactly 3 times before exiting"
105
103
 
106
104
 
107
- # ── _ensure_drain_daemon ──────────────────────────────────────────────────────
105
+ # ── ensure_drain_daemon ───────────────────────────────────────────────────────
108
106
 
109
107
  def test_ensure_drain_skips_spawn_when_alive(tmp_path):
110
108
  journal_dir = tmp_path / "journal"
111
109
  pid_path = journal_dir / "drain.pid"
112
110
  journal_dir.mkdir(parents=True, exist_ok=True)
113
- pid_path.write_text(str(os.getpid())) # our own PID — definitely alive
111
+ pid_path.write_text(str(os.getpid())) # own PID — definitely alive
114
112
 
115
113
  with (
116
- patch.object(ht, "JOURNAL_DIR", journal_dir),
117
- patch.object(ht, "JOURNAL_PID_PATH", pid_path),
114
+ patch.object(base, "JOURNAL_DIR", journal_dir),
115
+ patch.object(base, "JOURNAL_PID_PATH", pid_path),
118
116
  patch("subprocess.Popen") as mock_popen,
119
117
  ):
120
- ht._ensure_drain_daemon()
118
+ base.ensure_drain_daemon()
121
119
  mock_popen.assert_not_called()
122
120
 
123
121
 
@@ -125,14 +123,13 @@ def test_ensure_drain_spawns_when_pid_missing(tmp_path):
125
123
  journal_dir = tmp_path / "journal"
126
124
  pid_path = journal_dir / "drain.pid"
127
125
  journal_dir.mkdir(parents=True, exist_ok=True)
128
- # no pid file written
129
126
 
130
127
  with (
131
- patch.object(ht, "JOURNAL_DIR", journal_dir),
132
- patch.object(ht, "JOURNAL_PID_PATH", pid_path),
128
+ patch.object(base, "JOURNAL_DIR", journal_dir),
129
+ patch.object(base, "JOURNAL_PID_PATH", pid_path),
133
130
  patch("subprocess.Popen") as mock_popen,
134
131
  ):
135
- ht._ensure_drain_daemon()
132
+ base.ensure_drain_daemon(hook_module_path=Path(__file__))
136
133
  mock_popen.assert_called_once()
137
134
  args = mock_popen.call_args[0][0]
138
135
  assert args[-1] == "drain"
@@ -145,9 +142,9 @@ def test_ensure_drain_spawns_when_pid_stale(tmp_path):
145
142
  pid_path.write_text("99999999") # almost certainly dead
146
143
 
147
144
  with (
148
- patch.object(ht, "JOURNAL_DIR", journal_dir),
149
- patch.object(ht, "JOURNAL_PID_PATH", pid_path),
145
+ patch.object(base, "JOURNAL_DIR", journal_dir),
146
+ patch.object(base, "JOURNAL_PID_PATH", pid_path),
150
147
  patch("subprocess.Popen") as mock_popen,
151
148
  ):
152
- ht._ensure_drain_daemon()
149
+ base.ensure_drain_daemon(hook_module_path=Path(__file__))
153
150
  mock_popen.assert_called_once()
@@ -1,21 +1,29 @@
1
- """Tests for #855-A (signed policy) and #855-C (fail-mode meta-rule)."""
1
+ """Tests for signed policy (#855-A) and fail-mode meta-rule (#855-C).
2
+
3
+ Functions under test now live in conduct_cli.hooks.pretooluse;
4
+ shared constants (SIGNING_KEY_PATH, etc.) live in conduct_cli.hooks.base.
5
+ Patch targets follow Python's import-time binding rules:
6
+ - Constants used inside pretooluse functions → patch on `pre`
7
+ - Constants used inside base functions (load_config) → patch on `base`
8
+ """
2
9
  from __future__ import annotations
3
10
  import hashlib
4
11
  import hmac
5
12
  import json
6
13
  import time
14
+ from contextlib import ExitStack, contextmanager
7
15
  from pathlib import Path
8
16
  from unittest.mock import MagicMock, patch
9
17
 
10
18
  import pytest
11
19
 
12
- import conduct_cli.hook_template as ht
20
+ import conduct_cli.hooks.base as base
21
+ import conduct_cli.hooks.pretooluse as pre
13
22
 
14
23
 
15
24
  # ── helpers ───────────────────────────────────────────────────────────────────
16
25
 
17
26
  def _make_key() -> tuple[bytes, str]:
18
- """Return (key_bytes, hex_string) for a fresh signing key."""
19
27
  key = b"deadbeef" * 4 # 32 bytes
20
28
  return key, key.hex()
21
29
 
@@ -32,7 +40,15 @@ def _write_key(tmp_path: Path, key_hex: str) -> Path:
32
40
  return p
33
41
 
34
42
 
35
- # ── #855-A: _verify_policy_signature ─────────────────────────────────────────
43
+ # ── _verify_policy_signature ──────────────────────────────────────────────────
44
+
45
+ def _patch_verify(tmp_path, key_path):
46
+ """Patch both SIGNING_KEY_PATH and GUARD_DIR so path-traversal check passes."""
47
+ return (
48
+ patch.object(pre, "SIGNING_KEY_PATH", key_path),
49
+ patch.object(pre, "GUARD_DIR", tmp_path),
50
+ )
51
+
36
52
 
37
53
  def test_verify_passes_with_valid_signature(tmp_path):
38
54
  key_bytes, key_hex = _make_key()
@@ -40,11 +56,8 @@ def test_verify_passes_with_valid_signature(tmp_path):
40
56
  policy = {"version": "v1", "rules": []}
41
57
  policy["signature"] = _sign_policy(policy, key_bytes)
42
58
 
43
- with (
44
- patch.object(ht, "SIGNING_KEY_PATH", key_path),
45
- patch.object(ht, "GUARD_DIR", tmp_path),
46
- ):
47
- assert ht._verify_policy_signature(policy) is True
59
+ with _ctx(*_patch_verify(tmp_path, key_path)):
60
+ assert pre._verify_policy_signature(policy) is True
48
61
 
49
62
 
50
63
  def test_verify_fails_with_wrong_signature(tmp_path):
@@ -52,11 +65,8 @@ def test_verify_fails_with_wrong_signature(tmp_path):
52
65
  key_path = _write_key(tmp_path, key_hex)
53
66
  policy = {"version": "v1", "rules": [], "signature": "badbadbadbad"}
54
67
 
55
- with (
56
- patch.object(ht, "SIGNING_KEY_PATH", key_path),
57
- patch.object(ht, "GUARD_DIR", tmp_path),
58
- ):
59
- assert ht._verify_policy_signature(policy) is False
68
+ with _ctx(*_patch_verify(tmp_path, key_path)):
69
+ assert pre._verify_policy_signature(policy) is False
60
70
 
61
71
 
62
72
  def test_verify_fails_when_signature_missing_and_key_present(tmp_path):
@@ -64,11 +74,8 @@ def test_verify_fails_when_signature_missing_and_key_present(tmp_path):
64
74
  key_path = _write_key(tmp_path, key_hex)
65
75
  policy = {"version": "v1", "rules": []} # no "signature" key
66
76
 
67
- with (
68
- patch.object(ht, "SIGNING_KEY_PATH", key_path),
69
- patch.object(ht, "GUARD_DIR", tmp_path),
70
- ):
71
- assert ht._verify_policy_signature(policy) is False
77
+ with _ctx(*_patch_verify(tmp_path, key_path)):
78
+ assert pre._verify_policy_signature(policy) is False
72
79
 
73
80
 
74
81
  def test_verify_passes_when_no_key_file(tmp_path):
@@ -76,38 +83,27 @@ def test_verify_passes_when_no_key_file(tmp_path):
76
83
  key_path = tmp_path / "signing.key" # does not exist
77
84
  policy = {"version": "v1", "rules": [], "signature": "anything"}
78
85
 
79
- with (
80
- patch.object(ht, "SIGNING_KEY_PATH", key_path),
81
- patch.object(ht, "GUARD_DIR", tmp_path),
82
- ):
83
- assert ht._verify_policy_signature(policy) is True
86
+ with _ctx(*_patch_verify(tmp_path, key_path)):
87
+ assert pre._verify_policy_signature(policy) is True
84
88
 
85
89
 
86
90
  def test_verify_strips_signature_and_signed_at_before_computing(tmp_path):
87
- """signed_at and signature fields must not be part of the signed body."""
88
91
  key_bytes, key_hex = _make_key()
89
92
  key_path = _write_key(tmp_path, key_hex)
90
93
  policy = {"version": "v1", "rules": [], "signed_at": "2026-01-01T00:00:00Z"}
91
94
  policy["signature"] = _sign_policy(policy, key_bytes)
92
- # Add a different signed_at should still verify because it's excluded from body
93
- policy["signed_at"] = "2030-12-31T00:00:00Z"
95
+ policy["signed_at"] = "2030-12-31T00:00:00Z" # mutated after signing
94
96
 
95
- with (
96
- patch.object(ht, "SIGNING_KEY_PATH", key_path),
97
- patch.object(ht, "GUARD_DIR", tmp_path),
98
- ):
99
- assert ht._verify_policy_signature(policy) is True
97
+ with _ctx(*_patch_verify(tmp_path, key_path)):
98
+ assert pre._verify_policy_signature(policy) is True
100
99
 
101
100
 
102
- # ── #855-C: fail-mode meta-rule in _maybe_sync_policy ────────────────────────
101
+ # ── _maybe_sync_policy ────────────────────────────────────────────────────────
103
102
 
104
103
  def _setup_sync(tmp_path, local_policy, remote_policy, key_hex=None):
105
- """
106
- Wire up _maybe_sync_policy with mocked HTTP + config + paths.
107
- Returns the path objects so callers can assert on POLICY_PATH.
108
- """
109
- config_path = tmp_path / "config.json"
110
- policy_path = tmp_path / "policy.json"
104
+ """Wire _maybe_sync_policy with mocked HTTP + config + paths."""
105
+ config_path = tmp_path / "config.json"
106
+ policy_path = tmp_path / "policy.json"
111
107
  version_cache = tmp_path / "version_cache.json"
112
108
 
113
109
  config_path.write_text(json.dumps({
@@ -118,85 +114,80 @@ def _setup_sync(tmp_path, local_policy, remote_policy, key_hex=None):
118
114
  if local_policy is not None:
119
115
  policy_path.write_text(json.dumps(local_policy))
120
116
 
121
- # Mock urllib so "remote" is returned without a real HTTP call
122
117
  mock_resp = MagicMock()
123
118
  mock_resp.read.return_value = json.dumps(remote_policy).encode()
124
119
  mock_resp.__enter__ = lambda s: s
125
120
  mock_resp.__exit__ = MagicMock(return_value=False)
126
121
 
122
+ signing_key_path = tmp_path / "signing.key"
123
+ if key_hex:
124
+ signing_key_path.write_text(key_hex)
125
+
127
126
  patches = [
128
- patch.object(ht, "CONFIG_PATH", config_path),
129
- patch.object(ht, "POLICY_PATH", policy_path),
130
- patch.object(ht, "VERSION_CACHE_PATH", version_cache),
131
- patch.object(ht, "SIGNING_KEY_PATH", tmp_path / "signing.key"),
132
- patch.object(ht, "GUARD_DIR", tmp_path),
127
+ patch.object(base, "CONFIG_PATH", config_path), # for load_config()
128
+ patch.object(pre, "active_policy_path", return_value=policy_path),
129
+ patch.object(pre, "VERSION_CACHE_PATH", version_cache),
130
+ patch.object(pre, "SIGNING_KEY_PATH", signing_key_path),
131
+ patch.object(pre, "GUARD_DIR", tmp_path), # for path-traversal check
133
132
  patch("urllib.request.urlopen", return_value=mock_resp),
134
- patch.object(ht, "_daemon_alive", return_value=False),
135
- # Disable journal side-effect from _post_signature_invalid_event
136
- patch("subprocess.Popen"),
133
+ patch.object(pre, "_daemon_alive", return_value=False),
134
+ patch("subprocess.Popen"), # suppress _post_signature_invalid_event side-effect
137
135
  ]
138
- if key_hex:
139
- (tmp_path / "signing.key").write_text(key_hex)
140
-
141
136
  return policy_path, patches
142
137
 
143
138
 
144
139
  def test_sync_rejects_tampered_policy(tmp_path):
145
- """Tampered remote policy (bad sig) must not overwrite local policy."""
146
140
  key_bytes, key_hex = _make_key()
147
- local = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
141
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
148
142
  remote = {"version": "v2", "rules": [], "signature": "badhash"}
149
143
 
150
144
  policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
151
145
  with _ctx(*patches):
152
- ht._maybe_sync_policy()
146
+ pre._maybe_sync_policy()
153
147
 
154
- saved = json.loads(policy_path.read_text())
155
- assert saved["version"] == "v1", "tampered remote should not replace local policy"
148
+ assert json.loads(policy_path.read_text())["version"] == "v1"
156
149
 
157
150
 
158
151
  def test_sync_accepts_valid_signed_policy(tmp_path):
159
152
  key_bytes, key_hex = _make_key()
160
- local = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
153
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
161
154
  remote = {"version": "v2", "rules": [], "fail_mode": "fail_open"}
162
155
  remote["signature"] = _sign_policy(remote, key_bytes)
163
156
 
164
157
  policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
165
158
  with _ctx(*patches):
166
- ht._maybe_sync_policy()
159
+ pre._maybe_sync_policy()
167
160
 
168
- saved = json.loads(policy_path.read_text())
169
- assert saved["version"] == "v2"
161
+ assert json.loads(policy_path.read_text())["version"] == "v2"
170
162
 
171
163
 
172
164
  def test_failmode_downgrade_blocked_without_token(tmp_path):
173
- """fail_closed → fail_open must be rejected if no downgrade token matches."""
174
165
  key_bytes, key_hex = _make_key()
175
- local = {"version": "v1", "rules": [], "fail_mode": "fail_closed",
176
- "fail_mode_downgrade_token": "secret-abc"}
166
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_closed",
167
+ "fail_mode_downgrade_token": "secret-abc"}
177
168
  remote = {"version": "v2", "rules": [], "fail_mode": "fail_open"}
178
169
  remote["signature"] = _sign_policy(remote, key_bytes)
179
170
 
180
171
  policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
181
172
  with _ctx(*patches):
182
- ht._maybe_sync_policy()
173
+ pre._maybe_sync_policy()
183
174
 
184
175
  saved = json.loads(policy_path.read_text())
185
- assert saved["fail_mode"] == "fail_closed", "downgrade without token must be rejected"
176
+ assert saved["fail_mode"] == "fail_closed"
186
177
  assert saved["version"] == "v1"
187
178
 
188
179
 
189
180
  def test_failmode_downgrade_blocked_with_wrong_token(tmp_path):
190
181
  key_bytes, key_hex = _make_key()
191
- local = {"version": "v1", "rules": [], "fail_mode": "fail_closed",
192
- "fail_mode_downgrade_token": "secret-abc"}
182
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_closed",
183
+ "fail_mode_downgrade_token": "secret-abc"}
193
184
  remote = {"version": "v2", "rules": [], "fail_mode": "fail_open",
194
185
  "fail_mode_downgrade_token": "wrong-token"}
195
186
  remote["signature"] = _sign_policy(remote, key_bytes)
196
187
 
197
188
  policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
198
189
  with _ctx(*patches):
199
- ht._maybe_sync_policy()
190
+ pre._maybe_sync_policy()
200
191
 
201
192
  saved = json.loads(policy_path.read_text())
202
193
  assert saved["fail_mode"] == "fail_closed"
@@ -205,15 +196,15 @@ def test_failmode_downgrade_blocked_with_wrong_token(tmp_path):
205
196
 
206
197
  def test_failmode_downgrade_allowed_with_correct_token(tmp_path):
207
198
  key_bytes, key_hex = _make_key()
208
- local = {"version": "v1", "rules": [], "fail_mode": "fail_closed",
209
- "fail_mode_downgrade_token": "secret-abc"}
199
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_closed",
200
+ "fail_mode_downgrade_token": "secret-abc"}
210
201
  remote = {"version": "v2", "rules": [], "fail_mode": "fail_open",
211
202
  "fail_mode_downgrade_token": "secret-abc"}
212
203
  remote["signature"] = _sign_policy(remote, key_bytes)
213
204
 
214
205
  policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
215
206
  with _ctx(*patches):
216
- ht._maybe_sync_policy()
207
+ pre._maybe_sync_policy()
217
208
 
218
209
  saved = json.loads(policy_path.read_text())
219
210
  assert saved["fail_mode"] == "fail_open"
@@ -221,24 +212,21 @@ def test_failmode_downgrade_allowed_with_correct_token(tmp_path):
221
212
 
222
213
 
223
214
  def test_failmode_open_to_closed_always_allowed(tmp_path):
224
- """Upgrading to fail_closed needs no token — only downgrade is gated."""
225
215
  key_bytes, key_hex = _make_key()
226
- local = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
216
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
227
217
  remote = {"version": "v2", "rules": [], "fail_mode": "fail_closed"}
228
218
  remote["signature"] = _sign_policy(remote, key_bytes)
229
219
 
230
220
  policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
231
221
  with _ctx(*patches):
232
- ht._maybe_sync_policy()
222
+ pre._maybe_sync_policy()
233
223
 
234
224
  saved = json.loads(policy_path.read_text())
235
225
  assert saved["fail_mode"] == "fail_closed"
236
226
  assert saved["version"] == "v2"
237
227
 
238
228
 
239
- # ── tiny helper so we can unpack a list of context managers ──────────────────
240
-
241
- from contextlib import contextmanager, ExitStack
229
+ # ── helper ────────────────────────────────────────────────────────────────────
242
230
 
243
231
  @contextmanager
244
232
  def _ctx(*cms):
@@ -1,19 +0,0 @@
1
- """Validate that all hook template files are syntactically valid Python.
2
-
3
- This test exists to catch the class of bug where escape sequences in embedded
4
- string literals break generated hook files. Since templates are now real .py
5
- files, py_compile catches any issue immediately.
6
- """
7
- import py_compile
8
- from pathlib import Path
9
-
10
- TEMPLATES_DIR = Path(__file__).parent.parent / "src" / "conduct_cli"
11
-
12
- def test_hook_template_syntax():
13
- py_compile.compile(str(TEMPLATES_DIR / "hook_template.py"), doraise=True)
14
-
15
- def test_precompact_template_syntax():
16
- py_compile.compile(str(TEMPLATES_DIR / "hook_precompact_template.py"), doraise=True)
17
-
18
- def test_session_start_template_syntax():
19
- py_compile.compile(str(TEMPLATES_DIR / "hook_session_start_template.py"), doraise=True)
File without changes
File without changes
File without changes