conduct-cli 0.6.37__tar.gz → 0.7.1__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 (39) hide show
  1. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/PKG-INFO +1 -1
  2. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/pyproject.toml +1 -1
  3. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/guard.py +182 -1
  4. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/pretooluse.py +9 -0
  5. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/main.py +7 -0
  6. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  7. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
  8. conduct_cli-0.7.1/tests/test_advisory_and_hook.py +565 -0
  9. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/README.md +0 -0
  10. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/setup.cfg +0 -0
  11. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/setup.py +0 -0
  12. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/__init__.py +0 -0
  13. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/api.py +0 -0
  14. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/guardmcp.py +0 -0
  15. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/__init__.py +0 -0
  16. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/base.py +0 -0
  17. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/posttooluse.py +0 -0
  18. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/precompact.py +0 -0
  19. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/session_parser.py +0 -0
  20. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/session_start.py +0 -0
  21. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/stop.py +0 -0
  22. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/log_util.py +0 -0
  23. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/mcp_server.py +0 -0
  24. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/memory.py +0 -0
  25. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/paxel.py +0 -0
  26. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  27. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  28. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/requires.txt +0 -0
  29. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/top_level.txt +0 -0
  30. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_bash_operator_signature.py +0 -0
  31. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_command_word_matcher.py +0 -0
  32. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_guard_policy.py +0 -0
  33. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_guard_savings.py +0 -0
  34. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_hook_syntax.py +0 -0
  35. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_journal_drain.py +0 -0
  36. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_log_util.py +0 -0
  37. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_proxy_env.py +0 -0
  38. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_signed_policy.py +0 -0
  39. {conduct_cli-0.6.37 → conduct_cli-0.7.1}/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.37
3
+ Version: 0.7.1
4
4
  Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.6.37"
7
+ version = "0.7.1"
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" }
@@ -751,9 +751,11 @@ def cmd_guard_install(args):
751
751
  # even when POLICY_PATH is missing or unreadable.
752
752
  cfg = _load_guard_config()
753
753
  cfg["fail_mode"] = policy.get("fail_mode", "fail_open")
754
+ cfg["advisory_mode"] = policy.get("advisory_mode", False)
754
755
  _save_guard_config(cfg)
755
756
  rule_count = len(policy.get("rules", []))
756
- print(f" {GREEN}Guard policies:{RESET} {rule_count} rule(s) active · fail mode: {cfg['fail_mode']}")
757
+ advisory_label = " · advisory" if cfg["advisory_mode"] else ""
758
+ print(f" {GREEN}Guard policies:{RESET} {rule_count} rule(s) active · fail mode: {cfg['fail_mode']}{advisory_label}")
757
759
  except SystemExit:
758
760
  rule_count = 0
759
761
 
@@ -2444,6 +2446,185 @@ def cmd_guard_debug_hook(args):
2444
2446
  print(f"\n{BOLD}Exit code:{RESET} {exit_label}")
2445
2447
 
2446
2448
 
2449
+ # ── OWASP Agentic Top 10 mapping ──────────────────────────────────────────────
2450
+
2451
+ _PRIV_KEYWORDS = ["privilege", "escalat", "role_abuse", "admin_access", "no-sudo", "nosudo", "runasroot"]
2452
+
2453
+ _OWASP_MAP: list[tuple[list[str], str, str]] = [
2454
+ (["prompt_inject", "injection", "jailbreak", "override_system", "indirect_prompt"],
2455
+ "A01", "Prompt Injection"),
2456
+ (["secret", "credential", "token_leak", "api_key_leak", "password", "sensitive_data", "pii", "exfil"],
2457
+ "A02", "Sensitive Information Disclosure"),
2458
+ (["supply_chain", "dependency", "package", "malicious_package", "unsigned"],
2459
+ "A03", "Supply Chain Vulnerabilities"),
2460
+ (["excessive_agency", "no-rm-rf", "norms", "rmrf", "scope", "overreach", "unauthorized_action", "out_of_scope", "shell_destruct"],
2461
+ "A04", "Excessive Agency"),
2462
+ (["model_theft", "model_extract", "weights"],
2463
+ "A05", "Model Theft"),
2464
+ (["communication", "callback", "exfiltrate_output", "covert_channel"],
2465
+ "A06", "Agentic Communication Vulnerabilities"),
2466
+ (["unmonitored", "no_audit", "policy_eval_error", "policy_signature_invalid"],
2467
+ "A07", "Insufficient Monitoring and Logging"),
2468
+ (["hallucin", "unverified", "over_reliance", "fabricat"],
2469
+ "A08", "Over-reliance on Model Outputs"),
2470
+ (_PRIV_KEYWORDS,
2471
+ "A09", "Privilege Escalation"),
2472
+ (["budget", "rate_limit", "dos", "denial", "hard_cap", "spend"],
2473
+ "A10", "Model Denial of Service"),
2474
+ ]
2475
+
2476
+ _OWASP_UNKNOWN = ("A??", "Uncategorised")
2477
+
2478
+
2479
+ def _map_owasp(rule_id: str) -> tuple[str, str]:
2480
+ key = (rule_id or "").lower()
2481
+ for keywords, code, title in _OWASP_MAP:
2482
+ if any(k in key for k in keywords):
2483
+ return code, title
2484
+ return _OWASP_UNKNOWN
2485
+
2486
+
2487
+ def cmd_verify(args) -> None:
2488
+ """conduct verify — map guard audit events to OWASP Agentic Top 10; exit non-zero in CI."""
2489
+ import json as _json
2490
+ import datetime as _dt
2491
+
2492
+ evidence_path = getattr(args, "evidence", None)
2493
+ strict = getattr(args, "strict", False)
2494
+ fmt = getattr(args, "format", "text")
2495
+ since_str = getattr(args, "since", None) or "24h"
2496
+
2497
+ # ── load events ──────────────────────────────────────────────────────────
2498
+ if evidence_path:
2499
+ try:
2500
+ raw = _json.loads(Path(evidence_path).read_text())
2501
+ except FileNotFoundError:
2502
+ print(f"{RED}✗ File not found: {evidence_path}{RESET}", file=sys.stderr)
2503
+ sys.exit(2)
2504
+ except _json.JSONDecodeError as exc:
2505
+ print(f"{RED}✗ Invalid JSON: {exc}{RESET}", file=sys.stderr)
2506
+ sys.exit(2)
2507
+ events = raw if isinstance(raw, list) else raw.get("events", [])
2508
+ else:
2509
+ cfg = _require_guard_config()
2510
+ workspace_id = cfg.get("workspace_id")
2511
+ user_email = cfg.get("user_email", "")
2512
+ api_key = cfg.get("api_key", "")
2513
+ base_url = _api_url(cfg)
2514
+ since_iso = _parse_since(since_str)
2515
+ resp = _req(
2516
+ "GET",
2517
+ (
2518
+ f"{base_url}/guard/events"
2519
+ f"?workspace_id={workspace_id}"
2520
+ f"&user_email={user_email}"
2521
+ f"&since={since_iso}"
2522
+ f"&limit=200"
2523
+ ),
2524
+ api_key=api_key,
2525
+ )
2526
+ events = resp if isinstance(resp, list) else resp.get("events", [])
2527
+
2528
+ if not events:
2529
+ if fmt == "json":
2530
+ print(_json.dumps({"status": "pass", "violations": 0, "findings": []}, indent=2))
2531
+ else:
2532
+ print(f"{GREEN}✓ No guard events found — nothing to verify.{RESET}")
2533
+ return
2534
+
2535
+ # ── classify ──────────────────────────────────────────────────────────────
2536
+ findings: list[dict] = []
2537
+ for ev in events:
2538
+ decision = ev.get("decision", "allowed")
2539
+ rule_id = ev.get("rule_id") or ev.get("rule_message") or ""
2540
+ owasp_code, owasp_title = _map_owasp(rule_id)
2541
+ findings.append({
2542
+ "timestamp": (ev.get("timestamp") or ev.get("created_at") or "")[:19].replace("T", " ") or "—",
2543
+ "tool": ev.get("ai_tool") or "—",
2544
+ "action": (ev.get("tool_call") or "—")[:40],
2545
+ "decision": decision,
2546
+ "rule_id": rule_id or "—",
2547
+ "owasp": owasp_code,
2548
+ "owasp_title": owasp_title,
2549
+ "entry_hash": (ev.get("entry_hash") or "")[:12] or None,
2550
+ "policy_hash": (ev.get("policy_hash") or "")[:12] or None,
2551
+ })
2552
+
2553
+ blocked_count = sum(1 for f in findings if f["decision"] == "blocked")
2554
+ warned_count = sum(1 for f in findings if f["decision"] == "warned")
2555
+ total = len(findings)
2556
+
2557
+ owasp_counts: dict[str, int] = {}
2558
+ for f in findings:
2559
+ if f["decision"] in ("blocked", "warned"):
2560
+ owasp_counts[f["owasp"]] = owasp_counts.get(f["owasp"], 0) + 1
2561
+
2562
+ # ── output ───────────────────────────────────────────────────────────────
2563
+ if fmt == "json":
2564
+ out = {
2565
+ "status": "fail" if (strict and blocked_count) else "pass",
2566
+ "generated_at": _dt.datetime.utcnow().isoformat() + "Z",
2567
+ "summary": {"total": total, "blocked": blocked_count, "warned": warned_count},
2568
+ "owasp_distribution": owasp_counts,
2569
+ "findings": findings,
2570
+ }
2571
+ print(_json.dumps(out, indent=2))
2572
+ else:
2573
+ print()
2574
+ print(f"{BOLD}conduct verify — OWASP Agentic Top 10 Report{RESET}")
2575
+ print("─" * 78)
2576
+
2577
+ if owasp_counts:
2578
+ print(f"\n{BOLD}OWASP Distribution{RESET}")
2579
+ for _kws, code, title in _OWASP_MAP:
2580
+ count = owasp_counts.get(code, 0)
2581
+ if count:
2582
+ bar = "█" * min(count, 20)
2583
+ label = f"{code}: {title}"
2584
+ print(f" {label:<40} {RED}{bar} {count}{RESET}")
2585
+ unk = owasp_counts.get("A??", 0)
2586
+ if unk:
2587
+ print(f" {'A??: Uncategorised':<40} {GRAY}{'█' * min(unk, 20)} {unk}{RESET}")
2588
+
2589
+ print(f"\n{BOLD}{'Timestamp':<20} {'Tool':<14} {'Decision':<10} {'OWASP':<5} Rule{RESET}")
2590
+ print("─" * 78)
2591
+ for f in findings:
2592
+ if f["decision"] == "blocked":
2593
+ dec_col = f"{RED}blocked{RESET}"
2594
+ elif f["decision"] == "warned":
2595
+ dec_col = f"{YELLOW}warned{RESET}"
2596
+ elif f["decision"] == "audited":
2597
+ dec_col = f"{BLUE}audited{RESET}"
2598
+ else:
2599
+ dec_col = f"{GRAY}{f['decision']}{RESET}"
2600
+ chain_note = f" {GRAY}chain:{f['entry_hash']}{RESET}" if f["entry_hash"] else ""
2601
+ print(
2602
+ f" {f['timestamp']:<20} {f['tool']:<14} {dec_col:<10} "
2603
+ f"{f['owasp']:<5} {f['rule_id']}{chain_note}"
2604
+ )
2605
+
2606
+ print()
2607
+ if blocked_count:
2608
+ status_icon = f"{RED}✗{RESET}"
2609
+ status_label = f"{RED}FAIL{RESET}" if strict else f"{YELLOW}WARN{RESET}"
2610
+ else:
2611
+ status_icon = f"{GREEN}✓{RESET}"
2612
+ status_label = f"{GREEN}PASS{RESET}"
2613
+
2614
+ print(
2615
+ f" {status_icon} {status_label} "
2616
+ f"{total} events · "
2617
+ f"{RED if blocked_count else GRAY}{blocked_count} blocked{RESET} · "
2618
+ f"{YELLOW if warned_count else GRAY}{warned_count} warned{RESET}"
2619
+ )
2620
+ if strict and blocked_count:
2621
+ print(f"\n {RED}Strict mode: non-zero exit — {blocked_count} blocked event(s).{RESET}")
2622
+ print()
2623
+
2624
+ if strict and blocked_count:
2625
+ sys.exit(1)
2626
+
2627
+
2447
2628
  def dispatch_guard(args, guard_p):
2448
2629
  """Dispatch to the correct guard handler. Called from main()."""
2449
2630
  guard_command = getattr(args, "guard_command", None)
@@ -224,6 +224,11 @@ def _get_fail_mode() -> str:
224
224
  return mode if mode in ("fail_open", "fail_closed") else "fail_open"
225
225
 
226
226
 
227
+ def _get_advisory_mode() -> bool:
228
+ cfg = load_config()
229
+ return bool(cfg.get("advisory_mode", False))
230
+
231
+
227
232
  # ── Budget cache ──────────────────────────────────────────────────────────────
228
233
 
229
234
  def _load_budget_cache():
@@ -452,6 +457,10 @@ def main() -> None:
452
457
 
453
458
  _, action, rule_id, message = check_policy(tool_name, tool_input)
454
459
 
460
+ if _get_advisory_mode() and action in ("block", "warn", "approval"):
461
+ post_event(tool_name, tool_input, "audited", rule_id, f"[advisory] {message}", session_id, drain_via=_this_file)
462
+ sys.exit(0)
463
+
455
464
  decision = {"block": "blocked", "warn": "warned", "approval": "blocked"}.get(action, "allowed")
456
465
  if action == "warn" and session_id and rule_id and _already_warned_this_session(session_id, rule_id):
457
466
  sys.exit(0)
@@ -3097,6 +3097,11 @@ def main():
3097
3097
  sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
3098
3098
 
3099
3099
  # conduct test-guard / test-security / test-security-verify
3100
+ verify_p = sub.add_parser("verify", help="Map guard events to OWASP Agentic Top 10 and verify chain integrity")
3101
+ verify_p.add_argument("--evidence", metavar="FILE", default=None, help="JSON evidence file (guard events). Omit to fetch from API.")
3102
+ verify_p.add_argument("--strict", action="store_true", help="Exit 1 if any blocked events found (CI mode)")
3103
+ verify_p.add_argument("--format", choices=["text", "json"], default="text", help="Output format (default: text)")
3104
+ verify_p.add_argument("--since", default="24h", help="Time window when fetching from API (e.g. 7d, 24h)")
3100
3105
  sub.add_parser("test-guard", help="Fire a synthetic event per guard policy rule and show decisions")
3101
3106
  sub.add_parser("test-security", help="Post a synthetic finding per security classifier pattern")
3102
3107
  sub.add_parser("test-security-verify", help="Post test findings and verify full triage pipeline end-to-end")
@@ -3190,6 +3195,8 @@ def main():
3190
3195
  cmd_skill(args)
3191
3196
  elif args.command == "sync":
3192
3197
  cmd_sync(args)
3198
+ elif args.command == "verify":
3199
+ _guard.cmd_verify(args)
3193
3200
  elif args.command == "test-guard":
3194
3201
  cmd_test_guard(args)
3195
3202
  elif args.command == "test-security":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.6.37
3
+ Version: 0.7.1
4
4
  Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -24,6 +24,7 @@ src/conduct_cli/hooks/pretooluse.py
24
24
  src/conduct_cli/hooks/session_parser.py
25
25
  src/conduct_cli/hooks/session_start.py
26
26
  src/conduct_cli/hooks/stop.py
27
+ tests/test_advisory_and_hook.py
27
28
  tests/test_bash_operator_signature.py
28
29
  tests/test_command_word_matcher.py
29
30
  tests/test_guard_policy.py
@@ -0,0 +1,565 @@
1
+ """
2
+ CLI tests covering advisory mode (P1), pretooluse hook flow, and conduct verify (P3).
3
+
4
+ Covers:
5
+ Advisory mode config:
6
+ - _get_advisory_mode returns False by default
7
+ - _get_advisory_mode returns True when advisory_mode set in config
8
+ - guard sync mirrors advisory_mode from policy into config
9
+
10
+ Advisory mode hook behavior:
11
+ - block rule + advisory ON → exits 0, posts "audited" decision
12
+ - block rule + advisory OFF → exits 2, posts "blocked" decision
13
+ - warn rule + advisory ON → exits 0, posts "audited" decision
14
+ - approval rule + advisory ON → exits 0, posts "audited" decision
15
+ - allow (no match) + advisory ON → exits 0, posts "allowed"
16
+
17
+ Fail-closed gate:
18
+ - fail_closed + no policy file → exits 2, posts "blocked" guard-unavailable
19
+ - fail_open + no policy file → passes through to policy check
20
+
21
+ conduct verify (P3):
22
+ - OWASP mapping covers all 10 categories + unknown fallback
23
+ - evidence file: pass with no blocked events → exit 0
24
+ - evidence file: blocked events + --strict → exit 1
25
+ - evidence file: blocked events without --strict → exit 0
26
+ - empty evidence → exit 0, no-op message
27
+ - bad file path → exit 2
28
+ - invalid JSON → exit 2
29
+ - --format json: status=fail when strict+blocked, status=pass otherwise
30
+ - chain hashes appear in output when present
31
+
32
+ Budget hard cap:
33
+ - hard_blocked=True → exits 2 regardless of advisory mode
34
+
35
+ Warn dedup:
36
+ - same session + same rule → exits 0 on second warn (already warned)
37
+ - different session → warns again
38
+
39
+ Policy eval error interaction with advisory (P4 × P1):
40
+ - policy eval throws + advisory OFF → exits 2 (fail-closed wins)
41
+ - policy eval throws + advisory ON → exits 2 (fail-closed wins; advisory only covers rule matches)
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ import sys
47
+ from io import StringIO
48
+ from pathlib import Path
49
+ from unittest.mock import MagicMock, patch, call
50
+
51
+ import pytest
52
+
53
+ CONDUCT_CLI_SRC = Path(__file__).resolve().parent.parent / "src"
54
+ if str(CONDUCT_CLI_SRC) not in sys.path:
55
+ sys.path.insert(0, str(CONDUCT_CLI_SRC))
56
+
57
+ import conduct_cli.guard as guard_mod
58
+ from conduct_cli.guard import POLICY_PATH, CONFIG_PATH
59
+
60
+
61
+ # ── helpers ───────────────────────────────────────────────────────────────────
62
+
63
+ def _hook_input(tool="Bash", cmd="echo hi", session="sess-1") -> str:
64
+ return json.dumps({
65
+ "tool_name": tool,
66
+ "tool_input": {"command": cmd},
67
+ "session_id": session,
68
+ })
69
+
70
+
71
+ def _block_policy(rule_id="no-echo", pattern="echo", action="block") -> dict:
72
+ return {
73
+ "version": "test",
74
+ "rules": [{"rule_id": rule_id, "match_tool": "*", "match_pattern": pattern, "action": action, "message": f"rule {rule_id}"}],
75
+ }
76
+
77
+
78
+ def _write(path: Path, data: dict) -> None:
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ path.write_text(json.dumps(data))
81
+
82
+
83
+ # ── advisory config ───────────────────────────────────────────────────────────
84
+
85
+ class TestGetAdvisoryMode:
86
+ def test_default_false(self, tmp_path, monkeypatch):
87
+ cfg = tmp_path / "config.json"
88
+ _write(cfg, {"workspace_id": "ws1"})
89
+ monkeypatch.setattr(guard_mod, "CONFIG_PATH", cfg)
90
+ import conduct_cli.hooks.pretooluse as pt
91
+ monkeypatch.setattr(pt, "load_config", lambda: {"workspace_id": "ws1"})
92
+ assert pt._get_advisory_mode() is False
93
+
94
+ def test_true_when_set(self, tmp_path, monkeypatch):
95
+ import conduct_cli.hooks.pretooluse as pt
96
+ monkeypatch.setattr(pt, "load_config", lambda: {"advisory_mode": True})
97
+ assert pt._get_advisory_mode() is True
98
+
99
+ def test_false_when_explicitly_false(self, tmp_path, monkeypatch):
100
+ import conduct_cli.hooks.pretooluse as pt
101
+ monkeypatch.setattr(pt, "load_config", lambda: {"advisory_mode": False})
102
+ assert pt._get_advisory_mode() is False
103
+
104
+
105
+ # ── advisory hook behavior ────────────────────────────────────────────────────
106
+
107
+ class TestAdvisoryHookBehavior:
108
+ """Patch check_policy to return a specific action, verify advisory bypass logic."""
109
+
110
+ def _run(self, monkeypatch, *, advisory: bool, action: str, rule_id: str = "r1", message: str = "msg"):
111
+ import conduct_cli.hooks.pretooluse as pt
112
+
113
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
114
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: advisory)
115
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
116
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
117
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
118
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
119
+ # Return the desired action regardless of policy file content
120
+ fake_rule = {"rule_id": rule_id, "action": action}
121
+ monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: (fake_rule, action, rule_id, message))
122
+
123
+ posted: list[dict] = []
124
+ monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: posted.append({"decision": d, "message": m}))
125
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
126
+
127
+ exit_code = None
128
+ try:
129
+ pt.main()
130
+ except SystemExit as e:
131
+ exit_code = e.code
132
+ return exit_code, posted
133
+
134
+ def test_block_advisory_on_exits_0(self, monkeypatch):
135
+ code, posted = self._run(monkeypatch, advisory=True, action="block")
136
+ assert code == 0
137
+ assert posted[0]["decision"] == "audited"
138
+
139
+ def test_block_advisory_off_exits_2(self, monkeypatch):
140
+ code, posted = self._run(monkeypatch, advisory=False, action="block")
141
+ assert code == 2
142
+ assert posted[0]["decision"] == "blocked"
143
+
144
+ def test_warn_advisory_on_exits_0(self, monkeypatch):
145
+ code, posted = self._run(monkeypatch, advisory=True, action="warn")
146
+ assert code == 0
147
+ assert posted[0]["decision"] == "audited"
148
+
149
+ def test_approval_advisory_on_exits_0(self, monkeypatch):
150
+ code, posted = self._run(monkeypatch, advisory=True, action="approval")
151
+ assert code == 0
152
+ assert posted[0]["decision"] == "audited"
153
+
154
+ def test_no_match_advisory_on_exits_0(self, monkeypatch):
155
+ """No rule match — exits 0 even without advisory."""
156
+ import conduct_cli.hooks.pretooluse as pt
157
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
158
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: True)
159
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
160
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
161
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
162
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
163
+ monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: (None, "allow", None, None))
164
+ monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
165
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
166
+
167
+ with pytest.raises(SystemExit) as exc:
168
+ pt.main()
169
+ assert exc.value.code == 0
170
+
171
+ def test_advisory_message_prefixed(self, monkeypatch):
172
+ import conduct_cli.hooks.pretooluse as pt
173
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
174
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: True)
175
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
176
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
177
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
178
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
179
+ monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: ({}, "block", "r1", "bad thing"))
180
+
181
+ messages: list[str] = []
182
+ monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: messages.append(m))
183
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
184
+
185
+ try:
186
+ pt.main()
187
+ except SystemExit:
188
+ pass
189
+ assert messages and messages[0].startswith("[advisory]")
190
+
191
+
192
+ # ── fail-closed gate ──────────────────────────────────────────────────────────
193
+
194
+ class TestFailClosedGate:
195
+ def test_fail_closed_no_policy_exits_2(self, monkeypatch, tmp_path):
196
+ import conduct_cli.hooks.pretooluse as pt
197
+
198
+ missing = tmp_path / "nowhere.json"
199
+ monkeypatch.setattr(pt, "active_policy_path", lambda: missing)
200
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_closed")
201
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
202
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
203
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
204
+
205
+ posted: list[dict] = []
206
+ def _fake_post(tn, ti, decision, rule_id, msg, sid, **kw):
207
+ posted.append({"decision": decision, "rule_id": rule_id})
208
+ monkeypatch.setattr(pt, "post_event", _fake_post)
209
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
210
+
211
+ with pytest.raises(SystemExit) as exc:
212
+ pt.main()
213
+ assert exc.value.code == 2
214
+ assert posted[0]["decision"] == "blocked"
215
+ assert posted[0]["rule_id"] == "guard-unavailable"
216
+
217
+ def test_fail_open_no_policy_passes(self, monkeypatch, tmp_path):
218
+ import conduct_cli.hooks.pretooluse as pt
219
+
220
+ missing = tmp_path / "nowhere.json"
221
+ monkeypatch.setattr(pt, "active_policy_path", lambda: missing)
222
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
223
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
224
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
225
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
226
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
227
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
228
+ monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
229
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
230
+
231
+ with pytest.raises(SystemExit) as exc:
232
+ pt.main()
233
+ assert exc.value.code == 0
234
+
235
+
236
+ # ── budget hard cap ───────────────────────────────────────────────────────────
237
+
238
+ class TestBudgetHardCap:
239
+ def test_hard_cap_exits_2(self, monkeypatch, tmp_path):
240
+ import conduct_cli.hooks.pretooluse as pt
241
+
242
+ policy_path = tmp_path / "policy.json"
243
+ _write(policy_path, {"version": "test", "rules": []})
244
+ monkeypatch.setattr(pt, "active_policy_path", lambda: policy_path)
245
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
246
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
247
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (True, "Monthly cap reached"))
248
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
249
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
250
+
251
+ posted: list[dict] = []
252
+ monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: posted.append({"decision": d, "rule_id": r}))
253
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
254
+
255
+ with pytest.raises(SystemExit) as exc:
256
+ pt.main()
257
+ assert exc.value.code == 2
258
+ assert posted[0]["decision"] == "blocked"
259
+ assert posted[0]["rule_id"] == "budget-hard-cap"
260
+
261
+ def test_hard_cap_wins_over_advisory(self, monkeypatch, tmp_path):
262
+ """Budget hard cap blocks even when advisory mode is on."""
263
+ import conduct_cli.hooks.pretooluse as pt
264
+
265
+ policy_path = tmp_path / "policy.json"
266
+ _write(policy_path, {"version": "test", "rules": []})
267
+ monkeypatch.setattr(pt, "active_policy_path", lambda: policy_path)
268
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
269
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: True) # advisory ON
270
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (True, "Cap reached"))
271
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
272
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
273
+ monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
274
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
275
+
276
+ with pytest.raises(SystemExit) as exc:
277
+ pt.main()
278
+ assert exc.value.code == 2
279
+
280
+
281
+ # ── warn dedup ────────────────────────────────────────────────────────────────
282
+
283
+ class TestWarnDedup:
284
+ def _run_warn(self, monkeypatch, session_id: str):
285
+ import conduct_cli.hooks.pretooluse as pt
286
+
287
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
288
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
289
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
290
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
291
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
292
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
293
+ monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: ({}, "warn", "rule-w1", "watch out"))
294
+ monkeypatch.setattr(pt, "_already_warned_this_session", lambda s, r: False)
295
+ monkeypatch.setattr(pt, "_record_session_warn", lambda s, r: None)
296
+
297
+ posted: list[str] = []
298
+ monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: posted.append(d))
299
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input(session=session_id)))
300
+
301
+ try:
302
+ pt.main()
303
+ except SystemExit:
304
+ pass
305
+ return posted
306
+
307
+ def test_first_warn_posts(self, monkeypatch):
308
+ posted = self._run_warn(monkeypatch, "sess-abc")
309
+ assert "warned" in posted
310
+
311
+ def test_second_warn_same_session_skips(self, monkeypatch, tmp_path):
312
+ import conduct_cli.hooks.pretooluse as pt
313
+ warns: dict[str, set] = {}
314
+
315
+ def _fake_already(session, rule):
316
+ return rule in warns.get(session, set())
317
+
318
+ def _fake_record(session, rule):
319
+ warns.setdefault(session, set()).add(rule)
320
+
321
+ monkeypatch.setattr(pt, "_already_warned_this_session", _fake_already)
322
+ monkeypatch.setattr(pt, "_record_session_warn", _fake_record)
323
+
324
+ # First call records the warn
325
+ _fake_record("sess-xyz", "no-echo")
326
+
327
+ monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: ({}, "warn", "no-echo", "watch out"))
328
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
329
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
330
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
331
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
332
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
333
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
334
+
335
+ posted: list[str] = []
336
+ monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: posted.append(d))
337
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input(session="sess-xyz")))
338
+
339
+ try:
340
+ pt.main()
341
+ except SystemExit:
342
+ pass
343
+
344
+ assert posted == [], "second warn same session should be skipped"
345
+
346
+
347
+ # ── advisory_mode mirrored on sync ────────────────────────────────────────────
348
+
349
+ class TestSyncMirrorsAdvisoryMode:
350
+ def test_advisory_true_written_to_config(self, tmp_path, monkeypatch):
351
+ cfg_path = tmp_path / "config.json"
352
+ pol_path = tmp_path / "policy.json"
353
+ _write(cfg_path, {"workspace_id": "ws1"})
354
+
355
+ monkeypatch.setattr(guard_mod, "CONFIG_PATH", cfg_path)
356
+ monkeypatch.setattr(guard_mod, "POLICY_PATH", pol_path)
357
+
358
+ policy = {"version": "v1", "rules": [], "fail_mode": "fail_open", "advisory_mode": True}
359
+ monkeypatch.setattr(guard_mod, "_load_guard_config", lambda: {"workspace_id": "ws1"})
360
+
361
+ saved: list[dict] = []
362
+ monkeypatch.setattr(guard_mod, "_save_guard_config", lambda cfg: saved.append(dict(cfg)))
363
+ monkeypatch.setattr(guard_mod, "_save_policy", lambda p: None)
364
+
365
+ # Simulate the sync block
366
+ cfg = guard_mod._load_guard_config()
367
+ cfg["fail_mode"] = policy.get("fail_mode", "fail_open")
368
+ cfg["advisory_mode"] = policy.get("advisory_mode", False)
369
+ guard_mod._save_guard_config(cfg)
370
+
371
+ assert saved[0]["advisory_mode"] is True
372
+
373
+ def test_advisory_false_default(self, tmp_path, monkeypatch):
374
+ monkeypatch.setattr(guard_mod, "_load_guard_config", lambda: {"workspace_id": "ws1"})
375
+ saved: list[dict] = []
376
+ monkeypatch.setattr(guard_mod, "_save_guard_config", lambda cfg: saved.append(dict(cfg)))
377
+ monkeypatch.setattr(guard_mod, "_save_policy", lambda p: None)
378
+
379
+ policy = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
380
+ cfg = guard_mod._load_guard_config()
381
+ cfg["fail_mode"] = policy.get("fail_mode", "fail_open")
382
+ cfg["advisory_mode"] = policy.get("advisory_mode", False)
383
+ guard_mod._save_guard_config(cfg)
384
+
385
+ assert saved[0]["advisory_mode"] is False
386
+
387
+
388
+ # ── conduct verify (P3) ──────────────────────────────────────────────────────
389
+
390
+ class TestOWASPMapping:
391
+ """_map_owasp covers all 10 categories and falls back to A??."""
392
+
393
+ def _map(self, rule_id: str) -> str:
394
+ from conduct_cli.guard import _map_owasp
395
+ return _map_owasp(rule_id)[0]
396
+
397
+ def test_a01_prompt_injection(self):
398
+ assert self._map("prompt_inject_tool") == "A01"
399
+
400
+ def test_a02_sensitive_data(self):
401
+ assert self._map("sensitive_data_read") == "A02"
402
+
403
+ def test_a03_supply_chain(self):
404
+ assert self._map("supply_chain_pkg") == "A03"
405
+
406
+ def test_a04_excessive_agency(self):
407
+ assert self._map("excessive_agency_shell") == "A04"
408
+
409
+ def test_a07_monitoring(self):
410
+ assert self._map("policy_eval_error") == "A07"
411
+
412
+ def test_a07_sig_invalid(self):
413
+ assert self._map("policy_signature_invalid") == "A07"
414
+
415
+ def test_a09_privilege(self):
416
+ assert self._map("privilege_escalat_detected") == "A09"
417
+
418
+ def test_a10_budget(self):
419
+ assert self._map("budget_hard_cap") == "A10"
420
+
421
+ def test_unknown_falls_back(self):
422
+ assert self._map("some_random_rule") == "A??"
423
+
424
+ def test_empty_rule_id(self):
425
+ assert self._map("") == "A??"
426
+
427
+
428
+ _EVIDENCE_BLOCKED = [
429
+ {"timestamp": "2026-07-06T10:00:00", "ai_tool": "Claude",
430
+ "tool_call": "Bash", "decision": "blocked",
431
+ "rule_id": "excessive_agency_shell",
432
+ "entry_hash": "abc123def456", "policy_hash": "pol789"},
433
+ ]
434
+
435
+ _EVIDENCE_WARN_ONLY = [
436
+ {"timestamp": "2026-07-06T10:01:00", "ai_tool": "Claude",
437
+ "tool_call": "Read", "decision": "warned",
438
+ "rule_id": "sensitive_data_read"},
439
+ ]
440
+
441
+ _EVIDENCE_CLEAN = [
442
+ {"timestamp": "2026-07-06T10:02:00", "ai_tool": "Claude",
443
+ "tool_call": "Bash", "decision": "allowed", "rule_id": None},
444
+ ]
445
+
446
+
447
+ class TestVerifyCommand:
448
+ def _run(self, args_list: list[str], capsys) -> int:
449
+ import argparse
450
+ from conduct_cli.guard import cmd_verify
451
+
452
+ parser = argparse.ArgumentParser()
453
+ parser.add_argument("--evidence", default=None)
454
+ parser.add_argument("--strict", action="store_true")
455
+ parser.add_argument("--format", default="text")
456
+ parser.add_argument("--since", default="24h")
457
+ args = parser.parse_args(args_list)
458
+
459
+ try:
460
+ cmd_verify(args)
461
+ return 0
462
+ except SystemExit as exc:
463
+ return int(exc.code) if exc.code is not None else 0
464
+
465
+ def test_clean_evidence_exits_0(self, tmp_path, capsys):
466
+ f = tmp_path / "ev.json"
467
+ f.write_text(__import__("json").dumps(_EVIDENCE_CLEAN))
468
+ code = self._run(["--evidence", str(f)], capsys)
469
+ assert code == 0
470
+
471
+ def test_blocked_without_strict_exits_0(self, tmp_path, capsys):
472
+ f = tmp_path / "ev.json"
473
+ f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED))
474
+ code = self._run(["--evidence", str(f)], capsys)
475
+ assert code == 0
476
+
477
+ def test_blocked_with_strict_exits_1(self, tmp_path, capsys):
478
+ f = tmp_path / "ev.json"
479
+ f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED))
480
+ code = self._run(["--evidence", str(f), "--strict"], capsys)
481
+ assert code == 1
482
+
483
+ def test_warn_only_with_strict_exits_0(self, tmp_path, capsys):
484
+ f = tmp_path / "ev.json"
485
+ f.write_text(__import__("json").dumps(_EVIDENCE_WARN_ONLY))
486
+ code = self._run(["--evidence", str(f), "--strict"], capsys)
487
+ assert code == 0
488
+
489
+ def test_missing_file_exits_2(self, tmp_path, capsys):
490
+ code = self._run(["--evidence", str(tmp_path / "missing.json")], capsys)
491
+ assert code == 2
492
+
493
+ def test_invalid_json_exits_2(self, tmp_path, capsys):
494
+ f = tmp_path / "bad.json"
495
+ f.write_text("not valid {{{")
496
+ code = self._run(["--evidence", str(f)], capsys)
497
+ assert code == 2
498
+
499
+ def test_empty_array_no_error(self, tmp_path, capsys):
500
+ f = tmp_path / "empty.json"
501
+ f.write_text("[]")
502
+ code = self._run(["--evidence", str(f)], capsys)
503
+ assert code == 0
504
+ out = capsys.readouterr().out
505
+ assert "nothing to verify" in out.lower() or "no guard events" in out.lower()
506
+
507
+ def test_json_format_status_fail_when_strict_and_blocked(self, tmp_path, capsys):
508
+ import json
509
+ f = tmp_path / "ev.json"
510
+ f.write_text(json.dumps(_EVIDENCE_BLOCKED))
511
+ self._run(["--evidence", str(f), "--strict", "--format", "json"], capsys)
512
+ out = capsys.readouterr().out
513
+ data = json.loads(out)
514
+ assert data["status"] == "fail"
515
+ assert data["summary"]["blocked"] == 1
516
+
517
+ def test_json_format_status_pass_without_strict(self, tmp_path, capsys):
518
+ import json
519
+ f = tmp_path / "ev.json"
520
+ f.write_text(json.dumps(_EVIDENCE_BLOCKED))
521
+ self._run(["--evidence", str(f), "--format", "json"], capsys)
522
+ out = capsys.readouterr().out
523
+ data = json.loads(out)
524
+ assert data["status"] == "pass"
525
+
526
+ def test_chain_hash_appears_in_text_output(self, tmp_path, capsys):
527
+ f = tmp_path / "ev.json"
528
+ f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED))
529
+ self._run(["--evidence", str(f)], capsys)
530
+ out = capsys.readouterr().out
531
+ assert "chain:abc123def456" in out
532
+
533
+ def test_owasp_distribution_shown(self, tmp_path, capsys):
534
+ f = tmp_path / "ev.json"
535
+ f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED + _EVIDENCE_WARN_ONLY))
536
+ self._run(["--evidence", str(f)], capsys)
537
+ out = capsys.readouterr().out
538
+ assert "A04" in out
539
+ assert "A02" in out
540
+
541
+ def test_envelope_json_input(self, tmp_path, capsys):
542
+ """Accept {'events': [...]} envelope as well as raw array."""
543
+ import json
544
+ f = tmp_path / "ev.json"
545
+ f.write_text(json.dumps({"events": _EVIDENCE_CLEAN}))
546
+ code = self._run(["--evidence", str(f)], capsys)
547
+ assert code == 0
548
+
549
+
550
+ # ── invalid stdin ─────────────────────────────────────────────────────────────
551
+
552
+ class TestInvalidStdin:
553
+ def test_malformed_json_exits_0(self, monkeypatch):
554
+ import conduct_cli.hooks.pretooluse as pt
555
+ monkeypatch.setattr(sys, "stdin", StringIO("not json {{{"))
556
+ with pytest.raises(SystemExit) as exc:
557
+ pt.main()
558
+ assert exc.value.code == 0
559
+
560
+ def test_empty_stdin_exits_0(self, monkeypatch):
561
+ import conduct_cli.hooks.pretooluse as pt
562
+ monkeypatch.setattr(sys, "stdin", StringIO(""))
563
+ with pytest.raises(SystemExit) as exc:
564
+ pt.main()
565
+ assert exc.value.code == 0
File without changes
File without changes
File without changes