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.
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/PKG-INFO +1 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/pyproject.toml +1 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/guard.py +121 -123
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/base.py +66 -6
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/posttooluse.py +54 -7
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/pretooluse.py +4 -4
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/main.py +7 -5
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/SOURCES.txt +2 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_advisory_and_hook.py +60 -81
- conduct_cli-0.7.14/tests/test_blast_radius.py +44 -0
- conduct_cli-0.7.14/tests/test_drain_daemon_health.py +206 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_journal_drain.py +31 -34
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_signed_policy.py +65 -77
- conduct_cli-0.7.10/tests/test_hook_syntax.py +0 -19
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/README.md +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/setup.cfg +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/setup.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.14}/tests/test_switch.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.7.
|
|
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", "
|
|
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 —
|
|
2560
|
+
"""conduct verify — governance grade + OWASP Agentic Top 10 coverage."""
|
|
2513
2561
|
import json as _json
|
|
2514
|
-
import datetime as _dt
|
|
2515
2562
|
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
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
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
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
|
-
# ──
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
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
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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"")
|
|
2592
|
+
return
|
|
2580
2593
|
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
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
|
-
|
|
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}
|
|
2599
|
-
print("─" *
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
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
|
-
|
|
2624
|
-
|
|
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
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
f"{RED
|
|
2642
|
-
|
|
2643
|
-
)
|
|
2644
|
-
|
|
2645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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(
|
|
127
|
-
|
|
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
|
|
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", "
|
|
224
|
-
return mode if mode in ("fail_open", "fail_closed") else "
|
|
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]
|
|
425
|
+
msg = "[ConductGuard] Guard API unreachable and no local policy cache found — tool 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]
|
|
439
|
+
msg = "[ConductGuard] Guard API unreachable — tool 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="
|
|
3101
|
-
verify_p.add_argument("--evidence",
|
|
3102
|
-
verify_p.add_argument("--
|
|
3103
|
-
verify_p.add_argument("--
|
|
3104
|
-
verify_p.add_argument("--
|
|
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")
|
|
@@ -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
|