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.
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/PKG-INFO +25 -1
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/README.md +24 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/pyproject.toml +1 -1
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/guard.py +13 -1
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/hook_template.py +87 -14
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/PKG-INFO +25 -1
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- conduct_cli-0.6.9/tests/test_journal_drain.py +153 -0
- conduct_cli-0.6.9/tests/test_signed_policy.py +248 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/setup.cfg +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/setup.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/hook_precompact_template.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/hook_session_start_template.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/hook_stop_template.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_log_util.py +0 -0
- {conduct_cli-0.6.6 → conduct_cli-0.6.9}/tests/test_proxy_env.py +0 -0
- {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.
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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.
|
|
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
|
|
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
|