conduct-cli 0.6.6__tar.gz → 0.6.9__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 (34) hide show
  1. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/PKG-INFO +25 -1
  2. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/README.md +24 -0
  3. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/pyproject.toml +1 -1
  4. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/guard.py +13 -1
  5. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/hook_template.py +87 -14
  6. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/PKG-INFO +25 -1
  7. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  8. conduct_cli-0.6.9/tests/test_journal_drain.py +153 -0
  9. conduct_cli-0.6.9/tests/test_signed_policy.py +248 -0
  10. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/setup.cfg +0 -0
  11. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/setup.py +0 -0
  12. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/__init__.py +0 -0
  13. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/api.py +0 -0
  14. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/guardmcp.py +0 -0
  15. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/hook_precompact_template.py +0 -0
  16. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/hook_session_start_template.py +0 -0
  17. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/hook_stop_template.py +0 -0
  18. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/log_util.py +0 -0
  19. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/main.py +0 -0
  20. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/mcp_server.py +0 -0
  21. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/memory.py +0 -0
  22. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/paxel.py +0 -0
  23. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  24. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  25. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/requires.txt +0 -0
  26. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/top_level.txt +0 -0
  27. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_bash_operator_signature.py +0 -0
  28. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_command_word_matcher.py +0 -0
  29. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_guard_policy.py +0 -0
  30. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_guard_savings.py +0 -0
  31. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_hook_syntax.py +0 -0
  32. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_log_util.py +0 -0
  33. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_proxy_env.py +0 -0
  34. {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.6.6
3
+ Version: 0.6.9
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -25,6 +25,7 @@ Requires-Dist: rich>=13.0
25
25
  Provides-Extra: booster
26
26
  Requires-Dist: agent-booster[watch]>=0.2.23; extra == "booster"
27
27
 
28
+ <!-- mcp-name: io.github.sseshachala/conduct-cli -->
28
29
  # conduct-cli
29
30
 
30
31
  Official CLI for [Conduct AI](https://conductai.ai) — install AI agents, manage projects, run end-to-end tests, and enforce team AI policies with ConductGuard.
@@ -261,6 +262,29 @@ conduct guard sync
261
262
 
262
263
  ---
263
264
 
265
+ ## Claude Desktop
266
+
267
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
268
+
269
+ ```json
270
+ {
271
+ "mcpServers": {
272
+ "conduct": {
273
+ "command": "conduct-mcp",
274
+ "args": []
275
+ },
276
+ "conductguard": {
277
+ "command": "conductguard-mcp",
278
+ "args": ["--team", "<workspace-id>", "--token", "<member-token>"]
279
+ }
280
+ }
281
+ }
282
+ ```
283
+
284
+ Run `conduct login` first — `conduct-mcp` reads credentials from `~/.conduct/config.json`. Get your member token from **Settings → API Keys** in the Conduct AI dashboard.
285
+
286
+ ---
287
+
264
288
  ## Links
265
289
 
266
290
  - Dashboard: [conductai.ai](https://conductai.ai)
@@ -1,3 +1,4 @@
1
+ <!-- mcp-name: io.github.sseshachala/conduct-cli -->
1
2
  # conduct-cli
2
3
 
3
4
  Official CLI for [Conduct AI](https://conductai.ai) — install AI agents, manage projects, run end-to-end tests, and enforce team AI policies with ConductGuard.
@@ -234,6 +235,29 @@ conduct guard sync
234
235
 
235
236
  ---
236
237
 
238
+ ## Claude Desktop
239
+
240
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
241
+
242
+ ```json
243
+ {
244
+ "mcpServers": {
245
+ "conduct": {
246
+ "command": "conduct-mcp",
247
+ "args": []
248
+ },
249
+ "conductguard": {
250
+ "command": "conductguard-mcp",
251
+ "args": ["--team", "<workspace-id>", "--token", "<member-token>"]
252
+ }
253
+ }
254
+ }
255
+ ```
256
+
257
+ Run `conduct login` first — `conduct-mcp` reads credentials from `~/.conduct/config.json`. Get your member token from **Settings → API Keys** in the Conduct AI dashboard.
258
+
259
+ ---
260
+
237
261
  ## Links
238
262
 
239
263
  - Dashboard: [conductai.ai](https://conductai.ai)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.6.6"
7
+ version = "0.6.9"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1221,11 +1221,23 @@ def _write_proxy_env(member_token: str, proxy_url: str) -> tuple[Path, bool]:
1221
1221
  return Path(), False
1222
1222
 
1223
1223
  existing = rc.read_text() if rc.exists() else ""
1224
+ CLAUDE_ALIAS = "alias claude='env -u ANTHROPIC_BASE_URL claude'"
1225
+
1224
1226
  if SHELL_RC_MARKER in existing:
1227
+ # Already installed — patch in the alias if missing (upgrade path)
1228
+ if CLAUDE_ALIAS not in existing:
1229
+ rc.write_text(existing.rstrip() + f"\n{CLAUDE_ALIAS}\n")
1230
+ return rc, True
1225
1231
  return rc, False
1226
1232
 
1227
1233
  rc.parent.mkdir(parents=True, exist_ok=True)
1228
- addition = f"\n\n{SHELL_RC_MARKER}\n{SHELL_SOURCE_LINE}\n"
1234
+ # ponytail: alias strips ANTHROPIC_BASE_URL so Claude Code auth isn't routed
1235
+ # through the Guard proxy (Claude Code governs agents; it isn't one)
1236
+ addition = (
1237
+ f"\n\n{SHELL_RC_MARKER}\n"
1238
+ f"{SHELL_SOURCE_LINE}\n"
1239
+ f"{CLAUDE_ALIAS}\n"
1240
+ )
1229
1241
  rc.write_text(existing.rstrip() + addition if existing else addition.lstrip())
1230
1242
  return rc, True
1231
1243
 
@@ -39,6 +39,8 @@ VERSION_CACHE_PATH = GUARD_DIR / "version_cache.json"
39
39
  VERSION_CACHE_TTL = 60 # 1 minute — matches server poll window
40
40
  WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
41
41
  SIGNING_KEY_PATH = GUARD_DIR / "signing.key"
42
+ JOURNAL_DIR = GUARD_DIR / "journal"
43
+ JOURNAL_PID_PATH = JOURNAL_DIR / "drain.pid"
42
44
 
43
45
 
44
46
  _DAEMON_URL = "http://127.0.0.1:7878"
@@ -415,6 +417,87 @@ def _record_session_warn(session_id: str, rule_id: str) -> None:
415
417
  pass
416
418
 
417
419
 
420
+ def _journal_append(payload_str, api_url):
421
+ """Atomically write one event to the journal for the drain daemon to pick up."""
422
+ try:
423
+ JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
424
+ import time, random
425
+ name = f"{time.time_ns()}_{random.randint(0, 9999):04d}.json"
426
+ entry = json.dumps({"api_url": api_url, "payload": payload_str})
427
+ tmp = JOURNAL_DIR / (name + ".tmp")
428
+ tmp.write_text(entry)
429
+ tmp.rename(JOURNAL_DIR / name)
430
+ except Exception:
431
+ pass
432
+
433
+
434
+ def _ensure_drain_daemon():
435
+ """Start the drain daemon if it isn't already running."""
436
+ try:
437
+ if JOURNAL_PID_PATH.exists():
438
+ pid = int(JOURNAL_PID_PATH.read_text().strip())
439
+ import os as _os
440
+ try:
441
+ _os.kill(pid, 0)
442
+ return # alive
443
+ except (ProcessLookupError, PermissionError):
444
+ pass
445
+ hook_path = Path(__file__).resolve()
446
+ subprocess.Popen(
447
+ [sys.executable, str(hook_path), "drain"],
448
+ stdout=subprocess.DEVNULL,
449
+ stderr=subprocess.DEVNULL,
450
+ start_new_session=True,
451
+ )
452
+ except Exception:
453
+ pass
454
+
455
+
456
+ def _run_drain_daemon():
457
+ """Drain journal → API. Called as: python hook.py drain"""
458
+ import os as _os, time as _time
459
+ try:
460
+ JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
461
+ JOURNAL_PID_PATH.write_text(str(_os.getpid()))
462
+ except Exception:
463
+ return
464
+ empty_scans = 0
465
+ while empty_scans < 3:
466
+ files = sorted(f for f in JOURNAL_DIR.glob("*.json") if f.name != "drain.pid")
467
+ if not files:
468
+ empty_scans += 1
469
+ _time.sleep(2)
470
+ continue
471
+ posted_any = False
472
+ for f in files:
473
+ try:
474
+ entry = json.loads(f.read_text())
475
+ api_url = entry["api_url"]
476
+ payload = entry["payload"].encode() if isinstance(entry["payload"], str) else entry["payload"]
477
+ import urllib.request as _ur
478
+ req = _ur.Request(
479
+ f"{api_url}/guard/events",
480
+ data=payload,
481
+ headers={"Content-Type": "application/json"},
482
+ method="POST",
483
+ )
484
+ _ur.urlopen(req, timeout=8)
485
+ f.unlink(missing_ok=True)
486
+ posted_any = True
487
+ except Exception:
488
+ pass # ponytail: leave file on failure, next scan retries
489
+ # If every POST failed this scan, count it — prevents infinite loop on dead API
490
+ if not posted_any:
491
+ empty_scans += 1
492
+ _time.sleep(2)
493
+ else:
494
+ empty_scans = 0
495
+ try:
496
+ JOURNAL_PID_PATH.unlink(missing_ok=True)
497
+ except Exception:
498
+ pass
499
+
500
+
418
501
  def _post_event(tool_name, tool_input, decision, rule_id=None, message=None, session_id=None):
419
502
  try:
420
503
  cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
@@ -441,20 +524,8 @@ def _post_event(tool_name, tool_input, decision, rule_id=None, message=None, ses
441
524
  "hostname": _platform.node(),
442
525
  })
443
526
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
444
- script = (
445
- "import urllib.request\n"
446
- "try:\n"
447
- f" req = urllib.request.Request(\"{api_url}/guard/events\","
448
- f" data={repr(payload.encode())}, headers={{\"Content-Type\": \"application/json\"}}, method=\"POST\")\n"
449
- " urllib.request.urlopen(req, timeout=5)\n"
450
- "except: pass\n"
451
- )
452
- subprocess.Popen(
453
- [sys.executable, "-c", script],
454
- stdout=subprocess.DEVNULL,
455
- stderr=subprocess.DEVNULL,
456
- start_new_session=True,
457
- )
527
+ _journal_append(payload, api_url)
528
+ _ensure_drain_daemon()
458
529
 
459
530
 
460
531
  def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms):
@@ -906,5 +977,7 @@ if __name__ == "__main__":
906
977
  post_usage_main()
907
978
  elif len(sys.argv) > 1 and sys.argv[1] == "post-codex":
908
979
  post_codex_main()
980
+ elif len(sys.argv) > 1 and sys.argv[1] == "drain":
981
+ _run_drain_daemon()
909
982
  else:
910
983
  main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.6.6
3
+ Version: 0.6.9
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -25,6 +25,7 @@ Requires-Dist: rich>=13.0
25
25
  Provides-Extra: booster
26
26
  Requires-Dist: agent-booster[watch]>=0.2.23; extra == "booster"
27
27
 
28
+ <!-- mcp-name: io.github.sseshachala/conduct-cli -->
28
29
  # conduct-cli
29
30
 
30
31
  Official CLI for [Conduct AI](https://conductai.ai) — install AI agents, manage projects, run end-to-end tests, and enforce team AI policies with ConductGuard.
@@ -261,6 +262,29 @@ conduct guard sync
261
262
 
262
263
  ---
263
264
 
265
+ ## Claude Desktop
266
+
267
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
268
+
269
+ ```json
270
+ {
271
+ "mcpServers": {
272
+ "conduct": {
273
+ "command": "conduct-mcp",
274
+ "args": []
275
+ },
276
+ "conductguard": {
277
+ "command": "conductguard-mcp",
278
+ "args": ["--team", "<workspace-id>", "--token", "<member-token>"]
279
+ }
280
+ }
281
+ }
282
+ ```
283
+
284
+ Run `conduct login` first — `conduct-mcp` reads credentials from `~/.conduct/config.json`. Get your member token from **Settings → API Keys** in the Conduct AI dashboard.
285
+
286
+ ---
287
+
264
288
  ## Links
265
289
 
266
290
  - Dashboard: [conductai.ai](https://conductai.ai)
@@ -25,6 +25,8 @@ tests/test_command_word_matcher.py
25
25
  tests/test_guard_policy.py
26
26
  tests/test_guard_savings.py
27
27
  tests/test_hook_syntax.py
28
+ tests/test_journal_drain.py
28
29
  tests/test_log_util.py
29
30
  tests/test_proxy_env.py
31
+ tests/test_signed_policy.py
30
32
  tests/test_switch.py
@@ -0,0 +1,153 @@
1
+ """Tests for #855-B: journal append + drain daemon logic."""
2
+ from __future__ import annotations
3
+ import json
4
+ import os
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from unittest.mock import MagicMock, patch
9
+ import pytest
10
+
11
+ import conduct_cli.hook_template as ht
12
+
13
+
14
+ # ── helpers ──────────────────────────────────────────────────────────────────
15
+
16
+ def _patch_journal(tmp_path: Path):
17
+ """Return context managers that redirect JOURNAL_DIR/PID_PATH to tmp_path."""
18
+ journal_dir = tmp_path / "journal"
19
+ pid_path = journal_dir / "drain.pid"
20
+ return (
21
+ patch.object(ht, "JOURNAL_DIR", journal_dir),
22
+ patch.object(ht, "JOURNAL_PID_PATH", pid_path),
23
+ )
24
+
25
+
26
+ # ── _journal_append ───────────────────────────────────────────────────────────
27
+
28
+ def test_journal_append_creates_file(tmp_path):
29
+ p1, p2 = _patch_journal(tmp_path)
30
+ with p1, p2:
31
+ ht._journal_append('{"foo": 1}', "https://api.test")
32
+ journal_dir = tmp_path / "journal"
33
+ files = list(journal_dir.glob("*.json"))
34
+ assert len(files) == 1
35
+ entry = json.loads(files[0].read_text())
36
+ assert entry["payload"] == '{"foo": 1}'
37
+ assert entry["api_url"] == "https://api.test"
38
+
39
+
40
+ def test_journal_append_no_tmp_files_left(tmp_path):
41
+ p1, p2 = _patch_journal(tmp_path)
42
+ with p1, p2:
43
+ ht._journal_append("{}", "https://api.test")
44
+ journal_dir = tmp_path / "journal"
45
+ assert not list(journal_dir.glob("*.tmp"))
46
+
47
+
48
+ # ── _run_drain_daemon ─────────────────────────────────────────────────────────
49
+
50
+ def _write_journal_entry(journal_dir: Path, payload: str, api_url: str) -> Path:
51
+ journal_dir.mkdir(parents=True, exist_ok=True)
52
+ name = f"{time.time_ns()}_0000.json"
53
+ f = journal_dir / name
54
+ f.write_text(json.dumps({"api_url": api_url, "payload": payload}))
55
+ return f
56
+
57
+
58
+ def test_drain_posts_and_deletes_on_success(tmp_path):
59
+ journal_dir = tmp_path / "journal"
60
+ pid_path = journal_dir / "drain.pid"
61
+ entry = _write_journal_entry(journal_dir, '{"x":1}', "https://api.test")
62
+
63
+ mock_resp = MagicMock()
64
+ with (
65
+ patch.object(ht, "JOURNAL_DIR", journal_dir),
66
+ patch.object(ht, "JOURNAL_PID_PATH", pid_path),
67
+ patch("urllib.request.urlopen", return_value=mock_resp),
68
+ patch("time.sleep"), # skip sleeps
69
+ ):
70
+ ht._run_drain_daemon()
71
+
72
+ assert not entry.exists(), "file should be deleted after successful POST"
73
+
74
+
75
+ def test_drain_leaves_file_on_network_failure(tmp_path):
76
+ journal_dir = tmp_path / "journal"
77
+ pid_path = journal_dir / "drain.pid"
78
+ entry = _write_journal_entry(journal_dir, '{"x":1}', "https://api.test")
79
+
80
+ with (
81
+ patch.object(ht, "JOURNAL_DIR", journal_dir),
82
+ patch.object(ht, "JOURNAL_PID_PATH", pid_path),
83
+ patch("urllib.request.urlopen", side_effect=OSError("network down")),
84
+ patch("time.sleep"), # skip sleeps so daemon exits after 3 all-fail scans
85
+ ):
86
+ ht._run_drain_daemon()
87
+
88
+ assert entry.exists(), "file should survive a failed POST"
89
+
90
+
91
+ def test_drain_exits_after_three_empty_scans(tmp_path):
92
+ journal_dir = tmp_path / "journal"
93
+ pid_path = journal_dir / "drain.pid"
94
+ journal_dir.mkdir(parents=True, exist_ok=True)
95
+ sleep_calls = []
96
+
97
+ with (
98
+ patch.object(ht, "JOURNAL_DIR", journal_dir),
99
+ patch.object(ht, "JOURNAL_PID_PATH", pid_path),
100
+ patch("time.sleep", side_effect=lambda s: sleep_calls.append(s)),
101
+ ):
102
+ ht._run_drain_daemon()
103
+
104
+ assert len(sleep_calls) == 3, "should sleep exactly 3 times before exiting"
105
+
106
+
107
+ # ── _ensure_drain_daemon ──────────────────────────────────────────────────────
108
+
109
+ def test_ensure_drain_skips_spawn_when_alive(tmp_path):
110
+ journal_dir = tmp_path / "journal"
111
+ pid_path = journal_dir / "drain.pid"
112
+ journal_dir.mkdir(parents=True, exist_ok=True)
113
+ pid_path.write_text(str(os.getpid())) # our own PID — definitely alive
114
+
115
+ with (
116
+ patch.object(ht, "JOURNAL_DIR", journal_dir),
117
+ patch.object(ht, "JOURNAL_PID_PATH", pid_path),
118
+ patch("subprocess.Popen") as mock_popen,
119
+ ):
120
+ ht._ensure_drain_daemon()
121
+ mock_popen.assert_not_called()
122
+
123
+
124
+ def test_ensure_drain_spawns_when_pid_missing(tmp_path):
125
+ journal_dir = tmp_path / "journal"
126
+ pid_path = journal_dir / "drain.pid"
127
+ journal_dir.mkdir(parents=True, exist_ok=True)
128
+ # no pid file written
129
+
130
+ with (
131
+ patch.object(ht, "JOURNAL_DIR", journal_dir),
132
+ patch.object(ht, "JOURNAL_PID_PATH", pid_path),
133
+ patch("subprocess.Popen") as mock_popen,
134
+ ):
135
+ ht._ensure_drain_daemon()
136
+ mock_popen.assert_called_once()
137
+ args = mock_popen.call_args[0][0]
138
+ assert args[-1] == "drain"
139
+
140
+
141
+ def test_ensure_drain_spawns_when_pid_stale(tmp_path):
142
+ journal_dir = tmp_path / "journal"
143
+ pid_path = journal_dir / "drain.pid"
144
+ journal_dir.mkdir(parents=True, exist_ok=True)
145
+ pid_path.write_text("99999999") # almost certainly dead
146
+
147
+ with (
148
+ patch.object(ht, "JOURNAL_DIR", journal_dir),
149
+ patch.object(ht, "JOURNAL_PID_PATH", pid_path),
150
+ patch("subprocess.Popen") as mock_popen,
151
+ ):
152
+ ht._ensure_drain_daemon()
153
+ mock_popen.assert_called_once()
@@ -0,0 +1,248 @@
1
+ """Tests for #855-A (signed policy) and #855-C (fail-mode meta-rule)."""
2
+ from __future__ import annotations
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import pytest
11
+
12
+ import conduct_cli.hook_template as ht
13
+
14
+
15
+ # ── helpers ───────────────────────────────────────────────────────────────────
16
+
17
+ def _make_key() -> tuple[bytes, str]:
18
+ """Return (key_bytes, hex_string) for a fresh signing key."""
19
+ key = b"deadbeef" * 4 # 32 bytes
20
+ return key, key.hex()
21
+
22
+
23
+ def _sign_policy(policy: dict, key_bytes: bytes) -> str:
24
+ body = {k: v for k, v in policy.items() if k not in ("signature", "signed_at")}
25
+ canonical = json.dumps(body, sort_keys=True, separators=(",", ":"))
26
+ return hmac.new(key_bytes, canonical.encode(), hashlib.sha256).hexdigest()
27
+
28
+
29
+ def _write_key(tmp_path: Path, key_hex: str) -> Path:
30
+ p = tmp_path / "signing.key"
31
+ p.write_text(key_hex)
32
+ return p
33
+
34
+
35
+ # ── #855-A: _verify_policy_signature ─────────────────────────────────────────
36
+
37
+ def test_verify_passes_with_valid_signature(tmp_path):
38
+ key_bytes, key_hex = _make_key()
39
+ key_path = _write_key(tmp_path, key_hex)
40
+ policy = {"version": "v1", "rules": []}
41
+ policy["signature"] = _sign_policy(policy, key_bytes)
42
+
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
48
+
49
+
50
+ def test_verify_fails_with_wrong_signature(tmp_path):
51
+ _, key_hex = _make_key()
52
+ key_path = _write_key(tmp_path, key_hex)
53
+ policy = {"version": "v1", "rules": [], "signature": "badbadbadbad"}
54
+
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
60
+
61
+
62
+ def test_verify_fails_when_signature_missing_and_key_present(tmp_path):
63
+ _, key_hex = _make_key()
64
+ key_path = _write_key(tmp_path, key_hex)
65
+ policy = {"version": "v1", "rules": []} # no "signature" key
66
+
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
72
+
73
+
74
+ def test_verify_passes_when_no_key_file(tmp_path):
75
+ """No signing.key → dev mode, always allow."""
76
+ key_path = tmp_path / "signing.key" # does not exist
77
+ policy = {"version": "v1", "rules": [], "signature": "anything"}
78
+
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
84
+
85
+
86
+ 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
+ key_bytes, key_hex = _make_key()
89
+ key_path = _write_key(tmp_path, key_hex)
90
+ policy = {"version": "v1", "rules": [], "signed_at": "2026-01-01T00:00:00Z"}
91
+ 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"
94
+
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
100
+
101
+
102
+ # ── #855-C: fail-mode meta-rule in _maybe_sync_policy ────────────────────────
103
+
104
+ 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"
111
+ version_cache = tmp_path / "version_cache.json"
112
+
113
+ config_path.write_text(json.dumps({
114
+ "workspace_id": "ws-1",
115
+ "api_key": "tok",
116
+ "api_url": "https://api.test",
117
+ }))
118
+ if local_policy is not None:
119
+ policy_path.write_text(json.dumps(local_policy))
120
+
121
+ # Mock urllib so "remote" is returned without a real HTTP call
122
+ mock_resp = MagicMock()
123
+ mock_resp.read.return_value = json.dumps(remote_policy).encode()
124
+ mock_resp.__enter__ = lambda s: s
125
+ mock_resp.__exit__ = MagicMock(return_value=False)
126
+
127
+ 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),
133
+ 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"),
137
+ ]
138
+ if key_hex:
139
+ (tmp_path / "signing.key").write_text(key_hex)
140
+
141
+ return policy_path, patches
142
+
143
+
144
+ def test_sync_rejects_tampered_policy(tmp_path):
145
+ """Tampered remote policy (bad sig) must not overwrite local policy."""
146
+ key_bytes, key_hex = _make_key()
147
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
148
+ remote = {"version": "v2", "rules": [], "signature": "badhash"}
149
+
150
+ policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
151
+ with _ctx(*patches):
152
+ ht._maybe_sync_policy()
153
+
154
+ saved = json.loads(policy_path.read_text())
155
+ assert saved["version"] == "v1", "tampered remote should not replace local policy"
156
+
157
+
158
+ def test_sync_accepts_valid_signed_policy(tmp_path):
159
+ key_bytes, key_hex = _make_key()
160
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
161
+ remote = {"version": "v2", "rules": [], "fail_mode": "fail_open"}
162
+ remote["signature"] = _sign_policy(remote, key_bytes)
163
+
164
+ policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
165
+ with _ctx(*patches):
166
+ ht._maybe_sync_policy()
167
+
168
+ saved = json.loads(policy_path.read_text())
169
+ assert saved["version"] == "v2"
170
+
171
+
172
+ def test_failmode_downgrade_blocked_without_token(tmp_path):
173
+ """fail_closed → fail_open must be rejected if no downgrade token matches."""
174
+ key_bytes, key_hex = _make_key()
175
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_closed",
176
+ "fail_mode_downgrade_token": "secret-abc"}
177
+ remote = {"version": "v2", "rules": [], "fail_mode": "fail_open"}
178
+ remote["signature"] = _sign_policy(remote, key_bytes)
179
+
180
+ policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
181
+ with _ctx(*patches):
182
+ ht._maybe_sync_policy()
183
+
184
+ saved = json.loads(policy_path.read_text())
185
+ assert saved["fail_mode"] == "fail_closed", "downgrade without token must be rejected"
186
+ assert saved["version"] == "v1"
187
+
188
+
189
+ def test_failmode_downgrade_blocked_with_wrong_token(tmp_path):
190
+ key_bytes, key_hex = _make_key()
191
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_closed",
192
+ "fail_mode_downgrade_token": "secret-abc"}
193
+ remote = {"version": "v2", "rules": [], "fail_mode": "fail_open",
194
+ "fail_mode_downgrade_token": "wrong-token"}
195
+ remote["signature"] = _sign_policy(remote, key_bytes)
196
+
197
+ policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
198
+ with _ctx(*patches):
199
+ ht._maybe_sync_policy()
200
+
201
+ saved = json.loads(policy_path.read_text())
202
+ assert saved["fail_mode"] == "fail_closed"
203
+ assert saved["version"] == "v1"
204
+
205
+
206
+ def test_failmode_downgrade_allowed_with_correct_token(tmp_path):
207
+ key_bytes, key_hex = _make_key()
208
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_closed",
209
+ "fail_mode_downgrade_token": "secret-abc"}
210
+ remote = {"version": "v2", "rules": [], "fail_mode": "fail_open",
211
+ "fail_mode_downgrade_token": "secret-abc"}
212
+ remote["signature"] = _sign_policy(remote, key_bytes)
213
+
214
+ policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
215
+ with _ctx(*patches):
216
+ ht._maybe_sync_policy()
217
+
218
+ saved = json.loads(policy_path.read_text())
219
+ assert saved["fail_mode"] == "fail_open"
220
+ assert saved["version"] == "v2"
221
+
222
+
223
+ def test_failmode_open_to_closed_always_allowed(tmp_path):
224
+ """Upgrading to fail_closed needs no token — only downgrade is gated."""
225
+ key_bytes, key_hex = _make_key()
226
+ local = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
227
+ remote = {"version": "v2", "rules": [], "fail_mode": "fail_closed"}
228
+ remote["signature"] = _sign_policy(remote, key_bytes)
229
+
230
+ policy_path, patches = _setup_sync(tmp_path, local, remote, key_hex)
231
+ with _ctx(*patches):
232
+ ht._maybe_sync_policy()
233
+
234
+ saved = json.loads(policy_path.read_text())
235
+ assert saved["fail_mode"] == "fail_closed"
236
+ assert saved["version"] == "v2"
237
+
238
+
239
+ # ── tiny helper so we can unpack a list of context managers ──────────────────
240
+
241
+ from contextlib import contextmanager, ExitStack
242
+
243
+ @contextmanager
244
+ def _ctx(*cms):
245
+ with ExitStack() as stack:
246
+ for cm in cms:
247
+ stack.enter_context(cm)
248
+ yield
File without changes
File without changes