conduct-cli 0.6.37__tar.gz → 0.7.1__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.6.37 → conduct_cli-0.7.1}/PKG-INFO +1 -1
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/pyproject.toml +1 -1
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/guard.py +182 -1
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/pretooluse.py +9 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/main.py +7 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
- conduct_cli-0.7.1/tests/test_advisory_and_hook.py +565 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/README.md +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/setup.cfg +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/setup.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/base.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_log_util.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.6.37 → conduct_cli-0.7.1}/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
|
+
version = "0.7.1"
|
|
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" }
|
|
@@ -751,9 +751,11 @@ def cmd_guard_install(args):
|
|
|
751
751
|
# even when POLICY_PATH is missing or unreadable.
|
|
752
752
|
cfg = _load_guard_config()
|
|
753
753
|
cfg["fail_mode"] = policy.get("fail_mode", "fail_open")
|
|
754
|
+
cfg["advisory_mode"] = policy.get("advisory_mode", False)
|
|
754
755
|
_save_guard_config(cfg)
|
|
755
756
|
rule_count = len(policy.get("rules", []))
|
|
756
|
-
|
|
757
|
+
advisory_label = " · advisory" if cfg["advisory_mode"] else ""
|
|
758
|
+
print(f" {GREEN}Guard policies:{RESET} {rule_count} rule(s) active · fail mode: {cfg['fail_mode']}{advisory_label}")
|
|
757
759
|
except SystemExit:
|
|
758
760
|
rule_count = 0
|
|
759
761
|
|
|
@@ -2444,6 +2446,185 @@ def cmd_guard_debug_hook(args):
|
|
|
2444
2446
|
print(f"\n{BOLD}Exit code:{RESET} {exit_label}")
|
|
2445
2447
|
|
|
2446
2448
|
|
|
2449
|
+
# ── OWASP Agentic Top 10 mapping ──────────────────────────────────────────────
|
|
2450
|
+
|
|
2451
|
+
_PRIV_KEYWORDS = ["privilege", "escalat", "role_abuse", "admin_access", "no-sudo", "nosudo", "runasroot"]
|
|
2452
|
+
|
|
2453
|
+
_OWASP_MAP: list[tuple[list[str], str, str]] = [
|
|
2454
|
+
(["prompt_inject", "injection", "jailbreak", "override_system", "indirect_prompt"],
|
|
2455
|
+
"A01", "Prompt Injection"),
|
|
2456
|
+
(["secret", "credential", "token_leak", "api_key_leak", "password", "sensitive_data", "pii", "exfil"],
|
|
2457
|
+
"A02", "Sensitive Information Disclosure"),
|
|
2458
|
+
(["supply_chain", "dependency", "package", "malicious_package", "unsigned"],
|
|
2459
|
+
"A03", "Supply Chain Vulnerabilities"),
|
|
2460
|
+
(["excessive_agency", "no-rm-rf", "norms", "rmrf", "scope", "overreach", "unauthorized_action", "out_of_scope", "shell_destruct"],
|
|
2461
|
+
"A04", "Excessive Agency"),
|
|
2462
|
+
(["model_theft", "model_extract", "weights"],
|
|
2463
|
+
"A05", "Model Theft"),
|
|
2464
|
+
(["communication", "callback", "exfiltrate_output", "covert_channel"],
|
|
2465
|
+
"A06", "Agentic Communication Vulnerabilities"),
|
|
2466
|
+
(["unmonitored", "no_audit", "policy_eval_error", "policy_signature_invalid"],
|
|
2467
|
+
"A07", "Insufficient Monitoring and Logging"),
|
|
2468
|
+
(["hallucin", "unverified", "over_reliance", "fabricat"],
|
|
2469
|
+
"A08", "Over-reliance on Model Outputs"),
|
|
2470
|
+
(_PRIV_KEYWORDS,
|
|
2471
|
+
"A09", "Privilege Escalation"),
|
|
2472
|
+
(["budget", "rate_limit", "dos", "denial", "hard_cap", "spend"],
|
|
2473
|
+
"A10", "Model Denial of Service"),
|
|
2474
|
+
]
|
|
2475
|
+
|
|
2476
|
+
_OWASP_UNKNOWN = ("A??", "Uncategorised")
|
|
2477
|
+
|
|
2478
|
+
|
|
2479
|
+
def _map_owasp(rule_id: str) -> tuple[str, str]:
|
|
2480
|
+
key = (rule_id or "").lower()
|
|
2481
|
+
for keywords, code, title in _OWASP_MAP:
|
|
2482
|
+
if any(k in key for k in keywords):
|
|
2483
|
+
return code, title
|
|
2484
|
+
return _OWASP_UNKNOWN
|
|
2485
|
+
|
|
2486
|
+
|
|
2487
|
+
def cmd_verify(args) -> None:
|
|
2488
|
+
"""conduct verify — map guard audit events to OWASP Agentic Top 10; exit non-zero in CI."""
|
|
2489
|
+
import json as _json
|
|
2490
|
+
import datetime as _dt
|
|
2491
|
+
|
|
2492
|
+
evidence_path = getattr(args, "evidence", None)
|
|
2493
|
+
strict = getattr(args, "strict", False)
|
|
2494
|
+
fmt = getattr(args, "format", "text")
|
|
2495
|
+
since_str = getattr(args, "since", None) or "24h"
|
|
2496
|
+
|
|
2497
|
+
# ── load events ──────────────────────────────────────────────────────────
|
|
2498
|
+
if evidence_path:
|
|
2499
|
+
try:
|
|
2500
|
+
raw = _json.loads(Path(evidence_path).read_text())
|
|
2501
|
+
except FileNotFoundError:
|
|
2502
|
+
print(f"{RED}✗ File not found: {evidence_path}{RESET}", file=sys.stderr)
|
|
2503
|
+
sys.exit(2)
|
|
2504
|
+
except _json.JSONDecodeError as exc:
|
|
2505
|
+
print(f"{RED}✗ Invalid JSON: {exc}{RESET}", file=sys.stderr)
|
|
2506
|
+
sys.exit(2)
|
|
2507
|
+
events = raw if isinstance(raw, list) else raw.get("events", [])
|
|
2508
|
+
else:
|
|
2509
|
+
cfg = _require_guard_config()
|
|
2510
|
+
workspace_id = cfg.get("workspace_id")
|
|
2511
|
+
user_email = cfg.get("user_email", "")
|
|
2512
|
+
api_key = cfg.get("api_key", "")
|
|
2513
|
+
base_url = _api_url(cfg)
|
|
2514
|
+
since_iso = _parse_since(since_str)
|
|
2515
|
+
resp = _req(
|
|
2516
|
+
"GET",
|
|
2517
|
+
(
|
|
2518
|
+
f"{base_url}/guard/events"
|
|
2519
|
+
f"?workspace_id={workspace_id}"
|
|
2520
|
+
f"&user_email={user_email}"
|
|
2521
|
+
f"&since={since_iso}"
|
|
2522
|
+
f"&limit=200"
|
|
2523
|
+
),
|
|
2524
|
+
api_key=api_key,
|
|
2525
|
+
)
|
|
2526
|
+
events = resp if isinstance(resp, list) else resp.get("events", [])
|
|
2527
|
+
|
|
2528
|
+
if not events:
|
|
2529
|
+
if fmt == "json":
|
|
2530
|
+
print(_json.dumps({"status": "pass", "violations": 0, "findings": []}, indent=2))
|
|
2531
|
+
else:
|
|
2532
|
+
print(f"{GREEN}✓ No guard events found — nothing to verify.{RESET}")
|
|
2533
|
+
return
|
|
2534
|
+
|
|
2535
|
+
# ── classify ──────────────────────────────────────────────────────────────
|
|
2536
|
+
findings: list[dict] = []
|
|
2537
|
+
for ev in events:
|
|
2538
|
+
decision = ev.get("decision", "allowed")
|
|
2539
|
+
rule_id = ev.get("rule_id") or ev.get("rule_message") or ""
|
|
2540
|
+
owasp_code, owasp_title = _map_owasp(rule_id)
|
|
2541
|
+
findings.append({
|
|
2542
|
+
"timestamp": (ev.get("timestamp") or ev.get("created_at") or "")[:19].replace("T", " ") or "—",
|
|
2543
|
+
"tool": ev.get("ai_tool") or "—",
|
|
2544
|
+
"action": (ev.get("tool_call") or "—")[:40],
|
|
2545
|
+
"decision": decision,
|
|
2546
|
+
"rule_id": rule_id or "—",
|
|
2547
|
+
"owasp": owasp_code,
|
|
2548
|
+
"owasp_title": owasp_title,
|
|
2549
|
+
"entry_hash": (ev.get("entry_hash") or "")[:12] or None,
|
|
2550
|
+
"policy_hash": (ev.get("policy_hash") or "")[:12] or None,
|
|
2551
|
+
})
|
|
2552
|
+
|
|
2553
|
+
blocked_count = sum(1 for f in findings if f["decision"] == "blocked")
|
|
2554
|
+
warned_count = sum(1 for f in findings if f["decision"] == "warned")
|
|
2555
|
+
total = len(findings)
|
|
2556
|
+
|
|
2557
|
+
owasp_counts: dict[str, int] = {}
|
|
2558
|
+
for f in findings:
|
|
2559
|
+
if f["decision"] in ("blocked", "warned"):
|
|
2560
|
+
owasp_counts[f["owasp"]] = owasp_counts.get(f["owasp"], 0) + 1
|
|
2561
|
+
|
|
2562
|
+
# ── output ───────────────────────────────────────────────────────────────
|
|
2563
|
+
if fmt == "json":
|
|
2564
|
+
out = {
|
|
2565
|
+
"status": "fail" if (strict and blocked_count) else "pass",
|
|
2566
|
+
"generated_at": _dt.datetime.utcnow().isoformat() + "Z",
|
|
2567
|
+
"summary": {"total": total, "blocked": blocked_count, "warned": warned_count},
|
|
2568
|
+
"owasp_distribution": owasp_counts,
|
|
2569
|
+
"findings": findings,
|
|
2570
|
+
}
|
|
2571
|
+
print(_json.dumps(out, indent=2))
|
|
2572
|
+
else:
|
|
2573
|
+
print()
|
|
2574
|
+
print(f"{BOLD}conduct verify — OWASP Agentic Top 10 Report{RESET}")
|
|
2575
|
+
print("─" * 78)
|
|
2576
|
+
|
|
2577
|
+
if owasp_counts:
|
|
2578
|
+
print(f"\n{BOLD}OWASP Distribution{RESET}")
|
|
2579
|
+
for _kws, code, title in _OWASP_MAP:
|
|
2580
|
+
count = owasp_counts.get(code, 0)
|
|
2581
|
+
if count:
|
|
2582
|
+
bar = "█" * min(count, 20)
|
|
2583
|
+
label = f"{code}: {title}"
|
|
2584
|
+
print(f" {label:<40} {RED}{bar} {count}{RESET}")
|
|
2585
|
+
unk = owasp_counts.get("A??", 0)
|
|
2586
|
+
if unk:
|
|
2587
|
+
print(f" {'A??: Uncategorised':<40} {GRAY}{'█' * min(unk, 20)} {unk}{RESET}")
|
|
2588
|
+
|
|
2589
|
+
print(f"\n{BOLD}{'Timestamp':<20} {'Tool':<14} {'Decision':<10} {'OWASP':<5} Rule{RESET}")
|
|
2590
|
+
print("─" * 78)
|
|
2591
|
+
for f in findings:
|
|
2592
|
+
if f["decision"] == "blocked":
|
|
2593
|
+
dec_col = f"{RED}blocked{RESET}"
|
|
2594
|
+
elif f["decision"] == "warned":
|
|
2595
|
+
dec_col = f"{YELLOW}warned{RESET}"
|
|
2596
|
+
elif f["decision"] == "audited":
|
|
2597
|
+
dec_col = f"{BLUE}audited{RESET}"
|
|
2598
|
+
else:
|
|
2599
|
+
dec_col = f"{GRAY}{f['decision']}{RESET}"
|
|
2600
|
+
chain_note = f" {GRAY}chain:{f['entry_hash']}{RESET}" if f["entry_hash"] else ""
|
|
2601
|
+
print(
|
|
2602
|
+
f" {f['timestamp']:<20} {f['tool']:<14} {dec_col:<10} "
|
|
2603
|
+
f"{f['owasp']:<5} {f['rule_id']}{chain_note}"
|
|
2604
|
+
)
|
|
2605
|
+
|
|
2606
|
+
print()
|
|
2607
|
+
if blocked_count:
|
|
2608
|
+
status_icon = f"{RED}✗{RESET}"
|
|
2609
|
+
status_label = f"{RED}FAIL{RESET}" if strict else f"{YELLOW}WARN{RESET}"
|
|
2610
|
+
else:
|
|
2611
|
+
status_icon = f"{GREEN}✓{RESET}"
|
|
2612
|
+
status_label = f"{GREEN}PASS{RESET}"
|
|
2613
|
+
|
|
2614
|
+
print(
|
|
2615
|
+
f" {status_icon} {status_label} "
|
|
2616
|
+
f"{total} events · "
|
|
2617
|
+
f"{RED if blocked_count else GRAY}{blocked_count} blocked{RESET} · "
|
|
2618
|
+
f"{YELLOW if warned_count else GRAY}{warned_count} warned{RESET}"
|
|
2619
|
+
)
|
|
2620
|
+
if strict and blocked_count:
|
|
2621
|
+
print(f"\n {RED}Strict mode: non-zero exit — {blocked_count} blocked event(s).{RESET}")
|
|
2622
|
+
print()
|
|
2623
|
+
|
|
2624
|
+
if strict and blocked_count:
|
|
2625
|
+
sys.exit(1)
|
|
2626
|
+
|
|
2627
|
+
|
|
2447
2628
|
def dispatch_guard(args, guard_p):
|
|
2448
2629
|
"""Dispatch to the correct guard handler. Called from main()."""
|
|
2449
2630
|
guard_command = getattr(args, "guard_command", None)
|
|
@@ -224,6 +224,11 @@ def _get_fail_mode() -> str:
|
|
|
224
224
|
return mode if mode in ("fail_open", "fail_closed") else "fail_open"
|
|
225
225
|
|
|
226
226
|
|
|
227
|
+
def _get_advisory_mode() -> bool:
|
|
228
|
+
cfg = load_config()
|
|
229
|
+
return bool(cfg.get("advisory_mode", False))
|
|
230
|
+
|
|
231
|
+
|
|
227
232
|
# ── Budget cache ──────────────────────────────────────────────────────────────
|
|
228
233
|
|
|
229
234
|
def _load_budget_cache():
|
|
@@ -452,6 +457,10 @@ def main() -> None:
|
|
|
452
457
|
|
|
453
458
|
_, action, rule_id, message = check_policy(tool_name, tool_input)
|
|
454
459
|
|
|
460
|
+
if _get_advisory_mode() and action in ("block", "warn", "approval"):
|
|
461
|
+
post_event(tool_name, tool_input, "audited", rule_id, f"[advisory] {message}", session_id, drain_via=_this_file)
|
|
462
|
+
sys.exit(0)
|
|
463
|
+
|
|
455
464
|
decision = {"block": "blocked", "warn": "warned", "approval": "blocked"}.get(action, "allowed")
|
|
456
465
|
if action == "warn" and session_id and rule_id and _already_warned_this_session(session_id, rule_id):
|
|
457
466
|
sys.exit(0)
|
|
@@ -3097,6 +3097,11 @@ def main():
|
|
|
3097
3097
|
sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
|
|
3098
3098
|
|
|
3099
3099
|
# conduct test-guard / test-security / test-security-verify
|
|
3100
|
+
verify_p = sub.add_parser("verify", help="Map guard events to OWASP Agentic Top 10 and verify chain integrity")
|
|
3101
|
+
verify_p.add_argument("--evidence", metavar="FILE", default=None, help="JSON evidence file (guard events). Omit to fetch from API.")
|
|
3102
|
+
verify_p.add_argument("--strict", action="store_true", help="Exit 1 if any blocked events found (CI mode)")
|
|
3103
|
+
verify_p.add_argument("--format", choices=["text", "json"], default="text", help="Output format (default: text)")
|
|
3104
|
+
verify_p.add_argument("--since", default="24h", help="Time window when fetching from API (e.g. 7d, 24h)")
|
|
3100
3105
|
sub.add_parser("test-guard", help="Fire a synthetic event per guard policy rule and show decisions")
|
|
3101
3106
|
sub.add_parser("test-security", help="Post a synthetic finding per security classifier pattern")
|
|
3102
3107
|
sub.add_parser("test-security-verify", help="Post test findings and verify full triage pipeline end-to-end")
|
|
@@ -3190,6 +3195,8 @@ def main():
|
|
|
3190
3195
|
cmd_skill(args)
|
|
3191
3196
|
elif args.command == "sync":
|
|
3192
3197
|
cmd_sync(args)
|
|
3198
|
+
elif args.command == "verify":
|
|
3199
|
+
_guard.cmd_verify(args)
|
|
3193
3200
|
elif args.command == "test-guard":
|
|
3194
3201
|
cmd_test_guard(args)
|
|
3195
3202
|
elif args.command == "test-security":
|
|
@@ -24,6 +24,7 @@ src/conduct_cli/hooks/pretooluse.py
|
|
|
24
24
|
src/conduct_cli/hooks/session_parser.py
|
|
25
25
|
src/conduct_cli/hooks/session_start.py
|
|
26
26
|
src/conduct_cli/hooks/stop.py
|
|
27
|
+
tests/test_advisory_and_hook.py
|
|
27
28
|
tests/test_bash_operator_signature.py
|
|
28
29
|
tests/test_command_word_matcher.py
|
|
29
30
|
tests/test_guard_policy.py
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI tests covering advisory mode (P1), pretooluse hook flow, and conduct verify (P3).
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
Advisory mode config:
|
|
6
|
+
- _get_advisory_mode returns False by default
|
|
7
|
+
- _get_advisory_mode returns True when advisory_mode set in config
|
|
8
|
+
- guard sync mirrors advisory_mode from policy into config
|
|
9
|
+
|
|
10
|
+
Advisory mode hook behavior:
|
|
11
|
+
- block rule + advisory ON → exits 0, posts "audited" decision
|
|
12
|
+
- block rule + advisory OFF → exits 2, posts "blocked" decision
|
|
13
|
+
- warn rule + advisory ON → exits 0, posts "audited" decision
|
|
14
|
+
- approval rule + advisory ON → exits 0, posts "audited" decision
|
|
15
|
+
- allow (no match) + advisory ON → exits 0, posts "allowed"
|
|
16
|
+
|
|
17
|
+
Fail-closed gate:
|
|
18
|
+
- fail_closed + no policy file → exits 2, posts "blocked" guard-unavailable
|
|
19
|
+
- fail_open + no policy file → passes through to policy check
|
|
20
|
+
|
|
21
|
+
conduct verify (P3):
|
|
22
|
+
- OWASP mapping covers all 10 categories + unknown fallback
|
|
23
|
+
- evidence file: pass with no blocked events → exit 0
|
|
24
|
+
- evidence file: blocked events + --strict → exit 1
|
|
25
|
+
- evidence file: blocked events without --strict → exit 0
|
|
26
|
+
- empty evidence → exit 0, no-op message
|
|
27
|
+
- bad file path → exit 2
|
|
28
|
+
- invalid JSON → exit 2
|
|
29
|
+
- --format json: status=fail when strict+blocked, status=pass otherwise
|
|
30
|
+
- chain hashes appear in output when present
|
|
31
|
+
|
|
32
|
+
Budget hard cap:
|
|
33
|
+
- hard_blocked=True → exits 2 regardless of advisory mode
|
|
34
|
+
|
|
35
|
+
Warn dedup:
|
|
36
|
+
- same session + same rule → exits 0 on second warn (already warned)
|
|
37
|
+
- different session → warns again
|
|
38
|
+
|
|
39
|
+
Policy eval error interaction with advisory (P4 × P1):
|
|
40
|
+
- policy eval throws + advisory OFF → exits 2 (fail-closed wins)
|
|
41
|
+
- policy eval throws + advisory ON → exits 2 (fail-closed wins; advisory only covers rule matches)
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import sys
|
|
47
|
+
from io import StringIO
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from unittest.mock import MagicMock, patch, call
|
|
50
|
+
|
|
51
|
+
import pytest
|
|
52
|
+
|
|
53
|
+
CONDUCT_CLI_SRC = Path(__file__).resolve().parent.parent / "src"
|
|
54
|
+
if str(CONDUCT_CLI_SRC) not in sys.path:
|
|
55
|
+
sys.path.insert(0, str(CONDUCT_CLI_SRC))
|
|
56
|
+
|
|
57
|
+
import conduct_cli.guard as guard_mod
|
|
58
|
+
from conduct_cli.guard import POLICY_PATH, CONFIG_PATH
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def _hook_input(tool="Bash", cmd="echo hi", session="sess-1") -> str:
|
|
64
|
+
return json.dumps({
|
|
65
|
+
"tool_name": tool,
|
|
66
|
+
"tool_input": {"command": cmd},
|
|
67
|
+
"session_id": session,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _block_policy(rule_id="no-echo", pattern="echo", action="block") -> dict:
|
|
72
|
+
return {
|
|
73
|
+
"version": "test",
|
|
74
|
+
"rules": [{"rule_id": rule_id, "match_tool": "*", "match_pattern": pattern, "action": action, "message": f"rule {rule_id}"}],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _write(path: Path, data: dict) -> None:
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
path.write_text(json.dumps(data))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── advisory config ───────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
class TestGetAdvisoryMode:
|
|
86
|
+
def test_default_false(self, tmp_path, monkeypatch):
|
|
87
|
+
cfg = tmp_path / "config.json"
|
|
88
|
+
_write(cfg, {"workspace_id": "ws1"})
|
|
89
|
+
monkeypatch.setattr(guard_mod, "CONFIG_PATH", cfg)
|
|
90
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
91
|
+
monkeypatch.setattr(pt, "load_config", lambda: {"workspace_id": "ws1"})
|
|
92
|
+
assert pt._get_advisory_mode() is False
|
|
93
|
+
|
|
94
|
+
def test_true_when_set(self, tmp_path, monkeypatch):
|
|
95
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
96
|
+
monkeypatch.setattr(pt, "load_config", lambda: {"advisory_mode": True})
|
|
97
|
+
assert pt._get_advisory_mode() is True
|
|
98
|
+
|
|
99
|
+
def test_false_when_explicitly_false(self, tmp_path, monkeypatch):
|
|
100
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
101
|
+
monkeypatch.setattr(pt, "load_config", lambda: {"advisory_mode": False})
|
|
102
|
+
assert pt._get_advisory_mode() is False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── advisory hook behavior ────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
class TestAdvisoryHookBehavior:
|
|
108
|
+
"""Patch check_policy to return a specific action, verify advisory bypass logic."""
|
|
109
|
+
|
|
110
|
+
def _run(self, monkeypatch, *, advisory: bool, action: str, rule_id: str = "r1", message: str = "msg"):
|
|
111
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
112
|
+
|
|
113
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
|
|
114
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: advisory)
|
|
115
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
|
|
116
|
+
monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
|
|
117
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
118
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
119
|
+
# Return the desired action regardless of policy file content
|
|
120
|
+
fake_rule = {"rule_id": rule_id, "action": action}
|
|
121
|
+
monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: (fake_rule, action, rule_id, message))
|
|
122
|
+
|
|
123
|
+
posted: list[dict] = []
|
|
124
|
+
monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: posted.append({"decision": d, "message": m}))
|
|
125
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
126
|
+
|
|
127
|
+
exit_code = None
|
|
128
|
+
try:
|
|
129
|
+
pt.main()
|
|
130
|
+
except SystemExit as e:
|
|
131
|
+
exit_code = e.code
|
|
132
|
+
return exit_code, posted
|
|
133
|
+
|
|
134
|
+
def test_block_advisory_on_exits_0(self, monkeypatch):
|
|
135
|
+
code, posted = self._run(monkeypatch, advisory=True, action="block")
|
|
136
|
+
assert code == 0
|
|
137
|
+
assert posted[0]["decision"] == "audited"
|
|
138
|
+
|
|
139
|
+
def test_block_advisory_off_exits_2(self, monkeypatch):
|
|
140
|
+
code, posted = self._run(monkeypatch, advisory=False, action="block")
|
|
141
|
+
assert code == 2
|
|
142
|
+
assert posted[0]["decision"] == "blocked"
|
|
143
|
+
|
|
144
|
+
def test_warn_advisory_on_exits_0(self, monkeypatch):
|
|
145
|
+
code, posted = self._run(monkeypatch, advisory=True, action="warn")
|
|
146
|
+
assert code == 0
|
|
147
|
+
assert posted[0]["decision"] == "audited"
|
|
148
|
+
|
|
149
|
+
def test_approval_advisory_on_exits_0(self, monkeypatch):
|
|
150
|
+
code, posted = self._run(monkeypatch, advisory=True, action="approval")
|
|
151
|
+
assert code == 0
|
|
152
|
+
assert posted[0]["decision"] == "audited"
|
|
153
|
+
|
|
154
|
+
def test_no_match_advisory_on_exits_0(self, monkeypatch):
|
|
155
|
+
"""No rule match — exits 0 even without advisory."""
|
|
156
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
157
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
|
|
158
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: True)
|
|
159
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
|
|
160
|
+
monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
|
|
161
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
162
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
163
|
+
monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: (None, "allow", None, None))
|
|
164
|
+
monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
|
|
165
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
166
|
+
|
|
167
|
+
with pytest.raises(SystemExit) as exc:
|
|
168
|
+
pt.main()
|
|
169
|
+
assert exc.value.code == 0
|
|
170
|
+
|
|
171
|
+
def test_advisory_message_prefixed(self, monkeypatch):
|
|
172
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
173
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
|
|
174
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: True)
|
|
175
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
|
|
176
|
+
monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
|
|
177
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
178
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
179
|
+
monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: ({}, "block", "r1", "bad thing"))
|
|
180
|
+
|
|
181
|
+
messages: list[str] = []
|
|
182
|
+
monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: messages.append(m))
|
|
183
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
pt.main()
|
|
187
|
+
except SystemExit:
|
|
188
|
+
pass
|
|
189
|
+
assert messages and messages[0].startswith("[advisory]")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── fail-closed gate ──────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
class TestFailClosedGate:
|
|
195
|
+
def test_fail_closed_no_policy_exits_2(self, monkeypatch, tmp_path):
|
|
196
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
197
|
+
|
|
198
|
+
missing = tmp_path / "nowhere.json"
|
|
199
|
+
monkeypatch.setattr(pt, "active_policy_path", lambda: missing)
|
|
200
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_closed")
|
|
201
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
|
|
202
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
203
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
204
|
+
|
|
205
|
+
posted: list[dict] = []
|
|
206
|
+
def _fake_post(tn, ti, decision, rule_id, msg, sid, **kw):
|
|
207
|
+
posted.append({"decision": decision, "rule_id": rule_id})
|
|
208
|
+
monkeypatch.setattr(pt, "post_event", _fake_post)
|
|
209
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
210
|
+
|
|
211
|
+
with pytest.raises(SystemExit) as exc:
|
|
212
|
+
pt.main()
|
|
213
|
+
assert exc.value.code == 2
|
|
214
|
+
assert posted[0]["decision"] == "blocked"
|
|
215
|
+
assert posted[0]["rule_id"] == "guard-unavailable"
|
|
216
|
+
|
|
217
|
+
def test_fail_open_no_policy_passes(self, monkeypatch, tmp_path):
|
|
218
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
219
|
+
|
|
220
|
+
missing = tmp_path / "nowhere.json"
|
|
221
|
+
monkeypatch.setattr(pt, "active_policy_path", lambda: missing)
|
|
222
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
|
|
223
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
|
|
224
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
|
|
225
|
+
monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
|
|
226
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
227
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
228
|
+
monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
|
|
229
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
230
|
+
|
|
231
|
+
with pytest.raises(SystemExit) as exc:
|
|
232
|
+
pt.main()
|
|
233
|
+
assert exc.value.code == 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ── budget hard cap ───────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
class TestBudgetHardCap:
|
|
239
|
+
def test_hard_cap_exits_2(self, monkeypatch, tmp_path):
|
|
240
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
241
|
+
|
|
242
|
+
policy_path = tmp_path / "policy.json"
|
|
243
|
+
_write(policy_path, {"version": "test", "rules": []})
|
|
244
|
+
monkeypatch.setattr(pt, "active_policy_path", lambda: policy_path)
|
|
245
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
|
|
246
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
|
|
247
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (True, "Monthly cap reached"))
|
|
248
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
249
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
250
|
+
|
|
251
|
+
posted: list[dict] = []
|
|
252
|
+
monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: posted.append({"decision": d, "rule_id": r}))
|
|
253
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
254
|
+
|
|
255
|
+
with pytest.raises(SystemExit) as exc:
|
|
256
|
+
pt.main()
|
|
257
|
+
assert exc.value.code == 2
|
|
258
|
+
assert posted[0]["decision"] == "blocked"
|
|
259
|
+
assert posted[0]["rule_id"] == "budget-hard-cap"
|
|
260
|
+
|
|
261
|
+
def test_hard_cap_wins_over_advisory(self, monkeypatch, tmp_path):
|
|
262
|
+
"""Budget hard cap blocks even when advisory mode is on."""
|
|
263
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
264
|
+
|
|
265
|
+
policy_path = tmp_path / "policy.json"
|
|
266
|
+
_write(policy_path, {"version": "test", "rules": []})
|
|
267
|
+
monkeypatch.setattr(pt, "active_policy_path", lambda: policy_path)
|
|
268
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
|
|
269
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: True) # advisory ON
|
|
270
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (True, "Cap reached"))
|
|
271
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
272
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
273
|
+
monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
|
|
274
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
|
|
275
|
+
|
|
276
|
+
with pytest.raises(SystemExit) as exc:
|
|
277
|
+
pt.main()
|
|
278
|
+
assert exc.value.code == 2
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ── warn dedup ────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
class TestWarnDedup:
|
|
284
|
+
def _run_warn(self, monkeypatch, session_id: str):
|
|
285
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
286
|
+
|
|
287
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
|
|
288
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
|
|
289
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
|
|
290
|
+
monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
|
|
291
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
292
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
293
|
+
monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: ({}, "warn", "rule-w1", "watch out"))
|
|
294
|
+
monkeypatch.setattr(pt, "_already_warned_this_session", lambda s, r: False)
|
|
295
|
+
monkeypatch.setattr(pt, "_record_session_warn", lambda s, r: None)
|
|
296
|
+
|
|
297
|
+
posted: list[str] = []
|
|
298
|
+
monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: posted.append(d))
|
|
299
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input(session=session_id)))
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
pt.main()
|
|
303
|
+
except SystemExit:
|
|
304
|
+
pass
|
|
305
|
+
return posted
|
|
306
|
+
|
|
307
|
+
def test_first_warn_posts(self, monkeypatch):
|
|
308
|
+
posted = self._run_warn(monkeypatch, "sess-abc")
|
|
309
|
+
assert "warned" in posted
|
|
310
|
+
|
|
311
|
+
def test_second_warn_same_session_skips(self, monkeypatch, tmp_path):
|
|
312
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
313
|
+
warns: dict[str, set] = {}
|
|
314
|
+
|
|
315
|
+
def _fake_already(session, rule):
|
|
316
|
+
return rule in warns.get(session, set())
|
|
317
|
+
|
|
318
|
+
def _fake_record(session, rule):
|
|
319
|
+
warns.setdefault(session, set()).add(rule)
|
|
320
|
+
|
|
321
|
+
monkeypatch.setattr(pt, "_already_warned_this_session", _fake_already)
|
|
322
|
+
monkeypatch.setattr(pt, "_record_session_warn", _fake_record)
|
|
323
|
+
|
|
324
|
+
# First call records the warn
|
|
325
|
+
_fake_record("sess-xyz", "no-echo")
|
|
326
|
+
|
|
327
|
+
monkeypatch.setattr(pt, "check_policy", lambda tn, ti, **kw: ({}, "warn", "no-echo", "watch out"))
|
|
328
|
+
monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
|
|
329
|
+
monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
|
|
330
|
+
monkeypatch.setattr(pt, "_load_budget_cache", lambda: (False, None))
|
|
331
|
+
monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
|
|
332
|
+
monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
|
|
333
|
+
monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
|
|
334
|
+
|
|
335
|
+
posted: list[str] = []
|
|
336
|
+
monkeypatch.setattr(pt, "post_event", lambda tn, ti, d, r, m, s, **kw: posted.append(d))
|
|
337
|
+
monkeypatch.setattr(sys, "stdin", StringIO(_hook_input(session="sess-xyz")))
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
pt.main()
|
|
341
|
+
except SystemExit:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
assert posted == [], "second warn same session should be skipped"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ── advisory_mode mirrored on sync ────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
class TestSyncMirrorsAdvisoryMode:
|
|
350
|
+
def test_advisory_true_written_to_config(self, tmp_path, monkeypatch):
|
|
351
|
+
cfg_path = tmp_path / "config.json"
|
|
352
|
+
pol_path = tmp_path / "policy.json"
|
|
353
|
+
_write(cfg_path, {"workspace_id": "ws1"})
|
|
354
|
+
|
|
355
|
+
monkeypatch.setattr(guard_mod, "CONFIG_PATH", cfg_path)
|
|
356
|
+
monkeypatch.setattr(guard_mod, "POLICY_PATH", pol_path)
|
|
357
|
+
|
|
358
|
+
policy = {"version": "v1", "rules": [], "fail_mode": "fail_open", "advisory_mode": True}
|
|
359
|
+
monkeypatch.setattr(guard_mod, "_load_guard_config", lambda: {"workspace_id": "ws1"})
|
|
360
|
+
|
|
361
|
+
saved: list[dict] = []
|
|
362
|
+
monkeypatch.setattr(guard_mod, "_save_guard_config", lambda cfg: saved.append(dict(cfg)))
|
|
363
|
+
monkeypatch.setattr(guard_mod, "_save_policy", lambda p: None)
|
|
364
|
+
|
|
365
|
+
# Simulate the sync block
|
|
366
|
+
cfg = guard_mod._load_guard_config()
|
|
367
|
+
cfg["fail_mode"] = policy.get("fail_mode", "fail_open")
|
|
368
|
+
cfg["advisory_mode"] = policy.get("advisory_mode", False)
|
|
369
|
+
guard_mod._save_guard_config(cfg)
|
|
370
|
+
|
|
371
|
+
assert saved[0]["advisory_mode"] is True
|
|
372
|
+
|
|
373
|
+
def test_advisory_false_default(self, tmp_path, monkeypatch):
|
|
374
|
+
monkeypatch.setattr(guard_mod, "_load_guard_config", lambda: {"workspace_id": "ws1"})
|
|
375
|
+
saved: list[dict] = []
|
|
376
|
+
monkeypatch.setattr(guard_mod, "_save_guard_config", lambda cfg: saved.append(dict(cfg)))
|
|
377
|
+
monkeypatch.setattr(guard_mod, "_save_policy", lambda p: None)
|
|
378
|
+
|
|
379
|
+
policy = {"version": "v1", "rules": [], "fail_mode": "fail_open"}
|
|
380
|
+
cfg = guard_mod._load_guard_config()
|
|
381
|
+
cfg["fail_mode"] = policy.get("fail_mode", "fail_open")
|
|
382
|
+
cfg["advisory_mode"] = policy.get("advisory_mode", False)
|
|
383
|
+
guard_mod._save_guard_config(cfg)
|
|
384
|
+
|
|
385
|
+
assert saved[0]["advisory_mode"] is False
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ── conduct verify (P3) ──────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
class TestOWASPMapping:
|
|
391
|
+
"""_map_owasp covers all 10 categories and falls back to A??."""
|
|
392
|
+
|
|
393
|
+
def _map(self, rule_id: str) -> str:
|
|
394
|
+
from conduct_cli.guard import _map_owasp
|
|
395
|
+
return _map_owasp(rule_id)[0]
|
|
396
|
+
|
|
397
|
+
def test_a01_prompt_injection(self):
|
|
398
|
+
assert self._map("prompt_inject_tool") == "A01"
|
|
399
|
+
|
|
400
|
+
def test_a02_sensitive_data(self):
|
|
401
|
+
assert self._map("sensitive_data_read") == "A02"
|
|
402
|
+
|
|
403
|
+
def test_a03_supply_chain(self):
|
|
404
|
+
assert self._map("supply_chain_pkg") == "A03"
|
|
405
|
+
|
|
406
|
+
def test_a04_excessive_agency(self):
|
|
407
|
+
assert self._map("excessive_agency_shell") == "A04"
|
|
408
|
+
|
|
409
|
+
def test_a07_monitoring(self):
|
|
410
|
+
assert self._map("policy_eval_error") == "A07"
|
|
411
|
+
|
|
412
|
+
def test_a07_sig_invalid(self):
|
|
413
|
+
assert self._map("policy_signature_invalid") == "A07"
|
|
414
|
+
|
|
415
|
+
def test_a09_privilege(self):
|
|
416
|
+
assert self._map("privilege_escalat_detected") == "A09"
|
|
417
|
+
|
|
418
|
+
def test_a10_budget(self):
|
|
419
|
+
assert self._map("budget_hard_cap") == "A10"
|
|
420
|
+
|
|
421
|
+
def test_unknown_falls_back(self):
|
|
422
|
+
assert self._map("some_random_rule") == "A??"
|
|
423
|
+
|
|
424
|
+
def test_empty_rule_id(self):
|
|
425
|
+
assert self._map("") == "A??"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
_EVIDENCE_BLOCKED = [
|
|
429
|
+
{"timestamp": "2026-07-06T10:00:00", "ai_tool": "Claude",
|
|
430
|
+
"tool_call": "Bash", "decision": "blocked",
|
|
431
|
+
"rule_id": "excessive_agency_shell",
|
|
432
|
+
"entry_hash": "abc123def456", "policy_hash": "pol789"},
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
_EVIDENCE_WARN_ONLY = [
|
|
436
|
+
{"timestamp": "2026-07-06T10:01:00", "ai_tool": "Claude",
|
|
437
|
+
"tool_call": "Read", "decision": "warned",
|
|
438
|
+
"rule_id": "sensitive_data_read"},
|
|
439
|
+
]
|
|
440
|
+
|
|
441
|
+
_EVIDENCE_CLEAN = [
|
|
442
|
+
{"timestamp": "2026-07-06T10:02:00", "ai_tool": "Claude",
|
|
443
|
+
"tool_call": "Bash", "decision": "allowed", "rule_id": None},
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class TestVerifyCommand:
|
|
448
|
+
def _run(self, args_list: list[str], capsys) -> int:
|
|
449
|
+
import argparse
|
|
450
|
+
from conduct_cli.guard import cmd_verify
|
|
451
|
+
|
|
452
|
+
parser = argparse.ArgumentParser()
|
|
453
|
+
parser.add_argument("--evidence", default=None)
|
|
454
|
+
parser.add_argument("--strict", action="store_true")
|
|
455
|
+
parser.add_argument("--format", default="text")
|
|
456
|
+
parser.add_argument("--since", default="24h")
|
|
457
|
+
args = parser.parse_args(args_list)
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
cmd_verify(args)
|
|
461
|
+
return 0
|
|
462
|
+
except SystemExit as exc:
|
|
463
|
+
return int(exc.code) if exc.code is not None else 0
|
|
464
|
+
|
|
465
|
+
def test_clean_evidence_exits_0(self, tmp_path, capsys):
|
|
466
|
+
f = tmp_path / "ev.json"
|
|
467
|
+
f.write_text(__import__("json").dumps(_EVIDENCE_CLEAN))
|
|
468
|
+
code = self._run(["--evidence", str(f)], capsys)
|
|
469
|
+
assert code == 0
|
|
470
|
+
|
|
471
|
+
def test_blocked_without_strict_exits_0(self, tmp_path, capsys):
|
|
472
|
+
f = tmp_path / "ev.json"
|
|
473
|
+
f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED))
|
|
474
|
+
code = self._run(["--evidence", str(f)], capsys)
|
|
475
|
+
assert code == 0
|
|
476
|
+
|
|
477
|
+
def test_blocked_with_strict_exits_1(self, tmp_path, capsys):
|
|
478
|
+
f = tmp_path / "ev.json"
|
|
479
|
+
f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED))
|
|
480
|
+
code = self._run(["--evidence", str(f), "--strict"], capsys)
|
|
481
|
+
assert code == 1
|
|
482
|
+
|
|
483
|
+
def test_warn_only_with_strict_exits_0(self, tmp_path, capsys):
|
|
484
|
+
f = tmp_path / "ev.json"
|
|
485
|
+
f.write_text(__import__("json").dumps(_EVIDENCE_WARN_ONLY))
|
|
486
|
+
code = self._run(["--evidence", str(f), "--strict"], capsys)
|
|
487
|
+
assert code == 0
|
|
488
|
+
|
|
489
|
+
def test_missing_file_exits_2(self, tmp_path, capsys):
|
|
490
|
+
code = self._run(["--evidence", str(tmp_path / "missing.json")], capsys)
|
|
491
|
+
assert code == 2
|
|
492
|
+
|
|
493
|
+
def test_invalid_json_exits_2(self, tmp_path, capsys):
|
|
494
|
+
f = tmp_path / "bad.json"
|
|
495
|
+
f.write_text("not valid {{{")
|
|
496
|
+
code = self._run(["--evidence", str(f)], capsys)
|
|
497
|
+
assert code == 2
|
|
498
|
+
|
|
499
|
+
def test_empty_array_no_error(self, tmp_path, capsys):
|
|
500
|
+
f = tmp_path / "empty.json"
|
|
501
|
+
f.write_text("[]")
|
|
502
|
+
code = self._run(["--evidence", str(f)], capsys)
|
|
503
|
+
assert code == 0
|
|
504
|
+
out = capsys.readouterr().out
|
|
505
|
+
assert "nothing to verify" in out.lower() or "no guard events" in out.lower()
|
|
506
|
+
|
|
507
|
+
def test_json_format_status_fail_when_strict_and_blocked(self, tmp_path, capsys):
|
|
508
|
+
import json
|
|
509
|
+
f = tmp_path / "ev.json"
|
|
510
|
+
f.write_text(json.dumps(_EVIDENCE_BLOCKED))
|
|
511
|
+
self._run(["--evidence", str(f), "--strict", "--format", "json"], capsys)
|
|
512
|
+
out = capsys.readouterr().out
|
|
513
|
+
data = json.loads(out)
|
|
514
|
+
assert data["status"] == "fail"
|
|
515
|
+
assert data["summary"]["blocked"] == 1
|
|
516
|
+
|
|
517
|
+
def test_json_format_status_pass_without_strict(self, tmp_path, capsys):
|
|
518
|
+
import json
|
|
519
|
+
f = tmp_path / "ev.json"
|
|
520
|
+
f.write_text(json.dumps(_EVIDENCE_BLOCKED))
|
|
521
|
+
self._run(["--evidence", str(f), "--format", "json"], capsys)
|
|
522
|
+
out = capsys.readouterr().out
|
|
523
|
+
data = json.loads(out)
|
|
524
|
+
assert data["status"] == "pass"
|
|
525
|
+
|
|
526
|
+
def test_chain_hash_appears_in_text_output(self, tmp_path, capsys):
|
|
527
|
+
f = tmp_path / "ev.json"
|
|
528
|
+
f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED))
|
|
529
|
+
self._run(["--evidence", str(f)], capsys)
|
|
530
|
+
out = capsys.readouterr().out
|
|
531
|
+
assert "chain:abc123def456" in out
|
|
532
|
+
|
|
533
|
+
def test_owasp_distribution_shown(self, tmp_path, capsys):
|
|
534
|
+
f = tmp_path / "ev.json"
|
|
535
|
+
f.write_text(__import__("json").dumps(_EVIDENCE_BLOCKED + _EVIDENCE_WARN_ONLY))
|
|
536
|
+
self._run(["--evidence", str(f)], capsys)
|
|
537
|
+
out = capsys.readouterr().out
|
|
538
|
+
assert "A04" in out
|
|
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
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# ── invalid stdin ─────────────────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
class TestInvalidStdin:
|
|
553
|
+
def test_malformed_json_exits_0(self, monkeypatch):
|
|
554
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
555
|
+
monkeypatch.setattr(sys, "stdin", StringIO("not json {{{"))
|
|
556
|
+
with pytest.raises(SystemExit) as exc:
|
|
557
|
+
pt.main()
|
|
558
|
+
assert exc.value.code == 0
|
|
559
|
+
|
|
560
|
+
def test_empty_stdin_exits_0(self, monkeypatch):
|
|
561
|
+
import conduct_cli.hooks.pretooluse as pt
|
|
562
|
+
monkeypatch.setattr(sys, "stdin", StringIO(""))
|
|
563
|
+
with pytest.raises(SystemExit) as exc:
|
|
564
|
+
pt.main()
|
|
565
|
+
assert exc.value.code == 0
|
|
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
|