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.
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/PKG-INFO +1 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/pyproject.toml +1 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/base.py +2 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/posttooluse.py +54 -7
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/SOURCES.txt +1 -1
- conduct_cli-0.7.11/tests/test_blast_radius.py +44 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_journal_drain.py +31 -34
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_signed_policy.py +65 -77
- conduct_cli-0.7.10/tests/test_hook_syntax.py +0 -19
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/README.md +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/setup.cfg +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/setup.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/guard.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/pretooluse.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.11}/tests/test_switch.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.7.
|
|
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
|
|
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)
|
|
@@ -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
|
|
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.
|
|
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(
|
|
22
|
-
patch.object(
|
|
19
|
+
patch.object(base, "JOURNAL_DIR", journal_dir),
|
|
20
|
+
patch.object(base, "JOURNAL_PID_PATH", pid_path),
|
|
23
21
|
)
|
|
24
22
|
|
|
25
23
|
|
|
26
|
-
# ──
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
# ──
|
|
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(
|
|
66
|
-
patch.object(
|
|
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"),
|
|
66
|
+
patch("time.sleep"),
|
|
69
67
|
):
|
|
70
|
-
|
|
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(
|
|
82
|
-
patch.object(
|
|
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"),
|
|
82
|
+
patch("time.sleep"),
|
|
85
83
|
):
|
|
86
|
-
|
|
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(
|
|
99
|
-
patch.object(
|
|
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
|
-
|
|
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
|
-
# ──
|
|
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())) #
|
|
111
|
+
pid_path.write_text(str(os.getpid())) # own PID — definitely alive
|
|
114
112
|
|
|
115
113
|
with (
|
|
116
|
-
patch.object(
|
|
117
|
-
patch.object(
|
|
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
|
-
|
|
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(
|
|
132
|
-
patch.object(
|
|
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
|
-
|
|
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(
|
|
149
|
-
patch.object(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
# ──
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
# ──
|
|
101
|
+
# ── _maybe_sync_policy ────────────────────────────────────────────────────────
|
|
103
102
|
|
|
104
103
|
def _setup_sync(tmp_path, local_policy, remote_policy, key_hex=None):
|
|
105
|
-
"""
|
|
106
|
-
|
|
107
|
-
|
|
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(
|
|
129
|
-
patch.object(
|
|
130
|
-
patch.object(
|
|
131
|
-
patch.object(
|
|
132
|
-
patch.object(
|
|
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(
|
|
135
|
-
#
|
|
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
|
|
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
|
-
|
|
146
|
+
pre._maybe_sync_policy()
|
|
153
147
|
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
159
|
+
pre._maybe_sync_policy()
|
|
167
160
|
|
|
168
|
-
|
|
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
|
|
176
|
-
|
|
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
|
-
|
|
173
|
+
pre._maybe_sync_policy()
|
|
183
174
|
|
|
184
175
|
saved = json.loads(policy_path.read_text())
|
|
185
|
-
assert saved["fail_mode"] == "fail_closed"
|
|
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
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
# ──
|
|
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
|
|
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
|