conduct-cli 0.7.10__tar.gz → 0.7.17__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 (45) hide show
  1. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/guard.py +124 -124
  4. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/guardmcp.py +21 -7
  5. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/base.py +85 -11
  6. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/posttooluse.py +54 -7
  7. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/pretooluse.py +12 -5
  8. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/main.py +7 -5
  9. conduct_cli-0.7.17/src/conduct_cli/tool_groups.py +58 -0
  10. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  11. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/SOURCES.txt +4 -1
  12. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_advisory_and_hook.py +122 -81
  13. conduct_cli-0.7.17/tests/test_blast_radius.py +44 -0
  14. conduct_cli-0.7.17/tests/test_drain_daemon_health.py +206 -0
  15. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_guard_policy.py +8 -4
  16. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_journal_drain.py +31 -34
  17. conduct_cli-0.7.17/tests/test_proxy_coverage.py +79 -0
  18. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_signed_policy.py +65 -77
  19. conduct_cli-0.7.10/tests/test_hook_syntax.py +0 -19
  20. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/README.md +0 -0
  21. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/setup.cfg +0 -0
  22. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/setup.py +0 -0
  23. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/__init__.py +0 -0
  24. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/api.py +0 -0
  25. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/__init__.py +0 -0
  26. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/precompact.py +0 -0
  27. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_parser.py +0 -0
  28. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_report_push.py +0 -0
  29. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_start.py +0 -0
  30. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/stop.py +0 -0
  31. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/log_util.py +0 -0
  32. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/mcp_server.py +0 -0
  33. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/memory.py +0 -0
  34. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/paxel.py +0 -0
  35. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  36. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  37. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/requires.txt +0 -0
  38. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/top_level.txt +0 -0
  39. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_bash_operator_signature.py +0 -0
  40. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_command_word_matcher.py +0 -0
  41. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_guard_savings.py +0 -0
  42. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_hook_dispatch.py +0 -0
  43. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_log_util.py +0 -0
  44. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_proxy_env.py +0 -0
  45. {conduct_cli-0.7.10 → conduct_cli-0.7.17}/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.10
3
+ Version: 0.7.17
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.10"
7
+ version = "0.7.17"
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" }
@@ -9,6 +9,8 @@ import urllib.request
9
9
  from datetime import datetime, timedelta, timezone
10
10
  from pathlib import Path
11
11
 
12
+ from conduct_cli.tool_groups import expand_match_tool
13
+
12
14
  RESET = "\033[0m"
13
15
  BOLD = "\033[1m"
14
16
  GREEN = "\033[32m"
@@ -132,7 +134,7 @@ def _check_policy(tool_name, tool_input, tokens_before=0):
132
134
  for rule in rules:
133
135
  match_tool = (rule.get("match_tool") or "*").lower()
134
136
  if match_tool != "*":
135
- if tool_name not in [t.strip() for t in match_tool.split(",")]:
137
+ if tool_name not in expand_match_tool(match_tool):
136
138
  continue
137
139
  # New: structural match against the actual binary being invoked.
138
140
  # Single string or list of strings; case-insensitive equality.
@@ -759,7 +761,7 @@ def cmd_guard_install(args):
759
761
  # Mirror fail_mode into guard config so the hook can enforce it
760
762
  # even when POLICY_PATH is missing or unreadable.
761
763
  cfg = _load_guard_config()
762
- cfg["fail_mode"] = policy.get("fail_mode", "fail_open")
764
+ cfg["fail_mode"] = policy.get("fail_mode", "fail_closed")
763
765
  cfg["advisory_mode"] = policy.get("advisory_mode", False)
764
766
  _save_guard_config(cfg)
765
767
  rule_count = len(policy.get("rules", []))
@@ -1722,9 +1724,49 @@ def cmd_guard_status(args):
1722
1724
 
1723
1725
  session_str = f"{proxy_sessions} proxy · {hook_sessions} direct"
1724
1726
 
1727
+ # Drain daemon health
1728
+ try:
1729
+ from conduct_cli.hooks.base import drain_daemon_status
1730
+ d_status, d_pid = drain_daemon_status()
1731
+ except Exception:
1732
+ d_status, d_pid = "unknown", None
1733
+
1734
+ if d_status == "running":
1735
+ daemon_line = f"{GREEN}running{RESET} (pid {d_pid})"
1736
+ elif d_status == "stale":
1737
+ daemon_line = f"{YELLOW}stale{RESET} (pid {d_pid}, not flushing — will restart on next tool call)"
1738
+ else:
1739
+ daemon_line = f"{RED}not running{RESET} — will start on next tool call, journal events queued"
1740
+
1741
+ # Proxy coverage — check active env vars in this shell
1742
+ proxy_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/") + "/proxy"
1743
+ _env = os.environ
1744
+ anthropic_ok = proxy_url in _env.get("ANTHROPIC_BASE_URL", "")
1745
+ openai_ok = proxy_url in _env.get("OPENAI_BASE_URL", "")
1746
+ perplexity_ok = proxy_url in _env.get("PERPLEXITY_BASE_URL","")
1747
+ env_file_exists = (Path.home() / ".conduct" / "env").exists()
1748
+
1749
+ def _cov(ok: bool, name: str) -> str:
1750
+ return f"{GREEN}{name}{RESET}" if ok else f"{YELLOW}{name}{RESET}"
1751
+
1752
+ covered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if ok]
1753
+ uncovered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if not ok]
1754
+
1755
+ if covered and not uncovered:
1756
+ proxy_line = f"{GREEN}active{RESET} — {', '.join(covered)} routed through Guard proxy"
1757
+ elif covered:
1758
+ proxy_line = f"{YELLOW}partial{RESET} — {', '.join(covered)} covered · {', '.join(uncovered)} NOT intercepted"
1759
+ elif env_file_exists:
1760
+ proxy_line = f"{YELLOW}inactive{RESET} — ~/.conduct/env exists but not sourced in this shell. Run: {BOLD}. ~/.conduct/env{RESET}"
1761
+ else:
1762
+ proxy_line = f"{RED}not configured{RESET} — run: {BOLD}conduct guard sync{RESET}"
1763
+
1725
1764
  print(f"\n{BOLD}Guard status{RESET} — {user_email}")
1726
1765
  print(f"{rule_count} polic{'y' if rule_count == 1 else 'ies'} active")
1727
1766
  print()
1767
+ print(f"Proxy coverage: {proxy_line}")
1768
+ print(f"Drain daemon: {daemon_line}")
1769
+ print()
1728
1770
  print(f"Today:")
1729
1771
  print(f" Sessions: {session_str}")
1730
1772
  print(f" Tokens used: {tokens_used:,} (saved {token_saved_pct}% via optimization)")
@@ -2508,144 +2550,102 @@ def _map_owasp(rule_id: str) -> tuple[str, str]:
2508
2550
  return _OWASP_UNKNOWN
2509
2551
 
2510
2552
 
2553
+ _GRADE_COLORS = {"A": "\033[32m", "B": "\033[32m", "C": "\033[33m", "D": "\033[33m", "F": "\033[31m"}
2554
+ _GRADE_ORDER = ["A", "B", "C", "D", "F"]
2555
+
2556
+
2557
+ def _grade_below(actual: str, minimum: str) -> bool:
2558
+ return _GRADE_ORDER.index(actual) > _GRADE_ORDER.index(minimum.upper())
2559
+
2560
+
2511
2561
  def cmd_verify(args) -> None:
2512
- """conduct verify — map guard audit events to OWASP Agentic Top 10; exit non-zero in CI."""
2562
+ """conduct verify — governance grade + OWASP Agentic Top 10 coverage."""
2513
2563
  import json as _json
2514
- import datetime as _dt
2515
2564
 
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"
2565
+ evidence_out = getattr(args, "evidence", None)
2566
+ badge = getattr(args, "badge", False)
2567
+ min_grade = getattr(args, "min_grade", None)
2568
+ strict = getattr(args, "strict", False)
2569
+ fmt = getattr(args, "format", "text")
2520
2570
 
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
2571
+ cfg = _require_guard_config()
2572
+ workspace_id = cfg.get("workspace_id")
2573
+ api_key = cfg.get("api_key", "")
2574
+ base_url = _api_url(cfg)
2558
2575
 
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
- })
2576
+ # ── fetch evidence from API ───────────────────────────────────────────────
2577
+ ev = _req(
2578
+ "GET",
2579
+ f"{base_url}/guard/verify/evidence?workspace_id={workspace_id}",
2580
+ api_key=api_key,
2581
+ )
2576
2582
 
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)
2583
+ grade = ev.get("grade", "F")
2584
+ coverage_pct = ev.get("coverage_pct", 0)
2585
+ score = ev.get("score", 0)
2586
+ blocked_24h = ev.get("blocked_24h", 0)
2587
+ controls = ev.get("controls", [])
2588
+ generated_at = ev.get("generated_at", "")
2589
+
2590
+ # ── --badge ───────────────────────────────────────────────────────────────
2591
+ if badge:
2592
+ color = {"A": "brightgreen", "B": "green", "C": "yellow", "D": "orange", "F": "red"}.get(grade, "lightgrey")
2593
+ print(f"![Conduct Guard](https://img.shields.io/badge/Guard-Grade_{grade}-{color})")
2594
+ return
2580
2595
 
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
2596
+ # ── --evidence (write artifact) ───────────────────────────────────────────
2597
+ if evidence_out:
2598
+ artifact = {
2599
+ "grade": grade,
2600
+ "coverage_pct": coverage_pct,
2601
+ "score": score,
2602
+ "blocked_24h": blocked_24h,
2603
+ "controls": controls,
2604
+ "generated_at": generated_at,
2605
+ "workspace_id": workspace_id,
2606
+ }
2607
+ Path(evidence_out).write_text(_json.dumps(artifact, indent=2))
2608
+ print(f"{GREEN}✓ Evidence artifact written to {evidence_out}{RESET}")
2585
2609
 
2586
- # ── output ───────────────────────────────────────────────────────────────
2610
+ # ── output ────────────────────────────────────────────────────────────────
2587
2611
  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))
2612
+ print(_json.dumps(ev, indent=2))
2596
2613
  else:
2614
+ grade_color = _GRADE_COLORS.get(grade, GRAY)
2615
+ print()
2616
+ print(f"{BOLD}conduct verify — Governance Grade{RESET}")
2617
+ print("─" * 60)
2618
+ print(f"\n Grade: {grade_color}{BOLD}{grade}{RESET} (score {score}/100)")
2619
+ print(f" Coverage: {coverage_pct}% of OWASP ASI controls active")
2620
+ print(f" Blocked (24h): {blocked_24h}")
2597
2621
  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}"
2622
+ print(f"{BOLD} {'Control':<8} {'Status':<10} Name{RESET}")
2623
+ print(" " + "─" * 56)
2624
+ for c in controls:
2625
+ status = c.get("status", "missing")
2626
+ if status == "active":
2627
+ sc = f"{GREEN}active {RESET}"
2628
+ elif status == "partial":
2629
+ sc = f"{YELLOW}partial {RESET}"
2622
2630
  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
-
2631
+ sc = f"{RED}missing {RESET}"
2632
+ print(f" {c.get('id',''):<8} {sc} {c.get('name','')}")
2630
2633
  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
2634
 
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}")
2635
+ # CI checks
2636
+ failed = False
2637
+ if strict and blocked_24h > 0:
2638
+ print(f" {RED}✗ Strict mode: {blocked_24h} blocked event(s) in last 24h.{RESET}")
2639
+ failed = True
2640
+ if min_grade and _grade_below(grade, min_grade):
2641
+ print(f" {RED}✗ Grade {grade} is below minimum {min_grade.upper()}.{RESET}")
2642
+ failed = True
2643
+ if not failed:
2644
+ print(f" {GREEN}✓ PASS{RESET}")
2646
2645
  print()
2647
2646
 
2648
- if strict and blocked_count:
2647
+ # ── exit codes ────────────────────────────────────────────────────────────
2648
+ if (strict and blocked_24h > 0) or (min_grade and _grade_below(grade, min_grade)):
2649
2649
  sys.exit(1)
2650
2650
 
2651
2651
 
@@ -12,6 +12,7 @@ Exposes three tools:
12
12
  guard_sync — pull latest policy from the ConductGuard API
13
13
  """
14
14
  from __future__ import annotations
15
+ from conduct_cli.tool_groups import expand_match_tool
15
16
  import argparse
16
17
  import json
17
18
  import re
@@ -183,14 +184,23 @@ def _maybe_sync() -> None:
183
184
  def _detect_surface(client_info: dict) -> str:
184
185
  """Map MCP clientInfo.name → ai_tool label sent to Guard API."""
185
186
  name = (client_info.get("name") or "").lower()
186
- if "desktop" in name:
187
- return "claude_desktop"
187
+ # ── Anthropic ─────────────────────────────────────────────────────────────
188
+ if "code" in name and "claude" in name:
189
+ return "claude-code"
190
+ if "desktop" in name and "claude" in name:
191
+ return "claude-desktop"
188
192
  if "work" in name or "teams" in name or "enterprise" in name:
189
- return "claude_work"
193
+ return "claude-desktop" # Claude for Work / Teams
190
194
  if "claude" in name:
191
- return "claude_chat"
195
+ return "claude-ai" # claude.ai web
196
+ # ── OpenAI ────────────────────────────────────────────────────────────────
197
+ if "chatgpt" in name or "openai" in name:
198
+ return "openai-chatgpt"
199
+ if "codex" in name and "desktop" in name:
200
+ return "codex-desktop"
192
201
  if "codex" in name:
193
- return "codex"
202
+ return "codex-cli"
203
+ # ── Other IDEs ────────────────────────────────────────────────────────────
194
204
  if "cursor" in name:
195
205
  return "cursor"
196
206
  if "windsurf" in name:
@@ -259,9 +269,13 @@ def _match_policy(tool_name: str, tool_input: dict) -> dict | None:
259
269
  for rule in rules:
260
270
  match_tool = (rule.get("match_tool") or "*").lower()
261
271
  if match_tool != "*":
262
- allowed = [t.strip() for t in match_tool.split(",")]
263
- if tool_name.lower() not in allowed:
272
+ if tool_name.lower() not in expand_match_tool(match_tool):
264
273
  continue
274
+ match_ai = rule.get("match_ai_tool")
275
+ if match_ai:
276
+ surfaces = [s.strip().lower() for s in match_ai.split(",")]
277
+ if not any(s in ai_tool.lower() for s in surfaces):
278
+ continue # rule targets a different surface
265
279
 
266
280
  pattern = rule.get("match_pattern")
267
281
  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:
@@ -211,6 +283,7 @@ def post_event(
211
283
  session_id: Optional[str] = None,
212
284
  *,
213
285
  drain_via: Optional[Path] = None,
286
+ blast_radius: "dict | None" = None,
214
287
  ) -> None:
215
288
  """Post one guard event via the journal/drain pattern. Never raises.
216
289
 
@@ -235,6 +308,7 @@ def post_event(
235
308
  "hook_session_id": session_id,
236
309
  "os_info": _os_info,
237
310
  "hostname": _platform.node(),
311
+ "blast_radius": blast_radius,
238
312
  })
239
313
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
240
314
  journal_append(payload, api_url)
@@ -247,7 +247,51 @@ def _scan_codex_tokens(transcript_path: str):
247
247
  return 0, 0
248
248
 
249
249
 
250
- def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms) -> None:
250
+ def _compute_blast_radius(tool_name: str, tool_input: dict, tool_response: str) -> "dict | None":
251
+ """Classify blast radius after tool execution. Returns None for read-only tools."""
252
+ import re as _re
253
+
254
+ READ_ONLY = {"read", "ls", "glob", "search", "grep", "websearch", "webfetch", "computer"}
255
+ if tool_name in READ_ONLY:
256
+ return None
257
+
258
+ files = 0
259
+ symbols: "int | None" = None
260
+ tier = "local"
261
+
262
+ if tool_name in ("bash", "terminal"):
263
+ cmd = (tool_input.get("command") or "").lower()
264
+ if _re.search(r"\brm\s+.*-rf|\brm\s+-rf", cmd):
265
+ tier = "destructive"
266
+ elif _re.search(r"\bgit\s+(push|commit|merge|rebase|reset|tag)\b", cmd):
267
+ tier = "repo"
268
+ elif _re.search(r"\b(curl|wget|fetch)\b|https?://", cmd):
269
+ tier = "network"
270
+ # count path-like lines in output as proxy for files touched
271
+ lines = (tool_response or "").splitlines()
272
+ files = min(sum(1 for l in lines if "/" in l or ("." in l and len(l) < 200)), 50)
273
+
274
+ elif tool_name in ("write",):
275
+ files = 1
276
+ content = tool_input.get("content") or ""
277
+ symbols = len(content.splitlines()) or None
278
+
279
+ elif tool_name in ("edit", "str_replace_based_edit_tool", "str_replace_editor"):
280
+ files = 1
281
+ new_str = tool_input.get("new_string") or tool_input.get("new_content") or ""
282
+ symbols = len(new_str.splitlines()) or None
283
+
284
+ elif tool_name in ("multiedit",):
285
+ edits = tool_input.get("edits") or []
286
+ files = len(edits)
287
+
288
+ else:
289
+ files = 1 # unknown write-like tool
290
+
291
+ return {"files": files, "symbols": symbols, "tier": tier}
292
+
293
+
294
+ def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms, blast_radius=None) -> None:
251
295
  """Fire-and-forget POST to /guard/events/usage."""
252
296
  cfg = load_config()
253
297
  workspace_id = cfg.get("workspace_id")
@@ -261,6 +305,7 @@ def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms)
261
305
  "tokens_output": tokens_output,
262
306
  "duration_ms": duration_ms,
263
307
  "ai_tool": detect_ai_tool(),
308
+ "blast_radius": blast_radius,
264
309
  })
265
310
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
266
311
  script = (
@@ -300,7 +345,7 @@ def post_codex_main() -> None:
300
345
  transcript_path = args.get("transcript_path", "")
301
346
  tokens_in, tokens_out = _scan_codex_tokens(transcript_path)
302
347
  if tokens_in or tokens_out:
303
- _post_usage(args.get("session_id"), args.get("tool_name"), tokens_in, tokens_out, None)
348
+ _post_usage(args.get("session_id"), args.get("tool_name"), tokens_in, tokens_out, None, args.get("blast_radius"))
304
349
  sys.exit(0)
305
350
 
306
351
 
@@ -320,6 +365,10 @@ def main() -> None:
320
365
  is_codex = (tool_use_id or "").startswith("call_")
321
366
  session_id = data.get("session_id") or (f"transcript:{transcript_path}" if transcript_path else None)
322
367
 
368
+ tool_response = data.get("tool_response") or data.get("output") or ""
369
+ tool_input = data.get("tool_input") or {}
370
+ blast_radius = _compute_blast_radius(tool_name, tool_input, str(tool_response))
371
+
323
372
  if is_codex and transcript_path:
324
373
  import uuid as _uuid
325
374
  pending = GUARD_DIR / f"codex_pending_{_uuid.uuid4().hex[:8]}.json"
@@ -328,6 +377,7 @@ def main() -> None:
328
377
  "session_id": session_id,
329
378
  "tool_name": tool_name,
330
379
  "transcript_path": transcript_path,
380
+ "blast_radius": blast_radius,
331
381
  }))
332
382
  subprocess.Popen(
333
383
  [sys.executable, str(_this_file), "post-codex", str(pending)],
@@ -339,7 +389,7 @@ def main() -> None:
339
389
  pass
340
390
  elif transcript_path:
341
391
  tokens_input, tokens_output = _read_tokens_from_transcript(transcript_path, tool_use_id)
342
- _post_usage(session_id, tool_name, tokens_input, tokens_output, None)
392
+ _post_usage(session_id, tool_name, tokens_input, tokens_output, None, blast_radius)
343
393
  _, action, rule_id, message = check_policy(tool_name, {}, tokens_before=tokens_input)
344
394
  if action in ("warn", "block"):
345
395
  decision = "warned" if action == "warn" else "blocked"
@@ -348,10 +398,7 @@ def main() -> None:
348
398
  else:
349
399
  if action == "warn" and session_id and rule_id:
350
400
  _record_session_warn(session_id, rule_id)
351
- post_event(tool_name, {}, decision, rule_id, message, session_id, drain_via=_this_file)
352
-
353
- tool_response = data.get("tool_response") or data.get("output") or ""
354
- tool_input = data.get("tool_input") or {}
401
+ post_event(tool_name, {}, decision, rule_id, message, session_id, drain_via=_this_file, blast_radius=blast_radius)
355
402
  _maybe_emit_security_finding(str(tool_response), session_id, tool_name, tool_input)
356
403
 
357
404
  sys.exit(0)