conduct-cli 0.7.11__tar.gz → 0.7.16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/PKG-INFO +1 -1
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/pyproject.toml +1 -1
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/guard.py +121 -123
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/guardmcp.py +20 -7
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/base.py +83 -11
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/pretooluse.py +10 -4
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/main.py +7 -5
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_advisory_and_hook.py +122 -81
- conduct_cli-0.7.16/tests/test_drain_daemon_health.py +206 -0
- conduct_cli-0.7.16/tests/test_proxy_coverage.py +79 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/README.md +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/setup.cfg +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/setup.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.7.11 → conduct_cli-0.7.16}/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.16"
|
|
8
8
|
description = "CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -759,7 +759,7 @@ def cmd_guard_install(args):
|
|
|
759
759
|
# Mirror fail_mode into guard config so the hook can enforce it
|
|
760
760
|
# even when POLICY_PATH is missing or unreadable.
|
|
761
761
|
cfg = _load_guard_config()
|
|
762
|
-
cfg["fail_mode"] = policy.get("fail_mode", "
|
|
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
|
|
|
@@ -183,14 +183,23 @@ def _maybe_sync() -> None:
|
|
|
183
183
|
def _detect_surface(client_info: dict) -> str:
|
|
184
184
|
"""Map MCP clientInfo.name → ai_tool label sent to Guard API."""
|
|
185
185
|
name = (client_info.get("name") or "").lower()
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
# ── Anthropic ─────────────────────────────────────────────────────────────
|
|
187
|
+
if "code" in name and "claude" in name:
|
|
188
|
+
return "claude-code"
|
|
189
|
+
if "desktop" in name and "claude" in name:
|
|
190
|
+
return "claude-desktop"
|
|
188
191
|
if "work" in name or "teams" in name or "enterprise" in name:
|
|
189
|
-
return "
|
|
192
|
+
return "claude-desktop" # Claude for Work / Teams
|
|
190
193
|
if "claude" in name:
|
|
191
|
-
return "
|
|
194
|
+
return "claude-ai" # claude.ai web
|
|
195
|
+
# ── OpenAI ────────────────────────────────────────────────────────────────
|
|
196
|
+
if "chatgpt" in name or "openai" in name:
|
|
197
|
+
return "openai-chatgpt"
|
|
198
|
+
if "codex" in name and "desktop" in name:
|
|
199
|
+
return "codex-desktop"
|
|
192
200
|
if "codex" in name:
|
|
193
|
-
return "codex"
|
|
201
|
+
return "codex-cli"
|
|
202
|
+
# ── Other IDEs ────────────────────────────────────────────────────────────
|
|
194
203
|
if "cursor" in name:
|
|
195
204
|
return "cursor"
|
|
196
205
|
if "windsurf" in name:
|
|
@@ -259,9 +268,13 @@ def _match_policy(tool_name: str, tool_input: dict) -> dict | None:
|
|
|
259
268
|
for rule in rules:
|
|
260
269
|
match_tool = (rule.get("match_tool") or "*").lower()
|
|
261
270
|
if match_tool != "*":
|
|
262
|
-
|
|
263
|
-
if tool_name.lower() not in allowed:
|
|
271
|
+
if tool_name.lower() not in [t.strip() for t in match_tool.split(",")]:
|
|
264
272
|
continue
|
|
273
|
+
match_ai = rule.get("match_ai_tool")
|
|
274
|
+
if match_ai:
|
|
275
|
+
surfaces = [s.strip().lower() for s in match_ai.split(",")]
|
|
276
|
+
if not any(s in ai_tool.lower() for s in surfaces):
|
|
277
|
+
continue # rule targets a different surface
|
|
265
278
|
|
|
266
279
|
pattern = rule.get("match_pattern")
|
|
267
280
|
if pattern:
|
|
@@ -74,24 +74,38 @@ def detect_repo() -> Optional[str]:
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def detect_ai_tool() -> str:
|
|
77
|
-
"""Return a string identifying the active AI coding tool."""
|
|
77
|
+
"""Return a string identifying the active AI coding tool / surface."""
|
|
78
78
|
import os
|
|
79
|
+
# ── Anthropic ─────────────────────────────────────────────────────────────
|
|
79
80
|
if os.environ.get("CLAUDE_CODE_ENTRYPOINT") or os.environ.get("CLAUDECODE"):
|
|
80
81
|
return "claude-code"
|
|
81
|
-
if os.environ.get("
|
|
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:
|
|
@@ -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:
|
|
@@ -325,11 +325,17 @@ def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
|
|
|
325
325
|
input_text = json.dumps(tool_input)
|
|
326
326
|
path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
|
|
327
327
|
|
|
328
|
+
current_ai_tool = detect_ai_tool()
|
|
328
329
|
for rule in rules:
|
|
329
330
|
match_tool = (rule.get("match_tool") or "*").lower()
|
|
330
331
|
if match_tool != "*":
|
|
331
332
|
if tool_name not in [t.strip() for t in match_tool.split(",")]:
|
|
332
333
|
continue
|
|
334
|
+
match_ai = rule.get("match_ai_tool")
|
|
335
|
+
if match_ai:
|
|
336
|
+
surfaces = [s.strip().lower() for s in match_ai.split(",")]
|
|
337
|
+
if not any(s in current_ai_tool.lower() for s in surfaces):
|
|
338
|
+
continue
|
|
333
339
|
pattern = rule.get("match_pattern")
|
|
334
340
|
if pattern:
|
|
335
341
|
try:
|
|
@@ -422,7 +428,7 @@ def main() -> None:
|
|
|
422
428
|
tool_name = (data.get("tool_name") or "").lower()
|
|
423
429
|
tool_input = data.get("tool_input") or {}
|
|
424
430
|
session_id = data.get("session_id")
|
|
425
|
-
msg = "[ConductGuard]
|
|
431
|
+
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
432
|
print(msg)
|
|
427
433
|
print(msg, file=sys.stderr)
|
|
428
434
|
post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id, drain_via=_this_file)
|
|
@@ -436,7 +442,7 @@ def main() -> None:
|
|
|
436
442
|
tool_name = (data.get("tool_name") or "").lower()
|
|
437
443
|
tool_input = data.get("tool_input") or {}
|
|
438
444
|
session_id = data.get("session_id")
|
|
439
|
-
msg = "[ConductGuard]
|
|
445
|
+
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
446
|
print(msg)
|
|
441
447
|
print(msg, file=sys.stderr)
|
|
442
448
|
post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id, drain_via=_this_file)
|
|
@@ -3097,11 +3097,13 @@ def main():
|
|
|
3097
3097
|
sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
|
|
3098
3098
|
|
|
3099
3099
|
# conduct test-guard / test-security / test-security-verify
|
|
3100
|
-
verify_p = sub.add_parser("verify", help="
|
|
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")
|
|
@@ -29,11 +29,13 @@ tests/test_advisory_and_hook.py
|
|
|
29
29
|
tests/test_bash_operator_signature.py
|
|
30
30
|
tests/test_blast_radius.py
|
|
31
31
|
tests/test_command_word_matcher.py
|
|
32
|
+
tests/test_drain_daemon_health.py
|
|
32
33
|
tests/test_guard_policy.py
|
|
33
34
|
tests/test_guard_savings.py
|
|
34
35
|
tests/test_hook_dispatch.py
|
|
35
36
|
tests/test_journal_drain.py
|
|
36
37
|
tests/test_log_util.py
|
|
38
|
+
tests/test_proxy_coverage.py
|
|
37
39
|
tests/test_proxy_env.py
|
|
38
40
|
tests/test_signed_policy.py
|
|
39
41
|
tests/test_switch.py
|
|
@@ -425,35 +425,42 @@ class TestOWASPMapping:
|
|
|
425
425
|
assert self._map("") == "A??"
|
|
426
426
|
|
|
427
427
|
|
|
428
|
-
|
|
429
|
-
{"
|
|
430
|
-
|
|
431
|
-
"rule_id": "excessive_agency_shell",
|
|
432
|
-
"entry_hash": "abc123def456", "policy_hash": "pol789"},
|
|
428
|
+
_MOCK_CONTROLS = [
|
|
429
|
+
{"id": f"ASI-0{i}", "name": f"Control {i}", "status": "active", "control": "desc"}
|
|
430
|
+
for i in range(1, 11)
|
|
433
431
|
]
|
|
434
432
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
433
|
+
_MOCK_EVIDENCE_PASS = {
|
|
434
|
+
"grade": "B", "coverage_pct": 60, "score": 70,
|
|
435
|
+
"blocked_24h": 0, "events_24h": 5,
|
|
436
|
+
"controls": _MOCK_CONTROLS,
|
|
437
|
+
"generated_at": "2026-07-07T10:00:00+00:00",
|
|
438
|
+
"workspace_id": "test-ws-id",
|
|
439
|
+
}
|
|
440
440
|
|
|
441
|
-
|
|
442
|
-
{"
|
|
443
|
-
"tool_call": "Bash", "decision": "allowed", "rule_id": None},
|
|
444
|
-
]
|
|
441
|
+
_MOCK_EVIDENCE_BLOCKED = {**_MOCK_EVIDENCE_PASS, "blocked_24h": 3}
|
|
442
|
+
_MOCK_EVIDENCE_FAIL = {**_MOCK_EVIDENCE_PASS, "grade": "F", "score": 10, "coverage_pct": 20}
|
|
445
443
|
|
|
446
444
|
|
|
447
445
|
class TestVerifyCommand:
|
|
448
|
-
def _run(self, args_list: list[str], capsys) -> int:
|
|
446
|
+
def _run(self, args_list: list[str], capsys, monkeypatch, api_response=None) -> int:
|
|
449
447
|
import argparse
|
|
448
|
+
import conduct_cli.guard as _g
|
|
450
449
|
from conduct_cli.guard import cmd_verify
|
|
451
450
|
|
|
451
|
+
monkeypatch.setattr(_g, "_require_guard_config", lambda: {
|
|
452
|
+
"workspace_id": "test-ws-id", "api_key": "k", "api_url": "https://api.test",
|
|
453
|
+
})
|
|
454
|
+
monkeypatch.setattr(_g, "_api_url", lambda cfg: "https://api.test")
|
|
455
|
+
monkeypatch.setattr(_g, "_req", lambda *a, **kw: api_response or _MOCK_EVIDENCE_PASS)
|
|
456
|
+
|
|
452
457
|
parser = argparse.ArgumentParser()
|
|
453
|
-
parser.add_argument("--evidence",
|
|
454
|
-
parser.add_argument("--
|
|
455
|
-
parser.add_argument("--
|
|
456
|
-
parser.add_argument("--
|
|
458
|
+
parser.add_argument("--evidence", default=None)
|
|
459
|
+
parser.add_argument("--badge", action="store_true")
|
|
460
|
+
parser.add_argument("--min-grade", dest="min_grade", default=None)
|
|
461
|
+
parser.add_argument("--strict", action="store_true")
|
|
462
|
+
parser.add_argument("--format", default="text")
|
|
463
|
+
parser.add_argument("--since", default="24h")
|
|
457
464
|
args = parser.parse_args(args_list)
|
|
458
465
|
|
|
459
466
|
try:
|
|
@@ -462,89 +469,61 @@ class TestVerifyCommand:
|
|
|
462
469
|
except SystemExit as exc:
|
|
463
470
|
return int(exc.code) if exc.code is not None else 0
|
|
464
471
|
|
|
465
|
-
def
|
|
466
|
-
|
|
467
|
-
f.write_text(__import__("json").dumps(_EVIDENCE_CLEAN))
|
|
468
|
-
code = self._run(["--evidence", str(f)], capsys)
|
|
472
|
+
def test_pass_grade_exits_0(self, capsys, monkeypatch):
|
|
473
|
+
code = self._run([], capsys, monkeypatch)
|
|
469
474
|
assert code == 0
|
|
470
475
|
|
|
471
|
-
def test_blocked_without_strict_exits_0(self,
|
|
472
|
-
|
|
473
|
-
f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED))
|
|
474
|
-
code = self._run(["--evidence", str(f)], capsys)
|
|
476
|
+
def test_blocked_without_strict_exits_0(self, capsys, monkeypatch):
|
|
477
|
+
code = self._run(["--strict"], capsys, monkeypatch, api_response=_MOCK_EVIDENCE_PASS)
|
|
475
478
|
assert code == 0
|
|
476
479
|
|
|
477
|
-
def
|
|
478
|
-
|
|
479
|
-
f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED))
|
|
480
|
-
code = self._run(["--evidence", str(f), "--strict"], capsys)
|
|
480
|
+
def test_strict_exits_1_when_blocked(self, capsys, monkeypatch):
|
|
481
|
+
code = self._run(["--strict"], capsys, monkeypatch, api_response=_MOCK_EVIDENCE_BLOCKED)
|
|
481
482
|
assert code == 1
|
|
482
483
|
|
|
483
|
-
def
|
|
484
|
-
|
|
485
|
-
f.write_text(__import__("json").dumps(_EVIDENCE_WARN_ONLY))
|
|
486
|
-
code = self._run(["--evidence", str(f), "--strict"], capsys)
|
|
484
|
+
def test_min_grade_pass(self, capsys, monkeypatch):
|
|
485
|
+
code = self._run(["--min-grade", "C"], capsys, monkeypatch, api_response=_MOCK_EVIDENCE_PASS)
|
|
487
486
|
assert code == 0
|
|
488
487
|
|
|
489
|
-
def
|
|
490
|
-
code = self._run(["--
|
|
491
|
-
assert code ==
|
|
488
|
+
def test_min_grade_fail(self, capsys, monkeypatch):
|
|
489
|
+
code = self._run(["--min-grade", "A"], capsys, monkeypatch, api_response=_MOCK_EVIDENCE_PASS)
|
|
490
|
+
assert code == 1
|
|
492
491
|
|
|
493
|
-
def
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
code = self._run(["--evidence", str(f)], capsys)
|
|
497
|
-
assert code == 2
|
|
492
|
+
def test_fail_grade_below_min(self, capsys, monkeypatch):
|
|
493
|
+
code = self._run(["--min-grade", "B"], capsys, monkeypatch, api_response=_MOCK_EVIDENCE_FAIL)
|
|
494
|
+
assert code == 1
|
|
498
495
|
|
|
499
|
-
def
|
|
500
|
-
|
|
501
|
-
f.write_text("[]")
|
|
502
|
-
code = self._run(["--evidence", str(f)], capsys)
|
|
503
|
-
assert code == 0
|
|
496
|
+
def test_badge_output(self, capsys, monkeypatch):
|
|
497
|
+
self._run(["--badge"], capsys, monkeypatch)
|
|
504
498
|
out = capsys.readouterr().out
|
|
505
|
-
assert "
|
|
499
|
+
assert "img.shields.io" in out
|
|
500
|
+
assert "Grade_B" in out
|
|
506
501
|
|
|
507
|
-
def
|
|
502
|
+
def test_evidence_writes_file(self, tmp_path, capsys, monkeypatch):
|
|
508
503
|
import json
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
assert data["status"] == "fail"
|
|
515
|
-
assert data["summary"]["blocked"] == 1
|
|
504
|
+
out_file = tmp_path / "evidence.json"
|
|
505
|
+
self._run(["--evidence", str(out_file)], capsys, monkeypatch)
|
|
506
|
+
data = json.loads(out_file.read_text())
|
|
507
|
+
assert data["grade"] == "B"
|
|
508
|
+
assert "controls" in data
|
|
516
509
|
|
|
517
|
-
def
|
|
510
|
+
def test_json_format_output(self, capsys, monkeypatch):
|
|
518
511
|
import json
|
|
519
|
-
|
|
520
|
-
f.write_text(json.dumps(_EVIDENCE_BLOCKED))
|
|
521
|
-
self._run(["--evidence", str(f), "--format", "json"], capsys)
|
|
512
|
+
self._run(["--format", "json"], capsys, monkeypatch)
|
|
522
513
|
out = capsys.readouterr().out
|
|
523
514
|
data = json.loads(out)
|
|
524
|
-
assert data["
|
|
515
|
+
assert data["grade"] == "B"
|
|
525
516
|
|
|
526
|
-
def
|
|
527
|
-
|
|
528
|
-
f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED))
|
|
529
|
-
self._run(["--evidence", str(f)], capsys)
|
|
517
|
+
def test_grade_shown_in_text_output(self, capsys, monkeypatch):
|
|
518
|
+
self._run([], capsys, monkeypatch)
|
|
530
519
|
out = capsys.readouterr().out
|
|
531
|
-
assert "
|
|
520
|
+
assert "Grade" in out
|
|
521
|
+
assert "B" in out
|
|
532
522
|
|
|
533
|
-
def
|
|
534
|
-
|
|
535
|
-
f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED + _EVIDENCE_WARN_ONLY))
|
|
536
|
-
self._run(["--evidence", str(f)], capsys)
|
|
523
|
+
def test_controls_listed_in_text_output(self, capsys, monkeypatch):
|
|
524
|
+
self._run([], capsys, monkeypatch)
|
|
537
525
|
out = capsys.readouterr().out
|
|
538
|
-
assert "
|
|
539
|
-
assert "A02" in out
|
|
540
|
-
|
|
541
|
-
def test_envelope_json_input(self, tmp_path, capsys):
|
|
542
|
-
"""Accept {'events': [...]} envelope as well as raw array."""
|
|
543
|
-
import json
|
|
544
|
-
f = tmp_path / "ev.json"
|
|
545
|
-
f.write_text(json.dumps({"events": _EVIDENCE_CLEAN}))
|
|
546
|
-
code = self._run(["--evidence", str(f)], capsys)
|
|
547
|
-
assert code == 0
|
|
526
|
+
assert "ASI-01" in out
|
|
548
527
|
|
|
549
528
|
|
|
550
529
|
# ── invalid stdin ─────────────────────────────────────────────────────────────
|
|
@@ -563,3 +542,65 @@ class TestInvalidStdin:
|
|
|
563
542
|
with pytest.raises(SystemExit) as exc:
|
|
564
543
|
pt.main()
|
|
565
544
|
assert exc.value.code == 0
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ── Fail-closed: API unreachable (budget check path) ──────────────────────────
|
|
548
|
+
|
|
549
|
+
class TestFailClosedApiUnreachable:
|
|
550
|
+
"""fail_closed + policy exists + budget API unreachable → blocks with clear message."""
|
|
551
|
+
|
|
552
|
+
def test_api_unreachable_blocks_with_clear_message(self, monkeypatch, tmp_path, capsys):
|
|
553
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
554
|
+
from io import StringIO
|
|
555
|
+
|
|
556
|
+
policy_file = tmp_path / "policy.json"
|
|
557
|
+
policy_file.write_text('{"rules": [], "fail_mode": "fail_closed"}')
|
|
558
|
+
|
|
559
|
+
monkeypatch.setattr(pt, "active_policy_path", lambda: policy_file)
|
|
560
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_closed")
|
|
561
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
|
|
562
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
563
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
564
|
+
# Budget cache miss, then API unreachable → returns (False, None)
|
|
565
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (None, None))
|
|
566
|
+
monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
|
|
567
|
+
|
|
568
|
+
posted: list[dict] = []
|
|
569
|
+
def _fake_post(tn, ti, decision, rule_id, msg, sid, **kw):
|
|
570
|
+
posted.append({"decision": decision, "rule_id": rule_id, "msg": msg})
|
|
571
|
+
monkeypatch.setattr(pt, "post_event", _fake_post)
|
|
572
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
573
|
+
|
|
574
|
+
with pytest.raises(SystemExit) as exc:
|
|
575
|
+
pt.main()
|
|
576
|
+
|
|
577
|
+
assert exc.value.code == 2
|
|
578
|
+
assert posted[0]["decision"] == "blocked"
|
|
579
|
+
assert posted[0]["rule_id"] == "guard-unavailable"
|
|
580
|
+
# Message must say "unreachable", not "budget"
|
|
581
|
+
assert "unreachable" in posted[0]["msg"].lower()
|
|
582
|
+
assert "budget" not in posted[0]["msg"].lower()
|
|
583
|
+
|
|
584
|
+
def test_api_unreachable_fail_open_passes(self, monkeypatch, tmp_path):
|
|
585
|
+
"""Same scenario but fail_open → should NOT block."""
|
|
586
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
587
|
+
from io import StringIO
|
|
588
|
+
|
|
589
|
+
policy_file = tmp_path / "policy.json"
|
|
590
|
+
policy_file.write_text('{"rules": []}')
|
|
591
|
+
|
|
592
|
+
monkeypatch.setattr(pt, "active_policy_path", lambda: policy_file)
|
|
593
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
|
|
594
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
|
|
595
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
596
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
597
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (None, None))
|
|
598
|
+
monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
|
|
599
|
+
monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
|
|
600
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
601
|
+
|
|
602
|
+
# Should not raise SystemExit(2) — exits 0 (allow)
|
|
603
|
+
try:
|
|
604
|
+
pt.main()
|
|
605
|
+
except SystemExit as e:
|
|
606
|
+
assert e.code == 0
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Tests for drain daemon stale-PID detection and ensure_drain_daemon restart logic."""
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch, MagicMock
|
|
6
|
+
|
|
7
|
+
import conduct_cli.hooks.base as base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ── drain_daemon_status ────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
def test_status_dead_no_pid_file(tmp_path):
|
|
13
|
+
with patch.object(base, "JOURNAL_PID_PATH", tmp_path / "drain.pid"):
|
|
14
|
+
status, pid = base.drain_daemon_status()
|
|
15
|
+
assert status == "dead"
|
|
16
|
+
assert pid is None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_status_dead_process_gone(tmp_path):
|
|
20
|
+
pid_file = tmp_path / "drain.pid"
|
|
21
|
+
pid_file.write_text("999999999") # non-existent PID
|
|
22
|
+
with patch.object(base, "JOURNAL_PID_PATH", pid_file):
|
|
23
|
+
status, pid = base.drain_daemon_status()
|
|
24
|
+
assert status == "dead"
|
|
25
|
+
assert pid is None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_status_running_fresh(tmp_path):
|
|
29
|
+
pid_file = tmp_path / "drain.pid"
|
|
30
|
+
pid_file.write_text(str(os.getpid())) # current process — definitely alive
|
|
31
|
+
# mtime is now → fresh
|
|
32
|
+
with patch.object(base, "JOURNAL_PID_PATH", pid_file):
|
|
33
|
+
status, pid = base.drain_daemon_status()
|
|
34
|
+
assert status == "running"
|
|
35
|
+
assert pid == os.getpid()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_status_stale_old_mtime(tmp_path):
|
|
39
|
+
pid_file = tmp_path / "drain.pid"
|
|
40
|
+
pid_file.write_text(str(os.getpid()))
|
|
41
|
+
# backdate mtime beyond stale threshold
|
|
42
|
+
old = time.time() - base._DAEMON_STALE_SECS - 60
|
|
43
|
+
os.utime(pid_file, (old, old))
|
|
44
|
+
with patch.object(base, "JOURNAL_PID_PATH", pid_file):
|
|
45
|
+
status, pid = base.drain_daemon_status()
|
|
46
|
+
assert status == "stale"
|
|
47
|
+
assert pid == os.getpid()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_status_corrupt_pid_file(tmp_path):
|
|
51
|
+
pid_file = tmp_path / "drain.pid"
|
|
52
|
+
pid_file.write_text("not-a-number")
|
|
53
|
+
with patch.object(base, "JOURNAL_PID_PATH", pid_file):
|
|
54
|
+
status, pid = base.drain_daemon_status()
|
|
55
|
+
assert status == "dead"
|
|
56
|
+
assert pid is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── ensure_drain_daemon ────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def test_ensure_no_spawn_when_running(tmp_path):
|
|
62
|
+
pid_file = tmp_path / "drain.pid"
|
|
63
|
+
pid_file.write_text(str(os.getpid()))
|
|
64
|
+
|
|
65
|
+
with patch.object(base, "JOURNAL_PID_PATH", pid_file), \
|
|
66
|
+
patch.object(base, "subprocess") as mock_sub:
|
|
67
|
+
base.ensure_drain_daemon(hook_module_path=Path("/fake/hook.py"))
|
|
68
|
+
|
|
69
|
+
mock_sub.Popen.assert_not_called()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_ensure_spawns_when_dead(tmp_path):
|
|
73
|
+
pid_file = tmp_path / "drain.pid"
|
|
74
|
+
# no PID file → dead
|
|
75
|
+
|
|
76
|
+
with patch.object(base, "JOURNAL_PID_PATH", pid_file), \
|
|
77
|
+
patch.object(base, "subprocess") as mock_sub:
|
|
78
|
+
base.ensure_drain_daemon(hook_module_path=Path("/fake/hook.py"))
|
|
79
|
+
|
|
80
|
+
mock_sub.Popen.assert_called_once()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_ensure_kills_stale_and_spawns(tmp_path):
|
|
84
|
+
pid_file = tmp_path / "drain.pid"
|
|
85
|
+
pid_file.write_text(str(os.getpid()))
|
|
86
|
+
old = time.time() - base._DAEMON_STALE_SECS - 60
|
|
87
|
+
os.utime(pid_file, (old, old))
|
|
88
|
+
|
|
89
|
+
killed = []
|
|
90
|
+
|
|
91
|
+
def fake_kill(pid, sig):
|
|
92
|
+
killed.append((pid, sig))
|
|
93
|
+
|
|
94
|
+
with patch.object(base, "JOURNAL_PID_PATH", pid_file), \
|
|
95
|
+
patch.object(base, "subprocess"), \
|
|
96
|
+
patch("os.kill", fake_kill):
|
|
97
|
+
base.ensure_drain_daemon(hook_module_path=Path("/fake/hook.py"))
|
|
98
|
+
|
|
99
|
+
# stale PID (our own process) should have received SIGTERM
|
|
100
|
+
assert any(sig == 15 for _, sig in killed)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_ensure_no_spawn_without_hook_path(tmp_path):
|
|
104
|
+
"""hook_module_path=None → no spawn even when dead (test/debug mode)."""
|
|
105
|
+
pid_file = tmp_path / "drain.pid"
|
|
106
|
+
|
|
107
|
+
with patch.object(base, "JOURNAL_PID_PATH", pid_file), \
|
|
108
|
+
patch.object(base, "subprocess") as mock_sub:
|
|
109
|
+
base.ensure_drain_daemon(hook_module_path=None)
|
|
110
|
+
|
|
111
|
+
mock_sub.Popen.assert_not_called()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── PID file touch in drain loop ───────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def test_pid_file_touched_after_flush(tmp_path):
|
|
117
|
+
"""run_drain_daemon touches the PID file after each flush so mtime stays fresh."""
|
|
118
|
+
pid_file = tmp_path / "drain.pid"
|
|
119
|
+
journal_dir = tmp_path / "journal"
|
|
120
|
+
journal_dir.mkdir()
|
|
121
|
+
|
|
122
|
+
# Write one fake journal entry that will fail to POST (no server)
|
|
123
|
+
entry = journal_dir / "ev1.json"
|
|
124
|
+
entry.write_text('{"api_url": "http://127.0.0.1:19999", "payload": "{}"}')
|
|
125
|
+
|
|
126
|
+
before = time.time() - 5
|
|
127
|
+
|
|
128
|
+
with patch.object(base, "JOURNAL_DIR", journal_dir), \
|
|
129
|
+
patch.object(base, "JOURNAL_PID_PATH", pid_file):
|
|
130
|
+
# Run just enough of the drain loop to hit the touch
|
|
131
|
+
# Patch sleep to break out after first iteration
|
|
132
|
+
call_count = [0]
|
|
133
|
+
orig_sleep = time.sleep
|
|
134
|
+
def fast_sleep(n):
|
|
135
|
+
call_count[0] += 1
|
|
136
|
+
if call_count[0] >= 2:
|
|
137
|
+
raise KeyboardInterrupt
|
|
138
|
+
with patch("time.sleep", fast_sleep):
|
|
139
|
+
try:
|
|
140
|
+
base.run_drain_daemon()
|
|
141
|
+
except KeyboardInterrupt:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
if pid_file.exists():
|
|
145
|
+
assert pid_file.stat().st_mtime >= before
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── Policy refresh ─────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def test_refresh_policy_writes_file(tmp_path):
|
|
151
|
+
"""_refresh_policy_from_api writes API response to POLICY_PATH atomically."""
|
|
152
|
+
import urllib.request as _ur
|
|
153
|
+
from unittest.mock import MagicMock
|
|
154
|
+
|
|
155
|
+
fake_policy = b'{"rules": [{"id": "r1"}]}'
|
|
156
|
+
policy_path = tmp_path / "policy.json"
|
|
157
|
+
|
|
158
|
+
fake_resp = MagicMock()
|
|
159
|
+
fake_resp.read.return_value = fake_policy
|
|
160
|
+
fake_resp.__enter__ = lambda s: s
|
|
161
|
+
fake_resp.__exit__ = MagicMock(return_value=False)
|
|
162
|
+
|
|
163
|
+
with patch.object(base, "POLICY_PATH", policy_path), \
|
|
164
|
+
patch.object(base, "load_config", return_value={
|
|
165
|
+
"workspace_id": "ws_test",
|
|
166
|
+
"api_key": "cond_live_test",
|
|
167
|
+
"api_url": "https://api.conductai.ai",
|
|
168
|
+
}), \
|
|
169
|
+
patch("urllib.request.urlopen", return_value=fake_resp):
|
|
170
|
+
base._refresh_policy_from_api()
|
|
171
|
+
|
|
172
|
+
assert policy_path.exists()
|
|
173
|
+
assert policy_path.read_bytes() == fake_policy
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_refresh_policy_skips_without_api_key(tmp_path):
|
|
177
|
+
"""_refresh_policy_from_api is a no-op when api_key is absent."""
|
|
178
|
+
policy_path = tmp_path / "policy.json"
|
|
179
|
+
|
|
180
|
+
with patch.object(base, "POLICY_PATH", policy_path), \
|
|
181
|
+
patch.object(base, "load_config", return_value={"workspace_id": "ws_test"}), \
|
|
182
|
+
patch("urllib.request.urlopen") as mock_open:
|
|
183
|
+
base._refresh_policy_from_api()
|
|
184
|
+
|
|
185
|
+
mock_open.assert_not_called()
|
|
186
|
+
assert not policy_path.exists()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_refresh_policy_works_without_workspace_id(tmp_path):
|
|
190
|
+
"""_refresh_policy_from_api omits workspace_id param when not in config (org-level Guard)."""
|
|
191
|
+
fake_policy = b'{"rules": []}'
|
|
192
|
+
policy_path = tmp_path / "policy.json"
|
|
193
|
+
|
|
194
|
+
fake_resp = MagicMock()
|
|
195
|
+
fake_resp.read.return_value = fake_policy
|
|
196
|
+
fake_resp.__enter__ = lambda s: s
|
|
197
|
+
fake_resp.__exit__ = MagicMock(return_value=False)
|
|
198
|
+
|
|
199
|
+
with patch.object(base, "POLICY_PATH", policy_path), \
|
|
200
|
+
patch.object(base, "load_config", return_value={"api_key": "cond_live_test"}), \
|
|
201
|
+
patch("urllib.request.urlopen", return_value=fake_resp) as mock_open:
|
|
202
|
+
base._refresh_policy_from_api()
|
|
203
|
+
|
|
204
|
+
called_url = mock_open.call_args[0][0].full_url
|
|
205
|
+
assert "workspace_id" not in called_url
|
|
206
|
+
assert policy_path.read_bytes() == fake_policy
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for proxy coverage detection in guard status."""
|
|
2
|
+
import io
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _run_coverage_block(env: dict, cfg: dict, env_file_exists: bool = True) -> str:
|
|
9
|
+
"""Run only the proxy-coverage printing block from cmd_guard_status."""
|
|
10
|
+
import conduct_cli.guard as g
|
|
11
|
+
|
|
12
|
+
output = io.StringIO()
|
|
13
|
+
with patch.dict("os.environ", env, clear=True), \
|
|
14
|
+
patch.object(Path, "exists", return_value=env_file_exists):
|
|
15
|
+
proxy_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/") + "/proxy"
|
|
16
|
+
_env = env
|
|
17
|
+
anthropic_ok = proxy_url in _env.get("ANTHROPIC_BASE_URL", "")
|
|
18
|
+
openai_ok = proxy_url in _env.get("OPENAI_BASE_URL", "")
|
|
19
|
+
perplexity_ok = proxy_url in _env.get("PERPLEXITY_BASE_URL", "")
|
|
20
|
+
|
|
21
|
+
covered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if ok]
|
|
22
|
+
uncovered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if not ok]
|
|
23
|
+
|
|
24
|
+
GREEN, YELLOW, RED, RESET, BOLD = "", "", "", "", ""
|
|
25
|
+
|
|
26
|
+
if covered and not uncovered:
|
|
27
|
+
line = f"active — {', '.join(covered)} routed through Guard proxy"
|
|
28
|
+
elif covered:
|
|
29
|
+
line = f"partial — {', '.join(covered)} covered · {', '.join(uncovered)} NOT intercepted"
|
|
30
|
+
elif env_file_exists:
|
|
31
|
+
line = "inactive — ~/.conduct/env exists but not sourced in this shell."
|
|
32
|
+
else:
|
|
33
|
+
line = "not configured — run: conduct guard sync"
|
|
34
|
+
return line
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_CFG = {"api_url": "https://api.conductai.ai"}
|
|
38
|
+
_PROXY = "https://api.conductai.ai/proxy"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_all_three_active():
|
|
42
|
+
env = {
|
|
43
|
+
"ANTHROPIC_BASE_URL": _PROXY,
|
|
44
|
+
"OPENAI_BASE_URL": f"{_PROXY}/openai",
|
|
45
|
+
"PERPLEXITY_BASE_URL": f"{_PROXY}/perplexity",
|
|
46
|
+
}
|
|
47
|
+
line = _run_coverage_block(env, _CFG)
|
|
48
|
+
assert line.startswith("active")
|
|
49
|
+
assert "Anthropic" in line
|
|
50
|
+
assert "OpenAI" in line
|
|
51
|
+
assert "Perplexity" in line
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_partial_coverage():
|
|
55
|
+
env = {"ANTHROPIC_BASE_URL": _PROXY}
|
|
56
|
+
line = _run_coverage_block(env, _CFG)
|
|
57
|
+
assert line.startswith("partial")
|
|
58
|
+
assert "Anthropic" in line
|
|
59
|
+
assert "NOT intercepted" in line
|
|
60
|
+
assert "OpenAI" in line
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_inactive_env_file_exists():
|
|
64
|
+
line = _run_coverage_block({}, _CFG, env_file_exists=True)
|
|
65
|
+
assert line.startswith("inactive")
|
|
66
|
+
assert "~/.conduct/env" in line
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_not_configured_no_env_file():
|
|
70
|
+
line = _run_coverage_block({}, _CFG, env_file_exists=False)
|
|
71
|
+
assert line.startswith("not configured")
|
|
72
|
+
assert "conduct guard sync" in line
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_wrong_proxy_url_not_counted():
|
|
76
|
+
"""Env var pointing to a different proxy should not count as covered."""
|
|
77
|
+
env = {"ANTHROPIC_BASE_URL": "https://some-other-proxy.io"}
|
|
78
|
+
line = _run_coverage_block(env, _CFG, env_file_exists=True)
|
|
79
|
+
assert line.startswith("inactive")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|