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.
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/PKG-INFO +1 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/pyproject.toml +1 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/guard.py +124 -124
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/guardmcp.py +21 -7
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/base.py +85 -11
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/posttooluse.py +54 -7
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/pretooluse.py +12 -5
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/main.py +7 -5
- conduct_cli-0.7.17/src/conduct_cli/tool_groups.py +58 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/SOURCES.txt +4 -1
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_advisory_and_hook.py +122 -81
- conduct_cli-0.7.17/tests/test_blast_radius.py +44 -0
- conduct_cli-0.7.17/tests/test_drain_daemon_health.py +206 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_guard_policy.py +8 -4
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_journal_drain.py +31 -34
- conduct_cli-0.7.17/tests/test_proxy_coverage.py +79 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/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.17}/README.md +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/setup.cfg +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/setup.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.10 → conduct_cli-0.7.17}/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.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
|
|
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", "
|
|
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 —
|
|
2562
|
+
"""conduct verify — governance grade + OWASP Agentic Top 10 coverage."""
|
|
2513
2563
|
import json as _json
|
|
2514
|
-
import datetime as _dt
|
|
2515
2564
|
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
# ──
|
|
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
|
-
})
|
|
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
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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"")
|
|
2594
|
+
return
|
|
2580
2595
|
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
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
|
-
|
|
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}
|
|
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}"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
f"{RED
|
|
2642
|
-
|
|
2643
|
-
)
|
|
2644
|
-
|
|
2645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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 "
|
|
193
|
+
return "claude-desktop" # Claude for Work / Teams
|
|
190
194
|
if "claude" in name:
|
|
191
|
-
return "
|
|
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
|
-
|
|
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("
|
|
82
|
-
return "
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
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(
|
|
127
|
-
|
|
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
|
|
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)
|