conduct-cli 0.7.10__tar.gz → 0.7.14__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.10 → conduct_cli-0.7.14}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/guard.py +121 -123
  4. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/base.py +66 -6
  5. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/posttooluse.py +54 -7
  6. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/pretooluse.py +4 -4
  7. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/main.py +7 -5
  8. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  9. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/SOURCES.txt +2 -1
  10. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_advisory_and_hook.py +60 -81
  11. conduct_cli-0.7.14/tests/test_blast_radius.py +44 -0
  12. conduct_cli-0.7.14/tests/test_drain_daemon_health.py +206 -0
  13. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_journal_drain.py +31 -34
  14. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_signed_policy.py +65 -77
  15. conduct_cli-0.7.10/tests/test_hook_syntax.py +0 -19
  16. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/README.md +0 -0
  17. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/setup.cfg +0 -0
  18. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/setup.py +0 -0
  19. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/__init__.py +0 -0
  20. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/api.py +0 -0
  21. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/guardmcp.py +0 -0
  22. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/__init__.py +0 -0
  23. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/precompact.py +0 -0
  24. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/session_parser.py +0 -0
  25. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/session_report_push.py +0 -0
  26. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/session_start.py +0 -0
  27. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/stop.py +0 -0
  28. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/log_util.py +0 -0
  29. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/mcp_server.py +0 -0
  30. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/memory.py +0 -0
  31. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/paxel.py +0 -0
  32. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  33. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  34. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/requires.txt +0 -0
  35. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/top_level.txt +0 -0
  36. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_bash_operator_signature.py +0 -0
  37. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_command_word_matcher.py +0 -0
  38. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_guard_policy.py +0 -0
  39. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_guard_savings.py +0 -0
  40. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_hook_dispatch.py +0 -0
  41. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_log_util.py +0 -0
  42. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_proxy_env.py +0 -0
  43. {conduct_cli-0.7.10 → conduct_cli-0.7.14}/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.14
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.14"
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
 
@@ -111,21 +111,68 @@ def journal_append(payload_str: str, api_url: str) -> None:
111
111
  pass
112
112
 
113
113
 
114
+ _DAEMON_STALE_SECS = 600 # PID file not touched in 10 min → daemon is stale
115
+ _POLICY_REFRESH_INTERVAL = 30 # daemon refreshes policy.json every 30 s
116
+
117
+
118
+ def _refresh_policy_from_api() -> None:
119
+ """Pull fresh policy from API and write to POLICY_PATH atomically."""
120
+ try:
121
+ cfg = load_config()
122
+ workspace_id = cfg.get("workspace_id")
123
+ api_key = cfg.get("api_key", "")
124
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
125
+ if not api_key:
126
+ return
127
+ url = f"{api_url}/guard/policies/sync"
128
+ if workspace_id:
129
+ url += f"?workspace_id={workspace_id}"
130
+ req = urllib.request.Request(url, headers={"X-Api-Key": api_key})
131
+ with urllib.request.urlopen(req, timeout=8) as resp:
132
+ data = resp.read()
133
+ POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
134
+ tmp = POLICY_PATH.with_suffix(".tmp")
135
+ tmp.write_bytes(data)
136
+ tmp.rename(POLICY_PATH)
137
+ except Exception:
138
+ pass
139
+
140
+
141
+ def drain_daemon_status() -> tuple[str, int | None]:
142
+ """Return (status, pid) where status is 'running'|'stale'|'dead'."""
143
+ import os as _os
144
+ if not JOURNAL_PID_PATH.exists():
145
+ return "dead", None
146
+ try:
147
+ pid = int(JOURNAL_PID_PATH.read_text().strip())
148
+ _os.kill(pid, 0) # raises if process gone
149
+ age = time.time() - JOURNAL_PID_PATH.stat().st_mtime
150
+ if age > _DAEMON_STALE_SECS:
151
+ return "stale", pid
152
+ return "running", pid
153
+ except (ProcessLookupError, PermissionError, ValueError):
154
+ return "dead", None
155
+ except Exception:
156
+ return "dead", None
157
+
158
+
114
159
  def ensure_drain_daemon(hook_module_path: Optional[Path] = None) -> None:
115
- """Start the drain daemon if it is not already running.
160
+ """Start the drain daemon if it is not already running or is stale.
116
161
 
117
162
  hook_module_path — path of the calling hook file (used to spawn the daemon
118
163
  via `python <path> drain`). When None the drain subprocess is not started
119
164
  (used in unit tests / debug mode where the daemon is not needed).
120
165
  """
121
166
  try:
122
- if JOURNAL_PID_PATH.exists():
123
- pid = int(JOURNAL_PID_PATH.read_text().strip())
167
+ status, stale_pid = drain_daemon_status()
168
+ if status == "running":
169
+ return
170
+ # Kill stale process before spawning replacement — prevents double-drain race
171
+ if status == "stale" and stale_pid is not None:
124
172
  import os as _os
125
173
  try:
126
- _os.kill(pid, 0)
127
- return # alive
128
- except (ProcessLookupError, PermissionError):
174
+ _os.kill(stale_pid, 15) # SIGTERM
175
+ except Exception:
129
176
  pass
130
177
  if hook_module_path is None:
131
178
  return
@@ -148,6 +195,7 @@ def run_drain_daemon() -> None:
148
195
  except Exception:
149
196
  return
150
197
  empty_scans = 0
198
+ _last_policy_refresh = 0.0
151
199
  while empty_scans < 3:
152
200
  files = sorted(f for f in JOURNAL_DIR.glob("*.json") if f.name != "drain.pid")
153
201
  if not files:
@@ -188,6 +236,16 @@ def run_drain_daemon() -> None:
188
236
  time.sleep(2)
189
237
  else:
190
238
  empty_scans = 0
239
+ # Touch PID file so drain_daemon_status() knows we're still alive
240
+ try:
241
+ JOURNAL_PID_PATH.touch()
242
+ except Exception:
243
+ pass
244
+ # Refresh policy on a short interval — collapses propagation window to 30 s
245
+ _now = time.time()
246
+ if _now - _last_policy_refresh >= _POLICY_REFRESH_INTERVAL:
247
+ _refresh_policy_from_api()
248
+ _last_policy_refresh = _now
191
249
  try:
192
250
  JOURNAL_PID_PATH.unlink(missing_ok=True)
193
251
  except Exception:
@@ -211,6 +269,7 @@ def post_event(
211
269
  session_id: Optional[str] = None,
212
270
  *,
213
271
  drain_via: Optional[Path] = None,
272
+ blast_radius: "dict | None" = None,
214
273
  ) -> None:
215
274
  """Post one guard event via the journal/drain pattern. Never raises.
216
275
 
@@ -235,6 +294,7 @@ def post_event(
235
294
  "hook_session_id": session_id,
236
295
  "os_info": _os_info,
237
296
  "hostname": _platform.node(),
297
+ "blast_radius": blast_radius,
238
298
  })
239
299
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
240
300
  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)
@@ -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:
@@ -422,7 +422,7 @@ def main() -> None:
422
422
  tool_name = (data.get("tool_name") or "").lower()
423
423
  tool_input = data.get("tool_input") or {}
424
424
  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."
425
+ 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
426
  print(msg)
427
427
  print(msg, file=sys.stderr)
428
428
  post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id, drain_via=_this_file)
@@ -436,7 +436,7 @@ def main() -> None:
436
436
  tool_name = (data.get("tool_name") or "").lower()
437
437
  tool_input = data.get("tool_input") or {}
438
438
  session_id = data.get("session_id")
439
- msg = "[ConductGuard] Spend budget check unavailable — fail-closed mode is on."
439
+ msg = "[ConductGuard] Guard API unreachabletool call blocked (fail-closed). Check your connection or ask your admin to set fail_open in Guard settings."
440
440
  print(msg)
441
441
  print(msg, file=sys.stderr)
442
442
  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.10
3
+ Version: 0.7.14
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
@@ -27,11 +27,12 @@ src/conduct_cli/hooks/session_start.py
27
27
  src/conduct_cli/hooks/stop.py
28
28
  tests/test_advisory_and_hook.py
29
29
  tests/test_bash_operator_signature.py
30
+ tests/test_blast_radius.py
30
31
  tests/test_command_word_matcher.py
32
+ tests/test_drain_daemon_health.py
31
33
  tests/test_guard_policy.py
32
34
  tests/test_guard_savings.py
33
35
  tests/test_hook_dispatch.py
34
- tests/test_hook_syntax.py
35
36
  tests/test_journal_drain.py
36
37
  tests/test_log_util.py
37
38
  tests/test_proxy_env.py