conduct-cli 0.7.11__tar.gz → 0.7.16__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 (43) hide show
  1. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/guard.py +121 -123
  4. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/guardmcp.py +20 -7
  5. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/base.py +83 -11
  6. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/pretooluse.py +10 -4
  7. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/main.py +7 -5
  8. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  9. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  10. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_advisory_and_hook.py +122 -81
  11. conduct_cli-0.7.16/tests/test_drain_daemon_health.py +206 -0
  12. conduct_cli-0.7.16/tests/test_proxy_coverage.py +79 -0
  13. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/README.md +0 -0
  14. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/setup.cfg +0 -0
  15. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/setup.py +0 -0
  16. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/__init__.py +0 -0
  17. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/api.py +0 -0
  18. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/__init__.py +0 -0
  19. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/posttooluse.py +0 -0
  20. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/precompact.py +0 -0
  21. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_parser.py +0 -0
  22. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_report_push.py +0 -0
  23. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_start.py +0 -0
  24. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/stop.py +0 -0
  25. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/log_util.py +0 -0
  26. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/mcp_server.py +0 -0
  27. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/memory.py +0 -0
  28. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/paxel.py +0 -0
  29. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  30. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  31. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/requires.txt +0 -0
  32. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/top_level.txt +0 -0
  33. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_bash_operator_signature.py +0 -0
  34. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_blast_radius.py +0 -0
  35. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_command_word_matcher.py +0 -0
  36. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_guard_policy.py +0 -0
  37. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_guard_savings.py +0 -0
  38. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_hook_dispatch.py +0 -0
  39. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_journal_drain.py +0 -0
  40. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_log_util.py +0 -0
  41. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_proxy_env.py +0 -0
  42. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_signed_policy.py +0 -0
  43. {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.11
3
+ Version: 0.7.16
4
4
  Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.7.11"
7
+ version = "0.7.16"
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" }
@@ -759,7 +759,7 @@ def cmd_guard_install(args):
759
759
  # Mirror fail_mode into guard config so the hook can enforce it
760
760
  # even when POLICY_PATH is missing or unreadable.
761
761
  cfg = _load_guard_config()
762
- cfg["fail_mode"] = policy.get("fail_mode", "fail_open")
762
+ cfg["fail_mode"] = policy.get("fail_mode", "fail_closed")
763
763
  cfg["advisory_mode"] = policy.get("advisory_mode", False)
764
764
  _save_guard_config(cfg)
765
765
  rule_count = len(policy.get("rules", []))
@@ -1722,9 +1722,49 @@ def cmd_guard_status(args):
1722
1722
 
1723
1723
  session_str = f"{proxy_sessions} proxy · {hook_sessions} direct"
1724
1724
 
1725
+ # Drain daemon health
1726
+ try:
1727
+ from conduct_cli.hooks.base import drain_daemon_status
1728
+ d_status, d_pid = drain_daemon_status()
1729
+ except Exception:
1730
+ d_status, d_pid = "unknown", None
1731
+
1732
+ if d_status == "running":
1733
+ daemon_line = f"{GREEN}running{RESET} (pid {d_pid})"
1734
+ elif d_status == "stale":
1735
+ daemon_line = f"{YELLOW}stale{RESET} (pid {d_pid}, not flushing — will restart on next tool call)"
1736
+ else:
1737
+ daemon_line = f"{RED}not running{RESET} — will start on next tool call, journal events queued"
1738
+
1739
+ # Proxy coverage — check active env vars in this shell
1740
+ proxy_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/") + "/proxy"
1741
+ _env = os.environ
1742
+ anthropic_ok = proxy_url in _env.get("ANTHROPIC_BASE_URL", "")
1743
+ openai_ok = proxy_url in _env.get("OPENAI_BASE_URL", "")
1744
+ perplexity_ok = proxy_url in _env.get("PERPLEXITY_BASE_URL","")
1745
+ env_file_exists = (Path.home() / ".conduct" / "env").exists()
1746
+
1747
+ def _cov(ok: bool, name: str) -> str:
1748
+ return f"{GREEN}{name}{RESET}" if ok else f"{YELLOW}{name}{RESET}"
1749
+
1750
+ covered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if ok]
1751
+ uncovered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if not ok]
1752
+
1753
+ if covered and not uncovered:
1754
+ proxy_line = f"{GREEN}active{RESET} — {', '.join(covered)} routed through Guard proxy"
1755
+ elif covered:
1756
+ proxy_line = f"{YELLOW}partial{RESET} — {', '.join(covered)} covered · {', '.join(uncovered)} NOT intercepted"
1757
+ elif env_file_exists:
1758
+ proxy_line = f"{YELLOW}inactive{RESET} — ~/.conduct/env exists but not sourced in this shell. Run: {BOLD}. ~/.conduct/env{RESET}"
1759
+ else:
1760
+ proxy_line = f"{RED}not configured{RESET} — run: {BOLD}conduct guard sync{RESET}"
1761
+
1725
1762
  print(f"\n{BOLD}Guard status{RESET} — {user_email}")
1726
1763
  print(f"{rule_count} polic{'y' if rule_count == 1 else 'ies'} active")
1727
1764
  print()
1765
+ print(f"Proxy coverage: {proxy_line}")
1766
+ print(f"Drain daemon: {daemon_line}")
1767
+ print()
1728
1768
  print(f"Today:")
1729
1769
  print(f" Sessions: {session_str}")
1730
1770
  print(f" Tokens used: {tokens_used:,} (saved {token_saved_pct}% via optimization)")
@@ -2508,144 +2548,102 @@ def _map_owasp(rule_id: str) -> tuple[str, str]:
2508
2548
  return _OWASP_UNKNOWN
2509
2549
 
2510
2550
 
2551
+ _GRADE_COLORS = {"A": "\033[32m", "B": "\033[32m", "C": "\033[33m", "D": "\033[33m", "F": "\033[31m"}
2552
+ _GRADE_ORDER = ["A", "B", "C", "D", "F"]
2553
+
2554
+
2555
+ def _grade_below(actual: str, minimum: str) -> bool:
2556
+ return _GRADE_ORDER.index(actual) > _GRADE_ORDER.index(minimum.upper())
2557
+
2558
+
2511
2559
  def cmd_verify(args) -> None:
2512
- """conduct verify — map guard audit events to OWASP Agentic Top 10; exit non-zero in CI."""
2560
+ """conduct verify — governance grade + OWASP Agentic Top 10 coverage."""
2513
2561
  import json as _json
2514
- import datetime as _dt
2515
2562
 
2516
- evidence_path = getattr(args, "evidence", None)
2517
- strict = getattr(args, "strict", False)
2518
- fmt = getattr(args, "format", "text")
2519
- since_str = getattr(args, "since", None) or "24h"
2563
+ evidence_out = getattr(args, "evidence", None)
2564
+ badge = getattr(args, "badge", False)
2565
+ min_grade = getattr(args, "min_grade", None)
2566
+ strict = getattr(args, "strict", False)
2567
+ fmt = getattr(args, "format", "text")
2520
2568
 
2521
- # ── load events ──────────────────────────────────────────────────────────
2522
- if evidence_path:
2523
- try:
2524
- raw = _json.loads(Path(evidence_path).read_text())
2525
- except FileNotFoundError:
2526
- print(f"{RED}✗ File not found: {evidence_path}{RESET}", file=sys.stderr)
2527
- sys.exit(2)
2528
- except _json.JSONDecodeError as exc:
2529
- print(f"{RED}✗ Invalid JSON: {exc}{RESET}", file=sys.stderr)
2530
- sys.exit(2)
2531
- events = raw if isinstance(raw, list) else raw.get("events", [])
2532
- else:
2533
- cfg = _require_guard_config()
2534
- workspace_id = cfg.get("workspace_id")
2535
- user_email = cfg.get("user_email", "")
2536
- api_key = cfg.get("api_key", "")
2537
- base_url = _api_url(cfg)
2538
- since_iso = _parse_since(since_str)
2539
- resp = _req(
2540
- "GET",
2541
- (
2542
- f"{base_url}/guard/events"
2543
- f"?workspace_id={workspace_id}"
2544
- f"&user_email={user_email}"
2545
- f"&since={since_iso}"
2546
- f"&limit=200"
2547
- ),
2548
- api_key=api_key,
2549
- )
2550
- events = resp if isinstance(resp, list) else resp.get("events", [])
2551
-
2552
- if not events:
2553
- if fmt == "json":
2554
- print(_json.dumps({"status": "pass", "violations": 0, "findings": []}, indent=2))
2555
- else:
2556
- print(f"{GREEN}✓ No guard events found — nothing to verify.{RESET}")
2557
- return
2569
+ cfg = _require_guard_config()
2570
+ workspace_id = cfg.get("workspace_id")
2571
+ api_key = cfg.get("api_key", "")
2572
+ base_url = _api_url(cfg)
2558
2573
 
2559
- # ── classify ──────────────────────────────────────────────────────────────
2560
- findings: list[dict] = []
2561
- for ev in events:
2562
- decision = ev.get("decision", "allowed")
2563
- rule_id = ev.get("rule_id") or ev.get("rule_message") or ""
2564
- owasp_code, owasp_title = _map_owasp(rule_id)
2565
- findings.append({
2566
- "timestamp": (ev.get("ts") or ev.get("timestamp") or ev.get("created_at") or "")[:19].replace("T", " ") or "—",
2567
- "tool": ev.get("ai_tool") or "—",
2568
- "action": (ev.get("tool_call") or "—")[:40],
2569
- "decision": decision,
2570
- "rule_id": rule_id or "—",
2571
- "owasp": owasp_code,
2572
- "owasp_title": owasp_title,
2573
- "entry_hash": (ev.get("entry_hash") or "")[:12] or None,
2574
- "policy_hash": (ev.get("policy_hash") or "")[:12] or None,
2575
- })
2574
+ # ── fetch evidence from API ───────────────────────────────────────────────
2575
+ ev = _req(
2576
+ "GET",
2577
+ f"{base_url}/guard/verify/evidence?workspace_id={workspace_id}",
2578
+ api_key=api_key,
2579
+ )
2576
2580
 
2577
- blocked_count = sum(1 for f in findings if f["decision"] == "blocked")
2578
- warned_count = sum(1 for f in findings if f["decision"] == "warned")
2579
- total = len(findings)
2581
+ grade = ev.get("grade", "F")
2582
+ coverage_pct = ev.get("coverage_pct", 0)
2583
+ score = ev.get("score", 0)
2584
+ blocked_24h = ev.get("blocked_24h", 0)
2585
+ controls = ev.get("controls", [])
2586
+ generated_at = ev.get("generated_at", "")
2587
+
2588
+ # ── --badge ───────────────────────────────────────────────────────────────
2589
+ if badge:
2590
+ color = {"A": "brightgreen", "B": "green", "C": "yellow", "D": "orange", "F": "red"}.get(grade, "lightgrey")
2591
+ print(f"![Conduct Guard](https://img.shields.io/badge/Guard-Grade_{grade}-{color})")
2592
+ return
2580
2593
 
2581
- owasp_counts: dict[str, int] = {}
2582
- for f in findings:
2583
- if f["decision"] in ("blocked", "warned"):
2584
- owasp_counts[f["owasp"]] = owasp_counts.get(f["owasp"], 0) + 1
2594
+ # ── --evidence (write artifact) ───────────────────────────────────────────
2595
+ if evidence_out:
2596
+ artifact = {
2597
+ "grade": grade,
2598
+ "coverage_pct": coverage_pct,
2599
+ "score": score,
2600
+ "blocked_24h": blocked_24h,
2601
+ "controls": controls,
2602
+ "generated_at": generated_at,
2603
+ "workspace_id": workspace_id,
2604
+ }
2605
+ Path(evidence_out).write_text(_json.dumps(artifact, indent=2))
2606
+ print(f"{GREEN}✓ Evidence artifact written to {evidence_out}{RESET}")
2585
2607
 
2586
- # ── output ───────────────────────────────────────────────────────────────
2608
+ # ── output ────────────────────────────────────────────────────────────────
2587
2609
  if fmt == "json":
2588
- out = {
2589
- "status": "fail" if (strict and blocked_count) else "pass",
2590
- "generated_at": _dt.datetime.utcnow().isoformat() + "Z",
2591
- "summary": {"total": total, "blocked": blocked_count, "warned": warned_count},
2592
- "owasp_distribution": owasp_counts,
2593
- "findings": findings,
2594
- }
2595
- print(_json.dumps(out, indent=2))
2610
+ print(_json.dumps(ev, indent=2))
2596
2611
  else:
2612
+ grade_color = _GRADE_COLORS.get(grade, GRAY)
2613
+ print()
2614
+ print(f"{BOLD}conduct verify — Governance Grade{RESET}")
2615
+ print("─" * 60)
2616
+ print(f"\n Grade: {grade_color}{BOLD}{grade}{RESET} (score {score}/100)")
2617
+ print(f" Coverage: {coverage_pct}% of OWASP ASI controls active")
2618
+ print(f" Blocked (24h): {blocked_24h}")
2597
2619
  print()
2598
- print(f"{BOLD}conduct verify — OWASP Agentic Top 10 Report{RESET}")
2599
- print("─" * 78)
2600
-
2601
- if owasp_counts:
2602
- print(f"\n{BOLD}OWASP Distribution{RESET}")
2603
- for _kws, code, title in _OWASP_MAP:
2604
- count = owasp_counts.get(code, 0)
2605
- if count:
2606
- bar = "█" * min(count, 20)
2607
- label = f"{code}: {title}"
2608
- print(f" {label:<40} {RED}{bar} {count}{RESET}")
2609
- unk = owasp_counts.get("A??", 0)
2610
- if unk:
2611
- print(f" {'A??: Uncategorised':<40} {GRAY}{'█' * min(unk, 20)} {unk}{RESET}")
2612
-
2613
- print(f"\n{BOLD}{'Timestamp':<20} {'Tool':<14} {'Decision':<10} {'OWASP':<5} Rule{RESET}")
2614
- print("─" * 78)
2615
- for f in findings:
2616
- if f["decision"] == "blocked":
2617
- dec_col = f"{RED}blocked{RESET}"
2618
- elif f["decision"] == "warned":
2619
- dec_col = f"{YELLOW}warned{RESET}"
2620
- elif f["decision"] == "audited":
2621
- dec_col = f"{BLUE}audited{RESET}"
2620
+ print(f"{BOLD} {'Control':<8} {'Status':<10} Name{RESET}")
2621
+ print(" " + "─" * 56)
2622
+ for c in controls:
2623
+ status = c.get("status", "missing")
2624
+ if status == "active":
2625
+ sc = f"{GREEN}active {RESET}"
2626
+ elif status == "partial":
2627
+ sc = f"{YELLOW}partial {RESET}"
2622
2628
  else:
2623
- dec_col = f"{GRAY}{f['decision']}{RESET}"
2624
- chain_note = f" {GRAY}chain:{f['entry_hash']}{RESET}" if f["entry_hash"] else ""
2625
- print(
2626
- f" {f['timestamp']:<20} {f['tool']:<14} {dec_col:<10} "
2627
- f"{f['owasp']:<5} {f['rule_id']}{chain_note}"
2628
- )
2629
-
2629
+ sc = f"{RED}missing {RESET}"
2630
+ print(f" {c.get('id',''):<8} {sc} {c.get('name','')}")
2630
2631
  print()
2631
- if blocked_count:
2632
- status_icon = f"{RED}✗{RESET}"
2633
- status_label = f"{RED}FAIL{RESET}" if strict else f"{YELLOW}WARN{RESET}"
2634
- else:
2635
- status_icon = f"{GREEN}✓{RESET}"
2636
- status_label = f"{GREEN}PASS{RESET}"
2637
2632
 
2638
- print(
2639
- f" {status_icon} {status_label} "
2640
- f"{total} events · "
2641
- f"{RED if blocked_count else GRAY}{blocked_count} blocked{RESET} · "
2642
- f"{YELLOW if warned_count else GRAY}{warned_count} warned{RESET}"
2643
- )
2644
- if strict and blocked_count:
2645
- print(f"\n {RED}Strict mode: non-zero exit — {blocked_count} blocked event(s).{RESET}")
2633
+ # CI checks
2634
+ failed = False
2635
+ if strict and blocked_24h > 0:
2636
+ print(f" {RED}✗ Strict mode: {blocked_24h} blocked event(s) in last 24h.{RESET}")
2637
+ failed = True
2638
+ if min_grade and _grade_below(grade, min_grade):
2639
+ print(f" {RED}✗ Grade {grade} is below minimum {min_grade.upper()}.{RESET}")
2640
+ failed = True
2641
+ if not failed:
2642
+ print(f" {GREEN}✓ PASS{RESET}")
2646
2643
  print()
2647
2644
 
2648
- if strict and blocked_count:
2645
+ # ── exit codes ────────────────────────────────────────────────────────────
2646
+ if (strict and blocked_24h > 0) or (min_grade and _grade_below(grade, min_grade)):
2649
2647
  sys.exit(1)
2650
2648
 
2651
2649
 
@@ -183,14 +183,23 @@ def _maybe_sync() -> None:
183
183
  def _detect_surface(client_info: dict) -> str:
184
184
  """Map MCP clientInfo.name → ai_tool label sent to Guard API."""
185
185
  name = (client_info.get("name") or "").lower()
186
- if "desktop" in name:
187
- return "claude_desktop"
186
+ # ── Anthropic ─────────────────────────────────────────────────────────────
187
+ if "code" in name and "claude" in name:
188
+ return "claude-code"
189
+ if "desktop" in name and "claude" in name:
190
+ return "claude-desktop"
188
191
  if "work" in name or "teams" in name or "enterprise" in name:
189
- return "claude_work"
192
+ return "claude-desktop" # Claude for Work / Teams
190
193
  if "claude" in name:
191
- return "claude_chat"
194
+ return "claude-ai" # claude.ai web
195
+ # ── OpenAI ────────────────────────────────────────────────────────────────
196
+ if "chatgpt" in name or "openai" in name:
197
+ return "openai-chatgpt"
198
+ if "codex" in name and "desktop" in name:
199
+ return "codex-desktop"
192
200
  if "codex" in name:
193
- return "codex"
201
+ return "codex-cli"
202
+ # ── Other IDEs ────────────────────────────────────────────────────────────
194
203
  if "cursor" in name:
195
204
  return "cursor"
196
205
  if "windsurf" in name:
@@ -259,9 +268,13 @@ def _match_policy(tool_name: str, tool_input: dict) -> dict | None:
259
268
  for rule in rules:
260
269
  match_tool = (rule.get("match_tool") or "*").lower()
261
270
  if match_tool != "*":
262
- allowed = [t.strip() for t in match_tool.split(",")]
263
- if tool_name.lower() not in allowed:
271
+ if tool_name.lower() not in [t.strip() for t in match_tool.split(",")]:
264
272
  continue
273
+ match_ai = rule.get("match_ai_tool")
274
+ if match_ai:
275
+ surfaces = [s.strip().lower() for s in match_ai.split(",")]
276
+ if not any(s in ai_tool.lower() for s in surfaces):
277
+ continue # rule targets a different surface
265
278
 
266
279
  pattern = rule.get("match_pattern")
267
280
  if pattern:
@@ -74,24 +74,38 @@ def detect_repo() -> Optional[str]:
74
74
 
75
75
 
76
76
  def detect_ai_tool() -> str:
77
- """Return a string identifying the active AI coding tool."""
77
+ """Return a string identifying the active AI coding tool / surface."""
78
78
  import os
79
+ # ── Anthropic ─────────────────────────────────────────────────────────────
79
80
  if os.environ.get("CLAUDE_CODE_ENTRYPOINT") or os.environ.get("CLAUDECODE"):
80
81
  return "claude-code"
81
- if os.environ.get("CODEX_SESSION_ID") or os.environ.get("CODEX_CLI_VERSION"):
82
- return "codex"
82
+ if os.environ.get("CLAUDE_DESKTOP_ENTRYPOINT") or os.environ.get("CLAUDE_DESKTOP"):
83
+ return "claude-desktop"
84
+ # ── OpenAI / Codex ────────────────────────────────────────────────────────
85
+ if os.environ.get("CODEX_CLI_VERSION") or os.environ.get("CODEX_SESSION_ID"):
86
+ return "codex-cli"
87
+ if os.environ.get("CODEX_DESKTOP") or os.environ.get("OPENAI_CODEX_DESKTOP"):
88
+ return "codex-desktop"
89
+ if os.environ.get("OPENAI_CHATGPT_MCP") or os.environ.get("CHATGPT_SESSION_ID"):
90
+ return "openai-chatgpt"
91
+ # ── TERM_PROGRAM ──────────────────────────────────────────────────────────
83
92
  term = os.environ.get("TERM_PROGRAM", "").lower()
84
93
  if term == "cursor":
85
94
  return "cursor"
86
95
  if term == "windsurf":
87
96
  return "windsurf"
97
+ # ── PATH heuristics ───────────────────────────────────────────────────────
88
98
  path = os.environ.get("PATH", "")
89
- if "Codex.app" in path or "codex" in path.lower():
90
- return "codex"
99
+ if "Codex.app" in path:
100
+ return "codex-desktop"
101
+ if "codex" in path.lower():
102
+ return "codex-cli"
91
103
  if "cursor" in path.lower():
92
104
  return "cursor"
93
105
  if "windsurf" in path.lower():
94
106
  return "windsurf"
107
+ if "claude" in path.lower():
108
+ return "claude-desktop"
95
109
  return "claude-code"
96
110
 
97
111
 
@@ -111,21 +125,68 @@ def journal_append(payload_str: str, api_url: str) -> None:
111
125
  pass
112
126
 
113
127
 
128
+ _DAEMON_STALE_SECS = 600 # PID file not touched in 10 min → daemon is stale
129
+ _POLICY_REFRESH_INTERVAL = 30 # daemon refreshes policy.json every 30 s
130
+
131
+
132
+ def _refresh_policy_from_api() -> None:
133
+ """Pull fresh policy from API and write to POLICY_PATH atomically."""
134
+ try:
135
+ cfg = load_config()
136
+ workspace_id = cfg.get("workspace_id")
137
+ api_key = cfg.get("api_key", "")
138
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
139
+ if not api_key:
140
+ return
141
+ url = f"{api_url}/guard/policies/sync"
142
+ if workspace_id:
143
+ url += f"?workspace_id={workspace_id}"
144
+ req = urllib.request.Request(url, headers={"X-Api-Key": api_key})
145
+ with urllib.request.urlopen(req, timeout=8) as resp:
146
+ data = resp.read()
147
+ POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
148
+ tmp = POLICY_PATH.with_suffix(".tmp")
149
+ tmp.write_bytes(data)
150
+ tmp.rename(POLICY_PATH)
151
+ except Exception:
152
+ pass
153
+
154
+
155
+ def drain_daemon_status() -> tuple[str, int | None]:
156
+ """Return (status, pid) where status is 'running'|'stale'|'dead'."""
157
+ import os as _os
158
+ if not JOURNAL_PID_PATH.exists():
159
+ return "dead", None
160
+ try:
161
+ pid = int(JOURNAL_PID_PATH.read_text().strip())
162
+ _os.kill(pid, 0) # raises if process gone
163
+ age = time.time() - JOURNAL_PID_PATH.stat().st_mtime
164
+ if age > _DAEMON_STALE_SECS:
165
+ return "stale", pid
166
+ return "running", pid
167
+ except (ProcessLookupError, PermissionError, ValueError):
168
+ return "dead", None
169
+ except Exception:
170
+ return "dead", None
171
+
172
+
114
173
  def ensure_drain_daemon(hook_module_path: Optional[Path] = None) -> None:
115
- """Start the drain daemon if it is not already running.
174
+ """Start the drain daemon if it is not already running or is stale.
116
175
 
117
176
  hook_module_path — path of the calling hook file (used to spawn the daemon
118
177
  via `python <path> drain`). When None the drain subprocess is not started
119
178
  (used in unit tests / debug mode where the daemon is not needed).
120
179
  """
121
180
  try:
122
- if JOURNAL_PID_PATH.exists():
123
- pid = int(JOURNAL_PID_PATH.read_text().strip())
181
+ status, stale_pid = drain_daemon_status()
182
+ if status == "running":
183
+ return
184
+ # Kill stale process before spawning replacement — prevents double-drain race
185
+ if status == "stale" and stale_pid is not None:
124
186
  import os as _os
125
187
  try:
126
- _os.kill(pid, 0)
127
- return # alive
128
- except (ProcessLookupError, PermissionError):
188
+ _os.kill(stale_pid, 15) # SIGTERM
189
+ except Exception:
129
190
  pass
130
191
  if hook_module_path is None:
131
192
  return
@@ -148,6 +209,7 @@ def run_drain_daemon() -> None:
148
209
  except Exception:
149
210
  return
150
211
  empty_scans = 0
212
+ _last_policy_refresh = 0.0
151
213
  while empty_scans < 3:
152
214
  files = sorted(f for f in JOURNAL_DIR.glob("*.json") if f.name != "drain.pid")
153
215
  if not files:
@@ -188,6 +250,16 @@ def run_drain_daemon() -> None:
188
250
  time.sleep(2)
189
251
  else:
190
252
  empty_scans = 0
253
+ # Touch PID file so drain_daemon_status() knows we're still alive
254
+ try:
255
+ JOURNAL_PID_PATH.touch()
256
+ except Exception:
257
+ pass
258
+ # Refresh policy on a short interval — collapses propagation window to 30 s
259
+ _now = time.time()
260
+ if _now - _last_policy_refresh >= _POLICY_REFRESH_INTERVAL:
261
+ _refresh_policy_from_api()
262
+ _last_policy_refresh = _now
191
263
  try:
192
264
  JOURNAL_PID_PATH.unlink(missing_ok=True)
193
265
  except Exception:
@@ -220,8 +220,8 @@ def _maybe_sync_policy() -> None:
220
220
 
221
221
  def _get_fail_mode() -> str:
222
222
  cfg = load_config()
223
- mode = cfg.get("fail_mode", "fail_open")
224
- return mode if mode in ("fail_open", "fail_closed") else "fail_open"
223
+ mode = cfg.get("fail_mode", "fail_closed")
224
+ return mode if mode in ("fail_open", "fail_closed") else "fail_closed"
225
225
 
226
226
 
227
227
  def _get_advisory_mode() -> bool:
@@ -325,11 +325,17 @@ def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
325
325
  input_text = json.dumps(tool_input)
326
326
  path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
327
327
 
328
+ current_ai_tool = detect_ai_tool()
328
329
  for rule in rules:
329
330
  match_tool = (rule.get("match_tool") or "*").lower()
330
331
  if match_tool != "*":
331
332
  if tool_name not in [t.strip() for t in match_tool.split(",")]:
332
333
  continue
334
+ match_ai = rule.get("match_ai_tool")
335
+ if match_ai:
336
+ surfaces = [s.strip().lower() for s in match_ai.split(",")]
337
+ if not any(s in current_ai_tool.lower() for s in surfaces):
338
+ continue
333
339
  pattern = rule.get("match_pattern")
334
340
  if pattern:
335
341
  try:
@@ -422,7 +428,7 @@ def main() -> None:
422
428
  tool_name = (data.get("tool_name") or "").lower()
423
429
  tool_input = data.get("tool_input") or {}
424
430
  session_id = data.get("session_id")
425
- msg = "[ConductGuard] Policy cache unavailable — fail-closed mode is on. Run `conduct guard sync` or contact your admin."
431
+ msg = "[ConductGuard] Guard API unreachable and no local policy cache foundtool call blocked (fail-closed). Run `conduct guard sync` to cache your policy, or ask your admin to set fail_open."
426
432
  print(msg)
427
433
  print(msg, file=sys.stderr)
428
434
  post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id, drain_via=_this_file)
@@ -436,7 +442,7 @@ def main() -> None:
436
442
  tool_name = (data.get("tool_name") or "").lower()
437
443
  tool_input = data.get("tool_input") or {}
438
444
  session_id = data.get("session_id")
439
- msg = "[ConductGuard] Spend budget check unavailable — fail-closed mode is on."
445
+ msg = "[ConductGuard] Guard API unreachabletool call blocked (fail-closed). Check your connection or ask your admin to set fail_open in Guard settings."
440
446
  print(msg)
441
447
  print(msg, file=sys.stderr)
442
448
  post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id, drain_via=_this_file)
@@ -3097,11 +3097,13 @@ 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
+ verify_p = sub.add_parser("verify", help="OWASP Agentic Top 10 coverage + governance grade")
3101
+ verify_p.add_argument("--evidence", metavar="FILE", default=None, help="Write evidence artifact to FILE.")
3102
+ verify_p.add_argument("--badge", action="store_true", help="Print markdown badge and exit.")
3103
+ verify_p.add_argument("--min-grade", metavar="GRADE", default=None,help="Exit 1 if governance grade is below this (A–F).")
3104
+ verify_p.add_argument("--strict", action="store_true", help="Exit 1 if any blocked events in last 24h (CI mode).")
3105
+ verify_p.add_argument("--format", choices=["text", "json"], default="text", help="Output format (default: text)")
3106
+ verify_p.add_argument("--since", default="24h", help="Time window for blocked event check (e.g. 7d, 24h)")
3105
3107
  sub.add_parser("test-guard", help="Fire a synthetic event per guard policy rule and show decisions")
3106
3108
  sub.add_parser("test-security", help="Post a synthetic finding per security classifier pattern")
3107
3109
  sub.add_parser("test-security-verify", help="Post test findings and verify full triage pipeline end-to-end")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.11
3
+ Version: 0.7.16
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
@@ -29,11 +29,13 @@ tests/test_advisory_and_hook.py
29
29
  tests/test_bash_operator_signature.py
30
30
  tests/test_blast_radius.py
31
31
  tests/test_command_word_matcher.py
32
+ tests/test_drain_daemon_health.py
32
33
  tests/test_guard_policy.py
33
34
  tests/test_guard_savings.py
34
35
  tests/test_hook_dispatch.py
35
36
  tests/test_journal_drain.py
36
37
  tests/test_log_util.py
38
+ tests/test_proxy_coverage.py
37
39
  tests/test_proxy_env.py
38
40
  tests/test_signed_policy.py
39
41
  tests/test_switch.py
@@ -425,35 +425,42 @@ class TestOWASPMapping:
425
425
  assert self._map("") == "A??"
426
426
 
427
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"},
428
+ _MOCK_CONTROLS = [
429
+ {"id": f"ASI-0{i}", "name": f"Control {i}", "status": "active", "control": "desc"}
430
+ for i in range(1, 11)
433
431
  ]
434
432
 
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
- ]
433
+ _MOCK_EVIDENCE_PASS = {
434
+ "grade": "B", "coverage_pct": 60, "score": 70,
435
+ "blocked_24h": 0, "events_24h": 5,
436
+ "controls": _MOCK_CONTROLS,
437
+ "generated_at": "2026-07-07T10:00:00+00:00",
438
+ "workspace_id": "test-ws-id",
439
+ }
440
440
 
441
- _EVIDENCE_CLEAN = [
442
- {"timestamp": "2026-07-06T10:02:00", "ai_tool": "Claude",
443
- "tool_call": "Bash", "decision": "allowed", "rule_id": None},
444
- ]
441
+ _MOCK_EVIDENCE_BLOCKED = {**_MOCK_EVIDENCE_PASS, "blocked_24h": 3}
442
+ _MOCK_EVIDENCE_FAIL = {**_MOCK_EVIDENCE_PASS, "grade": "F", "score": 10, "coverage_pct": 20}
445
443
 
446
444
 
447
445
  class TestVerifyCommand:
448
- def _run(self, args_list: list[str], capsys) -> int:
446
+ def _run(self, args_list: list[str], capsys, monkeypatch, api_response=None) -> int:
449
447
  import argparse
448
+ import conduct_cli.guard as _g
450
449
  from conduct_cli.guard import cmd_verify
451
450
 
451
+ monkeypatch.setattr(_g, "_require_guard_config", lambda: {
452
+ "workspace_id": "test-ws-id", "api_key": "k", "api_url": "https://api.test",
453
+ })
454
+ monkeypatch.setattr(_g, "_api_url", lambda cfg: "https://api.test")
455
+ monkeypatch.setattr(_g, "_req", lambda *a, **kw: api_response or _MOCK_EVIDENCE_PASS)
456
+
452
457
  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")
458
+ parser.add_argument("--evidence", default=None)
459
+ parser.add_argument("--badge", action="store_true")
460
+ parser.add_argument("--min-grade", dest="min_grade", default=None)
461
+ parser.add_argument("--strict", action="store_true")
462
+ parser.add_argument("--format", default="text")
463
+ parser.add_argument("--since", default="24h")
457
464
  args = parser.parse_args(args_list)
458
465
 
459
466
  try:
@@ -462,89 +469,61 @@ class TestVerifyCommand:
462
469
  except SystemExit as exc:
463
470
  return int(exc.code) if exc.code is not None else 0
464
471
 
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)
472
+ def test_pass_grade_exits_0(self, capsys, monkeypatch):
473
+ code = self._run([], capsys, monkeypatch)
469
474
  assert code == 0
470
475
 
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)
476
+ def test_blocked_without_strict_exits_0(self, capsys, monkeypatch):
477
+ code = self._run(["--strict"], capsys, monkeypatch, api_response=_MOCK_EVIDENCE_PASS)
475
478
  assert code == 0
476
479
 
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)
480
+ def test_strict_exits_1_when_blocked(self, capsys, monkeypatch):
481
+ code = self._run(["--strict"], capsys, monkeypatch, api_response=_MOCK_EVIDENCE_BLOCKED)
481
482
  assert code == 1
482
483
 
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)
484
+ def test_min_grade_pass(self, capsys, monkeypatch):
485
+ code = self._run(["--min-grade", "C"], capsys, monkeypatch, api_response=_MOCK_EVIDENCE_PASS)
487
486
  assert code == 0
488
487
 
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
488
+ def test_min_grade_fail(self, capsys, monkeypatch):
489
+ code = self._run(["--min-grade", "A"], capsys, monkeypatch, api_response=_MOCK_EVIDENCE_PASS)
490
+ assert code == 1
492
491
 
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
492
+ def test_fail_grade_below_min(self, capsys, monkeypatch):
493
+ code = self._run(["--min-grade", "B"], capsys, monkeypatch, api_response=_MOCK_EVIDENCE_FAIL)
494
+ assert code == 1
498
495
 
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
496
+ def test_badge_output(self, capsys, monkeypatch):
497
+ self._run(["--badge"], capsys, monkeypatch)
504
498
  out = capsys.readouterr().out
505
- assert "nothing to verify" in out.lower() or "no guard events" in out.lower()
499
+ assert "img.shields.io" in out
500
+ assert "Grade_B" in out
506
501
 
507
- def test_json_format_status_fail_when_strict_and_blocked(self, tmp_path, capsys):
502
+ def test_evidence_writes_file(self, tmp_path, capsys, monkeypatch):
508
503
  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
504
+ out_file = tmp_path / "evidence.json"
505
+ self._run(["--evidence", str(out_file)], capsys, monkeypatch)
506
+ data = json.loads(out_file.read_text())
507
+ assert data["grade"] == "B"
508
+ assert "controls" in data
516
509
 
517
- def test_json_format_status_pass_without_strict(self, tmp_path, capsys):
510
+ def test_json_format_output(self, capsys, monkeypatch):
518
511
  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)
512
+ self._run(["--format", "json"], capsys, monkeypatch)
522
513
  out = capsys.readouterr().out
523
514
  data = json.loads(out)
524
- assert data["status"] == "pass"
515
+ assert data["grade"] == "B"
525
516
 
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)
517
+ def test_grade_shown_in_text_output(self, capsys, monkeypatch):
518
+ self._run([], capsys, monkeypatch)
530
519
  out = capsys.readouterr().out
531
- assert "chain:abc123def456" in out
520
+ assert "Grade" in out
521
+ assert "B" in out
532
522
 
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)
523
+ def test_controls_listed_in_text_output(self, capsys, monkeypatch):
524
+ self._run([], capsys, monkeypatch)
537
525
  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
526
+ assert "ASI-01" in out
548
527
 
549
528
 
550
529
  # ── invalid stdin ─────────────────────────────────────────────────────────────
@@ -563,3 +542,65 @@ class TestInvalidStdin:
563
542
  with pytest.raises(SystemExit) as exc:
564
543
  pt.main()
565
544
  assert exc.value.code == 0
545
+
546
+
547
+ # ── Fail-closed: API unreachable (budget check path) ──────────────────────────
548
+
549
+ class TestFailClosedApiUnreachable:
550
+ """fail_closed + policy exists + budget API unreachable → blocks with clear message."""
551
+
552
+ def test_api_unreachable_blocks_with_clear_message(self, monkeypatch, tmp_path, capsys):
553
+ import conduct_cli.hooks.pretooluse as pt
554
+ from io import StringIO
555
+
556
+ policy_file = tmp_path / "policy.json"
557
+ policy_file.write_text('{"rules": [], "fail_mode": "fail_closed"}')
558
+
559
+ monkeypatch.setattr(pt, "active_policy_path", lambda: policy_file)
560
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_closed")
561
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
562
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
563
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
564
+ # Budget cache miss, then API unreachable → returns (False, None)
565
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (None, None))
566
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
567
+
568
+ posted: list[dict] = []
569
+ def _fake_post(tn, ti, decision, rule_id, msg, sid, **kw):
570
+ posted.append({"decision": decision, "rule_id": rule_id, "msg": msg})
571
+ monkeypatch.setattr(pt, "post_event", _fake_post)
572
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
573
+
574
+ with pytest.raises(SystemExit) as exc:
575
+ pt.main()
576
+
577
+ assert exc.value.code == 2
578
+ assert posted[0]["decision"] == "blocked"
579
+ assert posted[0]["rule_id"] == "guard-unavailable"
580
+ # Message must say "unreachable", not "budget"
581
+ assert "unreachable" in posted[0]["msg"].lower()
582
+ assert "budget" not in posted[0]["msg"].lower()
583
+
584
+ def test_api_unreachable_fail_open_passes(self, monkeypatch, tmp_path):
585
+ """Same scenario but fail_open → should NOT block."""
586
+ import conduct_cli.hooks.pretooluse as pt
587
+ from io import StringIO
588
+
589
+ policy_file = tmp_path / "policy.json"
590
+ policy_file.write_text('{"rules": []}')
591
+
592
+ monkeypatch.setattr(pt, "active_policy_path", lambda: policy_file)
593
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
594
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
595
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
596
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
597
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (None, None))
598
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
599
+ monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
600
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
601
+
602
+ # Should not raise SystemExit(2) — exits 0 (allow)
603
+ try:
604
+ pt.main()
605
+ except SystemExit as e:
606
+ assert e.code == 0
@@ -0,0 +1,206 @@
1
+ """Tests for drain daemon stale-PID detection and ensure_drain_daemon restart logic."""
2
+ import os
3
+ import time
4
+ from pathlib import Path
5
+ from unittest.mock import patch, MagicMock
6
+
7
+ import conduct_cli.hooks.base as base
8
+
9
+
10
+ # ── drain_daemon_status ────────────────────────────────────────────────────────
11
+
12
+ def test_status_dead_no_pid_file(tmp_path):
13
+ with patch.object(base, "JOURNAL_PID_PATH", tmp_path / "drain.pid"):
14
+ status, pid = base.drain_daemon_status()
15
+ assert status == "dead"
16
+ assert pid is None
17
+
18
+
19
+ def test_status_dead_process_gone(tmp_path):
20
+ pid_file = tmp_path / "drain.pid"
21
+ pid_file.write_text("999999999") # non-existent PID
22
+ with patch.object(base, "JOURNAL_PID_PATH", pid_file):
23
+ status, pid = base.drain_daemon_status()
24
+ assert status == "dead"
25
+ assert pid is None
26
+
27
+
28
+ def test_status_running_fresh(tmp_path):
29
+ pid_file = tmp_path / "drain.pid"
30
+ pid_file.write_text(str(os.getpid())) # current process — definitely alive
31
+ # mtime is now → fresh
32
+ with patch.object(base, "JOURNAL_PID_PATH", pid_file):
33
+ status, pid = base.drain_daemon_status()
34
+ assert status == "running"
35
+ assert pid == os.getpid()
36
+
37
+
38
+ def test_status_stale_old_mtime(tmp_path):
39
+ pid_file = tmp_path / "drain.pid"
40
+ pid_file.write_text(str(os.getpid()))
41
+ # backdate mtime beyond stale threshold
42
+ old = time.time() - base._DAEMON_STALE_SECS - 60
43
+ os.utime(pid_file, (old, old))
44
+ with patch.object(base, "JOURNAL_PID_PATH", pid_file):
45
+ status, pid = base.drain_daemon_status()
46
+ assert status == "stale"
47
+ assert pid == os.getpid()
48
+
49
+
50
+ def test_status_corrupt_pid_file(tmp_path):
51
+ pid_file = tmp_path / "drain.pid"
52
+ pid_file.write_text("not-a-number")
53
+ with patch.object(base, "JOURNAL_PID_PATH", pid_file):
54
+ status, pid = base.drain_daemon_status()
55
+ assert status == "dead"
56
+ assert pid is None
57
+
58
+
59
+ # ── ensure_drain_daemon ────────────────────────────────────────────────────────
60
+
61
+ def test_ensure_no_spawn_when_running(tmp_path):
62
+ pid_file = tmp_path / "drain.pid"
63
+ pid_file.write_text(str(os.getpid()))
64
+
65
+ with patch.object(base, "JOURNAL_PID_PATH", pid_file), \
66
+ patch.object(base, "subprocess") as mock_sub:
67
+ base.ensure_drain_daemon(hook_module_path=Path("/fake/hook.py"))
68
+
69
+ mock_sub.Popen.assert_not_called()
70
+
71
+
72
+ def test_ensure_spawns_when_dead(tmp_path):
73
+ pid_file = tmp_path / "drain.pid"
74
+ # no PID file → dead
75
+
76
+ with patch.object(base, "JOURNAL_PID_PATH", pid_file), \
77
+ patch.object(base, "subprocess") as mock_sub:
78
+ base.ensure_drain_daemon(hook_module_path=Path("/fake/hook.py"))
79
+
80
+ mock_sub.Popen.assert_called_once()
81
+
82
+
83
+ def test_ensure_kills_stale_and_spawns(tmp_path):
84
+ pid_file = tmp_path / "drain.pid"
85
+ pid_file.write_text(str(os.getpid()))
86
+ old = time.time() - base._DAEMON_STALE_SECS - 60
87
+ os.utime(pid_file, (old, old))
88
+
89
+ killed = []
90
+
91
+ def fake_kill(pid, sig):
92
+ killed.append((pid, sig))
93
+
94
+ with patch.object(base, "JOURNAL_PID_PATH", pid_file), \
95
+ patch.object(base, "subprocess"), \
96
+ patch("os.kill", fake_kill):
97
+ base.ensure_drain_daemon(hook_module_path=Path("/fake/hook.py"))
98
+
99
+ # stale PID (our own process) should have received SIGTERM
100
+ assert any(sig == 15 for _, sig in killed)
101
+
102
+
103
+ def test_ensure_no_spawn_without_hook_path(tmp_path):
104
+ """hook_module_path=None → no spawn even when dead (test/debug mode)."""
105
+ pid_file = tmp_path / "drain.pid"
106
+
107
+ with patch.object(base, "JOURNAL_PID_PATH", pid_file), \
108
+ patch.object(base, "subprocess") as mock_sub:
109
+ base.ensure_drain_daemon(hook_module_path=None)
110
+
111
+ mock_sub.Popen.assert_not_called()
112
+
113
+
114
+ # ── PID file touch in drain loop ───────────────────────────────────────────────
115
+
116
+ def test_pid_file_touched_after_flush(tmp_path):
117
+ """run_drain_daemon touches the PID file after each flush so mtime stays fresh."""
118
+ pid_file = tmp_path / "drain.pid"
119
+ journal_dir = tmp_path / "journal"
120
+ journal_dir.mkdir()
121
+
122
+ # Write one fake journal entry that will fail to POST (no server)
123
+ entry = journal_dir / "ev1.json"
124
+ entry.write_text('{"api_url": "http://127.0.0.1:19999", "payload": "{}"}')
125
+
126
+ before = time.time() - 5
127
+
128
+ with patch.object(base, "JOURNAL_DIR", journal_dir), \
129
+ patch.object(base, "JOURNAL_PID_PATH", pid_file):
130
+ # Run just enough of the drain loop to hit the touch
131
+ # Patch sleep to break out after first iteration
132
+ call_count = [0]
133
+ orig_sleep = time.sleep
134
+ def fast_sleep(n):
135
+ call_count[0] += 1
136
+ if call_count[0] >= 2:
137
+ raise KeyboardInterrupt
138
+ with patch("time.sleep", fast_sleep):
139
+ try:
140
+ base.run_drain_daemon()
141
+ except KeyboardInterrupt:
142
+ pass
143
+
144
+ if pid_file.exists():
145
+ assert pid_file.stat().st_mtime >= before
146
+
147
+
148
+ # ── Policy refresh ─────────────────────────────────────────────────────────────
149
+
150
+ def test_refresh_policy_writes_file(tmp_path):
151
+ """_refresh_policy_from_api writes API response to POLICY_PATH atomically."""
152
+ import urllib.request as _ur
153
+ from unittest.mock import MagicMock
154
+
155
+ fake_policy = b'{"rules": [{"id": "r1"}]}'
156
+ policy_path = tmp_path / "policy.json"
157
+
158
+ fake_resp = MagicMock()
159
+ fake_resp.read.return_value = fake_policy
160
+ fake_resp.__enter__ = lambda s: s
161
+ fake_resp.__exit__ = MagicMock(return_value=False)
162
+
163
+ with patch.object(base, "POLICY_PATH", policy_path), \
164
+ patch.object(base, "load_config", return_value={
165
+ "workspace_id": "ws_test",
166
+ "api_key": "cond_live_test",
167
+ "api_url": "https://api.conductai.ai",
168
+ }), \
169
+ patch("urllib.request.urlopen", return_value=fake_resp):
170
+ base._refresh_policy_from_api()
171
+
172
+ assert policy_path.exists()
173
+ assert policy_path.read_bytes() == fake_policy
174
+
175
+
176
+ def test_refresh_policy_skips_without_api_key(tmp_path):
177
+ """_refresh_policy_from_api is a no-op when api_key is absent."""
178
+ policy_path = tmp_path / "policy.json"
179
+
180
+ with patch.object(base, "POLICY_PATH", policy_path), \
181
+ patch.object(base, "load_config", return_value={"workspace_id": "ws_test"}), \
182
+ patch("urllib.request.urlopen") as mock_open:
183
+ base._refresh_policy_from_api()
184
+
185
+ mock_open.assert_not_called()
186
+ assert not policy_path.exists()
187
+
188
+
189
+ def test_refresh_policy_works_without_workspace_id(tmp_path):
190
+ """_refresh_policy_from_api omits workspace_id param when not in config (org-level Guard)."""
191
+ fake_policy = b'{"rules": []}'
192
+ policy_path = tmp_path / "policy.json"
193
+
194
+ fake_resp = MagicMock()
195
+ fake_resp.read.return_value = fake_policy
196
+ fake_resp.__enter__ = lambda s: s
197
+ fake_resp.__exit__ = MagicMock(return_value=False)
198
+
199
+ with patch.object(base, "POLICY_PATH", policy_path), \
200
+ patch.object(base, "load_config", return_value={"api_key": "cond_live_test"}), \
201
+ patch("urllib.request.urlopen", return_value=fake_resp) as mock_open:
202
+ base._refresh_policy_from_api()
203
+
204
+ called_url = mock_open.call_args[0][0].full_url
205
+ assert "workspace_id" not in called_url
206
+ assert policy_path.read_bytes() == fake_policy
@@ -0,0 +1,79 @@
1
+ """Tests for proxy coverage detection in guard status."""
2
+ import io
3
+ import sys
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock, patch
6
+
7
+
8
+ def _run_coverage_block(env: dict, cfg: dict, env_file_exists: bool = True) -> str:
9
+ """Run only the proxy-coverage printing block from cmd_guard_status."""
10
+ import conduct_cli.guard as g
11
+
12
+ output = io.StringIO()
13
+ with patch.dict("os.environ", env, clear=True), \
14
+ patch.object(Path, "exists", return_value=env_file_exists):
15
+ proxy_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/") + "/proxy"
16
+ _env = env
17
+ anthropic_ok = proxy_url in _env.get("ANTHROPIC_BASE_URL", "")
18
+ openai_ok = proxy_url in _env.get("OPENAI_BASE_URL", "")
19
+ perplexity_ok = proxy_url in _env.get("PERPLEXITY_BASE_URL", "")
20
+
21
+ covered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if ok]
22
+ uncovered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if not ok]
23
+
24
+ GREEN, YELLOW, RED, RESET, BOLD = "", "", "", "", ""
25
+
26
+ if covered and not uncovered:
27
+ line = f"active — {', '.join(covered)} routed through Guard proxy"
28
+ elif covered:
29
+ line = f"partial — {', '.join(covered)} covered · {', '.join(uncovered)} NOT intercepted"
30
+ elif env_file_exists:
31
+ line = "inactive — ~/.conduct/env exists but not sourced in this shell."
32
+ else:
33
+ line = "not configured — run: conduct guard sync"
34
+ return line
35
+
36
+
37
+ _CFG = {"api_url": "https://api.conductai.ai"}
38
+ _PROXY = "https://api.conductai.ai/proxy"
39
+
40
+
41
+ def test_all_three_active():
42
+ env = {
43
+ "ANTHROPIC_BASE_URL": _PROXY,
44
+ "OPENAI_BASE_URL": f"{_PROXY}/openai",
45
+ "PERPLEXITY_BASE_URL": f"{_PROXY}/perplexity",
46
+ }
47
+ line = _run_coverage_block(env, _CFG)
48
+ assert line.startswith("active")
49
+ assert "Anthropic" in line
50
+ assert "OpenAI" in line
51
+ assert "Perplexity" in line
52
+
53
+
54
+ def test_partial_coverage():
55
+ env = {"ANTHROPIC_BASE_URL": _PROXY}
56
+ line = _run_coverage_block(env, _CFG)
57
+ assert line.startswith("partial")
58
+ assert "Anthropic" in line
59
+ assert "NOT intercepted" in line
60
+ assert "OpenAI" in line
61
+
62
+
63
+ def test_inactive_env_file_exists():
64
+ line = _run_coverage_block({}, _CFG, env_file_exists=True)
65
+ assert line.startswith("inactive")
66
+ assert "~/.conduct/env" in line
67
+
68
+
69
+ def test_not_configured_no_env_file():
70
+ line = _run_coverage_block({}, _CFG, env_file_exists=False)
71
+ assert line.startswith("not configured")
72
+ assert "conduct guard sync" in line
73
+
74
+
75
+ def test_wrong_proxy_url_not_counted():
76
+ """Env var pointing to a different proxy should not count as covered."""
77
+ env = {"ANTHROPIC_BASE_URL": "https://some-other-proxy.io"}
78
+ line = _run_coverage_block(env, _CFG, env_file_exists=True)
79
+ assert line.startswith("inactive")
File without changes
File without changes
File without changes