conduct-cli 0.7.33__tar.gz → 0.7.35__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.33 → conduct_cli-0.7.35}/PKG-INFO +1 -1
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/pyproject.toml +1 -1
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/api.py +18 -5
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/guard.py +83 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/guardmcp.py +12 -4
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/base.py +6 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/posttooluse.py +55 -12
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/main.py +18 -6
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/mcp_server.py +30 -3
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/SOURCES.txt +4 -1
- conduct_cli-0.7.35/tests/test_api_client.py +350 -0
- conduct_cli-0.7.35/tests/test_blast_radius.py +81 -0
- conduct_cli-0.7.35/tests/test_cmd_run.py +291 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_guard_policy.py +55 -2
- conduct_cli-0.7.35/tests/test_token_lifecycle.py +232 -0
- conduct_cli-0.7.33/tests/test_blast_radius.py +0 -44
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/README.md +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/setup.cfg +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/setup.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/pretooluse.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.7.33 → conduct_cli-0.7.35}/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.35"
|
|
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" }
|
|
@@ -6,7 +6,20 @@ import urllib.error
|
|
|
6
6
|
|
|
7
7
|
RED = "\033[31m"
|
|
8
8
|
RESET = "\033[0m"
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
_HINTS = {
|
|
11
|
+
401: "run `conduct login` to re-authenticate",
|
|
12
|
+
403: "check your permissions or run `conduct login`",
|
|
13
|
+
404: "not found — check the agent name or run ID",
|
|
14
|
+
429: "rate limited — wait a moment and try again",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _friendly(code: int, detail: str) -> str:
|
|
19
|
+
hint = _HINTS.get(code)
|
|
20
|
+
if hint and hint.lower() not in detail.lower():
|
|
21
|
+
return f"{detail} — {hint}"
|
|
22
|
+
return detail
|
|
10
23
|
|
|
11
24
|
|
|
12
25
|
def headers(workspace_id: str, token=None, content_type="application/json", api_key=None) -> dict:
|
|
@@ -31,10 +44,10 @@ def req(method: str, url: str, hdrs: dict, body=None, timeout: int = 30) -> dict
|
|
|
31
44
|
detail = json.loads(raw).get("detail", raw)
|
|
32
45
|
except Exception:
|
|
33
46
|
detail = raw
|
|
34
|
-
print(f"{RED}
|
|
47
|
+
print(f"{RED}{_friendly(e.code, detail)}{RESET}")
|
|
35
48
|
sys.exit(1)
|
|
36
49
|
except (socket.timeout, TimeoutError):
|
|
37
|
-
print(f"{RED}Request timed out
|
|
50
|
+
print(f"{RED}Request timed out — check your network connection{RESET}")
|
|
38
51
|
sys.exit(1)
|
|
39
52
|
|
|
40
53
|
|
|
@@ -50,10 +63,10 @@ def req_text(method: str, url: str, hdrs: dict, body_text: str, timeout: int = 3
|
|
|
50
63
|
detail = json.loads(raw).get("detail", raw)
|
|
51
64
|
except Exception:
|
|
52
65
|
detail = raw
|
|
53
|
-
print(f"{RED}
|
|
66
|
+
print(f"{RED}{_friendly(e.code, detail)}{RESET}")
|
|
54
67
|
sys.exit(1)
|
|
55
68
|
except (socket.timeout, TimeoutError):
|
|
56
|
-
print(f"{RED}Request timed out
|
|
69
|
+
print(f"{RED}Request timed out — check your network connection{RESET}")
|
|
57
70
|
sys.exit(1)
|
|
58
71
|
|
|
59
72
|
|
|
@@ -1878,6 +1878,9 @@ def cmd_guard_status(args):
|
|
|
1878
1878
|
proxy_line = f"{RED}not configured{RESET} — run: {BOLD}conduct guard sync{RESET}"
|
|
1879
1879
|
|
|
1880
1880
|
print(f"\n{BOLD}Guard status{RESET} — {user_email}")
|
|
1881
|
+
_active_cfg = _load_guard_config()
|
|
1882
|
+
if _active_cfg.get("current_goal_id"):
|
|
1883
|
+
print(f" Active goal: {CYAN}{_active_cfg.get('current_goal_name', 'unnamed')}{RESET} ({_active_cfg['current_goal_id'][:8]}...)")
|
|
1881
1884
|
print(f"{rule_count} polic{'y' if rule_count == 1 else 'ies'} active")
|
|
1882
1885
|
print()
|
|
1883
1886
|
print(f"Proxy coverage: {proxy_line}")
|
|
@@ -1931,6 +1934,32 @@ def cmd_guard_savings(args):
|
|
|
1931
1934
|
print()
|
|
1932
1935
|
|
|
1933
1936
|
|
|
1937
|
+
def cmd_guard_session(args):
|
|
1938
|
+
sub = getattr(args, "session_cmd", None)
|
|
1939
|
+
if sub == "start":
|
|
1940
|
+
import uuid as _uuid
|
|
1941
|
+
goal = getattr(args, "goal", "") or ""
|
|
1942
|
+
goal_id = str(_uuid.uuid4())
|
|
1943
|
+
cfg = _load_guard_config()
|
|
1944
|
+
cfg["current_goal_id"] = goal_id
|
|
1945
|
+
cfg["current_goal_name"] = goal
|
|
1946
|
+
_save_guard_config(cfg)
|
|
1947
|
+
print(f" {GREEN}Session started{RESET}")
|
|
1948
|
+
if goal:
|
|
1949
|
+
print(f" Goal: {CYAN}{goal}{RESET}")
|
|
1950
|
+
print(f" ID: {goal_id}")
|
|
1951
|
+
print(f"\nAll Guard events in this session will be tagged with this goal.")
|
|
1952
|
+
print(f"Run {BOLD}conduct guard session stop{RESET} when done.")
|
|
1953
|
+
elif sub == "stop":
|
|
1954
|
+
cfg = _load_guard_config()
|
|
1955
|
+
name = cfg.pop("current_goal_name", "")
|
|
1956
|
+
cfg.pop("current_goal_id", None)
|
|
1957
|
+
_save_guard_config(cfg)
|
|
1958
|
+
print(f" {GREEN}Session stopped{RESET}" + (f" — {name}" if name else ""))
|
|
1959
|
+
else:
|
|
1960
|
+
print("Usage: conduct guard session start [--goal GOAL] | stop")
|
|
1961
|
+
|
|
1962
|
+
|
|
1934
1963
|
def cmd_guard_audit(args):
|
|
1935
1964
|
cfg = _require_guard_config()
|
|
1936
1965
|
workspace_id = cfg.get("workspace_id")
|
|
@@ -2085,6 +2114,14 @@ def register_guard_parser(sub):
|
|
|
2085
2114
|
lint_p.add_argument("--file", default=None, metavar="FILE",
|
|
2086
2115
|
help="Path to policy YAML/JSON (default: ~/.conduct/policy.json)")
|
|
2087
2116
|
|
|
2117
|
+
# conduct guard session start|stop
|
|
2118
|
+
p_session = guard_sub.add_parser("session", help="Start or stop a named goal session")
|
|
2119
|
+
session_sub = p_session.add_subparsers(dest="session_cmd")
|
|
2120
|
+
p_sess_start = session_sub.add_parser("start", help="Start a goal session — tags all Guard events with a goal ID")
|
|
2121
|
+
p_sess_start.add_argument("--goal", default="", help="Human-readable goal label")
|
|
2122
|
+
session_sub.add_parser("stop", help="End the current goal session")
|
|
2123
|
+
p_session.set_defaults(func=cmd_guard_session)
|
|
2124
|
+
|
|
2088
2125
|
return guard_p, guard_sub
|
|
2089
2126
|
|
|
2090
2127
|
|
|
@@ -2683,6 +2720,9 @@ def _grade_below(actual: str, minimum: str) -> bool:
|
|
|
2683
2720
|
return _GRADE_ORDER.index(actual) > _GRADE_ORDER.index(minimum.upper())
|
|
2684
2721
|
|
|
2685
2722
|
|
|
2723
|
+
_VERDICT_COLOR = {"held": GREEN, "bypassed": RED, "not_tested": YELLOW}
|
|
2724
|
+
|
|
2725
|
+
|
|
2686
2726
|
def cmd_verify(args) -> None:
|
|
2687
2727
|
"""conduct verify — governance grade + OWASP Agentic Top 10 coverage."""
|
|
2688
2728
|
import json as _json
|
|
@@ -2692,6 +2732,7 @@ def cmd_verify(args) -> None:
|
|
|
2692
2732
|
min_grade = getattr(args, "min_grade", None)
|
|
2693
2733
|
strict = getattr(args, "strict", False)
|
|
2694
2734
|
fmt = getattr(args, "format", "text")
|
|
2735
|
+
run_battery = getattr(args, "run", False)
|
|
2695
2736
|
|
|
2696
2737
|
cfg = _require_guard_config()
|
|
2697
2738
|
workspace_id = cfg.get("workspace_id")
|
|
@@ -2712,6 +2753,46 @@ def cmd_verify(args) -> None:
|
|
|
2712
2753
|
controls = ev.get("controls", [])
|
|
2713
2754
|
generated_at = ev.get("generated_at", "")
|
|
2714
2755
|
|
|
2756
|
+
# ── --run (live adversarial battery) ─────────────────────────────────────
|
|
2757
|
+
run_result = None
|
|
2758
|
+
if run_battery:
|
|
2759
|
+
print(f"\n{BOLD}Running adversarial test battery…{RESET}")
|
|
2760
|
+
run_result = _req(
|
|
2761
|
+
"POST",
|
|
2762
|
+
f"{base_url}/guard/verify/run?workspace_id={workspace_id}",
|
|
2763
|
+
api_key=api_key,
|
|
2764
|
+
)
|
|
2765
|
+
if fmt == "json":
|
|
2766
|
+
print(_json.dumps(run_result, indent=2))
|
|
2767
|
+
return
|
|
2768
|
+
rs = run_result.get("score", 0)
|
|
2769
|
+
rg = run_result.get("grade", "F")
|
|
2770
|
+
passed = run_result.get("passed_tests", 0)
|
|
2771
|
+
total = run_result.get("total_tests", 0)
|
|
2772
|
+
results = run_result.get("results", [])
|
|
2773
|
+
rg_col = _GRADE_COLORS.get(rg, GRAY)
|
|
2774
|
+
print()
|
|
2775
|
+
print(f"{BOLD}conduct verify --run — Live Adversarial Score{RESET}")
|
|
2776
|
+
print("─" * 60)
|
|
2777
|
+
print(f"\n Live Grade: {rg_col}{BOLD}{rg}{RESET} ({rs}/100) · {passed}/{total} held\n")
|
|
2778
|
+
print(f" {BOLD}{'ASI':<8} {'Verdict':<12} {'Expected':<10} {'Actual':<10} Test{RESET}")
|
|
2779
|
+
print(" " + "─" * 56)
|
|
2780
|
+
for r in results:
|
|
2781
|
+
vc = _VERDICT_COLOR.get(r.get("verdict", ""), GRAY)
|
|
2782
|
+
print(
|
|
2783
|
+
f" {r.get('asi',''):<8} "
|
|
2784
|
+
f"{vc}{r.get('verdict',''):<12}{RESET} "
|
|
2785
|
+
f"{r.get('expected',''):<10} "
|
|
2786
|
+
f"{r.get('actual',''):<10} "
|
|
2787
|
+
f"{r.get('name','')}"
|
|
2788
|
+
)
|
|
2789
|
+
print()
|
|
2790
|
+
if min_grade and _grade_below(rg, min_grade):
|
|
2791
|
+
print(f" {RED}✗ Live grade {rg} is below minimum {min_grade.upper()}.{RESET}\n")
|
|
2792
|
+
sys.exit(1)
|
|
2793
|
+
print(f" {GREEN}✓ PASS{RESET}\n")
|
|
2794
|
+
return
|
|
2795
|
+
|
|
2715
2796
|
# ── --badge ───────────────────────────────────────────────────────────────
|
|
2716
2797
|
if badge:
|
|
2717
2798
|
color = {"A": "brightgreen", "B": "green", "C": "yellow", "D": "orange", "F": "red"}.get(grade, "lightgrey")
|
|
@@ -2797,6 +2878,8 @@ def dispatch_guard(args, guard_p):
|
|
|
2797
2878
|
cmd_guard_booster_status(args)
|
|
2798
2879
|
elif guard_command == "debug-hook":
|
|
2799
2880
|
cmd_guard_debug_hook(args)
|
|
2881
|
+
elif guard_command == "session":
|
|
2882
|
+
cmd_guard_session(args)
|
|
2800
2883
|
else:
|
|
2801
2884
|
guard_p.print_help()
|
|
2802
2885
|
sys.exit(1)
|
|
@@ -45,6 +45,7 @@ _TOOLS = [
|
|
|
45
45
|
"number of active rules, and the policy version timestamp."
|
|
46
46
|
),
|
|
47
47
|
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
|
48
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": False},
|
|
48
49
|
},
|
|
49
50
|
{
|
|
50
51
|
"name": "guard_check",
|
|
@@ -75,6 +76,7 @@ _TOOLS = [
|
|
|
75
76
|
},
|
|
76
77
|
"required": ["tool_name"],
|
|
77
78
|
},
|
|
79
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": False},
|
|
78
80
|
},
|
|
79
81
|
{
|
|
80
82
|
"name": "guard_sync",
|
|
@@ -83,6 +85,7 @@ _TOOLS = [
|
|
|
83
85
|
"Run this after your security team updates policies."
|
|
84
86
|
),
|
|
85
87
|
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
|
88
|
+
"annotations": {"idempotentHint": True, "openWorldHint": True},
|
|
86
89
|
},
|
|
87
90
|
{
|
|
88
91
|
"name": "guard_enable",
|
|
@@ -93,6 +96,7 @@ _TOOLS = [
|
|
|
93
96
|
"should paste into their Claude.ai Project settings to make guard_check fire automatically."
|
|
94
97
|
),
|
|
95
98
|
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
|
99
|
+
"annotations": {"idempotentHint": True, "openWorldHint": False},
|
|
96
100
|
},
|
|
97
101
|
{
|
|
98
102
|
"name": "guard_activity",
|
|
@@ -116,6 +120,7 @@ _TOOLS = [
|
|
|
116
120
|
},
|
|
117
121
|
"required": ["summary"],
|
|
118
122
|
},
|
|
123
|
+
"annotations": {"openWorldHint": False},
|
|
119
124
|
},
|
|
120
125
|
]
|
|
121
126
|
|
|
@@ -468,10 +473,13 @@ def main() -> None:
|
|
|
468
473
|
_ok(msg_id, {"tools": _TOOLS})
|
|
469
474
|
|
|
470
475
|
elif method == "tools/call":
|
|
471
|
-
tool_name
|
|
472
|
-
arguments
|
|
473
|
-
|
|
474
|
-
|
|
476
|
+
tool_name = params.get("name", "")
|
|
477
|
+
arguments = params.get("arguments") or {}
|
|
478
|
+
try:
|
|
479
|
+
text = _dispatch_tool(tool_name, arguments, workspace_id, token, ai_tool)
|
|
480
|
+
_ok(msg_id, {"content": [{"type": "text", "text": text}]})
|
|
481
|
+
except Exception as exc:
|
|
482
|
+
_ok(msg_id, {"content": [{"type": "text", "text": str(exc)}], "isError": True})
|
|
475
483
|
|
|
476
484
|
elif method == "ping":
|
|
477
485
|
_ok(msg_id, {})
|
|
@@ -309,6 +309,10 @@ def post_event(
|
|
|
309
309
|
return
|
|
310
310
|
import platform as _platform
|
|
311
311
|
_os_info = f"{_platform.system()} {_platform.release()} {_platform.machine()}".strip()
|
|
312
|
+
# Read goal session fields directly from config — avoids circular import with guard.py
|
|
313
|
+
import json as _json_gcfg, pathlib as _pl_gcfg
|
|
314
|
+
_gcfg_path = _pl_gcfg.Path.home() / ".conduct" / "config.json"
|
|
315
|
+
_gcfg = _json_gcfg.loads(_gcfg_path.read_text()) if _gcfg_path.exists() else {}
|
|
312
316
|
payload = json.dumps({
|
|
313
317
|
"workspace_id": workspace_id,
|
|
314
318
|
"clerk_user_id": cfg.get("user_email"),
|
|
@@ -323,6 +327,8 @@ def post_event(
|
|
|
323
327
|
"os_info": _os_info,
|
|
324
328
|
"hostname": _platform.node(),
|
|
325
329
|
"blast_radius": blast_radius,
|
|
330
|
+
"goal_id": _gcfg.get("current_goal_id") or None,
|
|
331
|
+
"goal_name": _gcfg.get("current_goal_name") or None,
|
|
326
332
|
})
|
|
327
333
|
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
328
334
|
journal_append(payload, api_url)
|
|
@@ -247,6 +247,27 @@ def _scan_codex_tokens(transcript_path: str):
|
|
|
247
247
|
return 0, 0
|
|
248
248
|
|
|
249
249
|
|
|
250
|
+
def _git_context() -> "tuple[str | None, str | None]":
|
|
251
|
+
"""Return (repo_url, branch) from git — best-effort, never raises."""
|
|
252
|
+
import subprocess as _sp
|
|
253
|
+
repo = branch = None
|
|
254
|
+
try:
|
|
255
|
+
repo = _sp.check_output(
|
|
256
|
+
["git", "remote", "get-url", "origin"],
|
|
257
|
+
timeout=2, stderr=_sp.DEVNULL, text=True,
|
|
258
|
+
).strip() or None
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
try:
|
|
262
|
+
branch = _sp.check_output(
|
|
263
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
264
|
+
timeout=2, stderr=_sp.DEVNULL, text=True,
|
|
265
|
+
).strip() or None
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
return repo, branch
|
|
269
|
+
|
|
270
|
+
|
|
250
271
|
def _compute_blast_radius(tool_name: str, tool_input: dict, tool_response: str) -> "dict | None":
|
|
251
272
|
"""Classify blast radius after tool execution. Returns None for read-only tools."""
|
|
252
273
|
import re as _re
|
|
@@ -258,6 +279,7 @@ def _compute_blast_radius(tool_name: str, tool_input: dict, tool_response: str)
|
|
|
258
279
|
files = 0
|
|
259
280
|
symbols: "int | None" = None
|
|
260
281
|
tier = "local"
|
|
282
|
+
file_paths: "list[str]" = []
|
|
261
283
|
|
|
262
284
|
if tool_name in ("bash", "terminal"):
|
|
263
285
|
cmd = (tool_input.get("command") or "").lower()
|
|
@@ -269,43 +291,55 @@ def _compute_blast_radius(tool_name: str, tool_input: dict, tool_response: str)
|
|
|
269
291
|
tier = "network"
|
|
270
292
|
# count path-like lines in output as proxy for files touched
|
|
271
293
|
lines = (tool_response or "").splitlines()
|
|
272
|
-
|
|
294
|
+
path_lines = [l.strip() for l in lines if "/" in l or ("." in l and len(l) < 200)]
|
|
295
|
+
files = min(len(path_lines), 50)
|
|
296
|
+
file_paths = path_lines[:20]
|
|
273
297
|
|
|
274
298
|
elif tool_name in ("write",):
|
|
275
299
|
files = 1
|
|
276
300
|
content = tool_input.get("content") or ""
|
|
277
301
|
symbols = len(content.splitlines()) or None
|
|
302
|
+
fp = tool_input.get("file_path") or tool_input.get("path")
|
|
303
|
+
if fp:
|
|
304
|
+
file_paths = [fp]
|
|
278
305
|
|
|
279
306
|
elif tool_name in ("edit", "str_replace_based_edit_tool", "str_replace_editor"):
|
|
280
307
|
files = 1
|
|
281
308
|
new_str = tool_input.get("new_string") or tool_input.get("new_content") or ""
|
|
282
309
|
symbols = len(new_str.splitlines()) or None
|
|
310
|
+
fp = tool_input.get("file_path") or tool_input.get("path")
|
|
311
|
+
if fp:
|
|
312
|
+
file_paths = [fp]
|
|
283
313
|
|
|
284
314
|
elif tool_name in ("multiedit",):
|
|
285
315
|
edits = tool_input.get("edits") or []
|
|
286
316
|
files = len(edits)
|
|
317
|
+
file_paths = [e.get("file_path") or e.get("path") for e in edits if e.get("file_path") or e.get("path")]
|
|
287
318
|
|
|
288
319
|
else:
|
|
289
320
|
files = 1 # unknown write-like tool
|
|
290
321
|
|
|
291
|
-
|
|
322
|
+
repo, branch = _git_context()
|
|
323
|
+
return {"files": files, "symbols": symbols, "tier": tier, "file_paths": file_paths, "repo": repo, "branch": branch}
|
|
292
324
|
|
|
293
325
|
|
|
294
|
-
def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms, blast_radius=None) -> None:
|
|
326
|
+
def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms, blast_radius=None, execution_status=None, result_summary=None) -> None:
|
|
295
327
|
"""Fire-and-forget POST to /guard/events/usage."""
|
|
296
328
|
cfg = load_config()
|
|
297
329
|
workspace_id = cfg.get("workspace_id")
|
|
298
330
|
if not workspace_id or not session_id:
|
|
299
331
|
return
|
|
300
332
|
payload = json.dumps({
|
|
301
|
-
"workspace_id":
|
|
302
|
-
"hook_session_id":
|
|
303
|
-
"tool_name":
|
|
304
|
-
"tokens_input":
|
|
305
|
-
"tokens_output":
|
|
306
|
-
"duration_ms":
|
|
307
|
-
"ai_tool":
|
|
308
|
-
"blast_radius":
|
|
333
|
+
"workspace_id": workspace_id,
|
|
334
|
+
"hook_session_id": session_id,
|
|
335
|
+
"tool_name": tool_name,
|
|
336
|
+
"tokens_input": tokens_input,
|
|
337
|
+
"tokens_output": tokens_output,
|
|
338
|
+
"duration_ms": duration_ms,
|
|
339
|
+
"ai_tool": detect_ai_tool(),
|
|
340
|
+
"blast_radius": blast_radius,
|
|
341
|
+
"execution_status": execution_status,
|
|
342
|
+
"result_summary": result_summary,
|
|
309
343
|
})
|
|
310
344
|
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
311
345
|
script = (
|
|
@@ -369,6 +403,15 @@ def main() -> None:
|
|
|
369
403
|
tool_input = data.get("tool_input") or {}
|
|
370
404
|
blast_radius = _compute_blast_radius(tool_name, tool_input, str(tool_response))
|
|
371
405
|
|
|
406
|
+
# Derive execution outcome from hook data
|
|
407
|
+
_resp_str = str(tool_response)
|
|
408
|
+
if data.get("error") or "Error" in _resp_str[:100]:
|
|
409
|
+
execution_status = "error"
|
|
410
|
+
result_summary = _resp_str[:200] or None
|
|
411
|
+
else:
|
|
412
|
+
execution_status = "success"
|
|
413
|
+
result_summary = None
|
|
414
|
+
|
|
372
415
|
if is_codex and transcript_path:
|
|
373
416
|
import uuid as _uuid
|
|
374
417
|
pending = GUARD_DIR / f"codex_pending_{_uuid.uuid4().hex[:8]}.json"
|
|
@@ -389,7 +432,7 @@ def main() -> None:
|
|
|
389
432
|
pass
|
|
390
433
|
elif transcript_path:
|
|
391
434
|
tokens_input, tokens_output = _read_tokens_from_transcript(transcript_path, tool_use_id)
|
|
392
|
-
_post_usage(session_id, tool_name, tokens_input, tokens_output, None, blast_radius)
|
|
435
|
+
_post_usage(session_id, tool_name, tokens_input, tokens_output, None, blast_radius, execution_status, result_summary)
|
|
393
436
|
_, action, rule_id, message = check_policy(tool_name, {}, tokens_before=tokens_input)
|
|
394
437
|
if action in ("warn", "block"):
|
|
395
438
|
decision = "warned" if action == "warn" else "blocked"
|
|
@@ -161,10 +161,16 @@ def _stream_run(server: str, workflow_id: str, run_id: str, workspace_id: str, t
|
|
|
161
161
|
summary = payload.get("summary", payload.get("tool", ""))
|
|
162
162
|
print(f" · {summary}{RESET}")
|
|
163
163
|
elif kind == "guard_check":
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
n = payload.get("rules_checked", 0)
|
|
165
|
+
block_type = payload.get("block_type", "")
|
|
166
|
+
if payload.get("warnings"):
|
|
167
|
+
for w in payload["warnings"]:
|
|
168
|
+
print(f"{YELLOW} ⚠ [guard:{block_type}] {w.get('rule_id', '')}: {w.get('message', '')}{RESET}")
|
|
169
|
+
elif payload.get("audited"):
|
|
170
|
+
for a in payload["audited"]:
|
|
171
|
+
print(f"{BLUE} ● [guard:{block_type}] {a.get('rule_id', '')}: {a.get('message', '')}{RESET}")
|
|
172
|
+
else:
|
|
173
|
+
print(f"{GREEN} ✓ [guard:{block_type}] {n} rules checked — passed{RESET}")
|
|
168
174
|
elif kind == "run_completed":
|
|
169
175
|
print(f"{BOLD}{GREEN} ✓ done{RESET}")
|
|
170
176
|
elif kind == "run_failed":
|
|
@@ -458,7 +464,11 @@ def _exchange_clerk_token(api_url: str, clerk_token: str, workspace_id: str) ->
|
|
|
458
464
|
}
|
|
459
465
|
except urllib.error.HTTPError as e:
|
|
460
466
|
body_text = e.read().decode(errors="replace")
|
|
461
|
-
|
|
467
|
+
try:
|
|
468
|
+
detail = json.loads(body_text).get("detail", body_text)
|
|
469
|
+
except Exception:
|
|
470
|
+
detail = body_text[:120]
|
|
471
|
+
print(f"{RED}Login failed: {detail} — run `conduct login` to try again{RESET}")
|
|
462
472
|
sys.exit(1)
|
|
463
473
|
|
|
464
474
|
|
|
@@ -3020,7 +3030,8 @@ def cmd_skill(args):
|
|
|
3020
3030
|
msg = json.loads(body).get("detail", body)
|
|
3021
3031
|
except Exception:
|
|
3022
3032
|
msg = body
|
|
3023
|
-
|
|
3033
|
+
from conduct_cli.api import _friendly
|
|
3034
|
+
print(f"{RED}{_friendly(e.code, msg)}{RESET}")
|
|
3024
3035
|
sys.exit(1)
|
|
3025
3036
|
|
|
3026
3037
|
if skill_command == "list":
|
|
@@ -3227,6 +3238,7 @@ def main():
|
|
|
3227
3238
|
verify_p.add_argument("--strict", action="store_true", help="Exit 1 if any blocked events in last 24h (CI mode).")
|
|
3228
3239
|
verify_p.add_argument("--format", choices=["text", "json"], default="text", help="Output format (default: text)")
|
|
3229
3240
|
verify_p.add_argument("--since", default="24h", help="Time window for blocked event check (e.g. 7d, 24h)")
|
|
3241
|
+
verify_p.add_argument("--run", action="store_true", help="Fire live adversarial test battery and show per-test verdicts.")
|
|
3230
3242
|
sub.add_parser("test-guard", help="Fire a synthetic event per guard policy rule and show decisions")
|
|
3231
3243
|
sub.add_parser("test-security", help="Post a synthetic finding per security classifier pattern")
|
|
3232
3244
|
sub.add_parser("test-security-verify", help="Post test findings and verify full triage pipeline end-to-end")
|
|
@@ -258,7 +258,15 @@ def _dispatch_tool(
|
|
|
258
258
|
if name == "conduct_list_playbooks":
|
|
259
259
|
return _handle_list_playbooks(server, workspace_id, token, api_key)
|
|
260
260
|
if name == "conduct_run_workflow":
|
|
261
|
-
|
|
261
|
+
try:
|
|
262
|
+
from conduct_cli.hooks.pretooluse import check_policy
|
|
263
|
+
_, action, rule_id, message = check_policy("workflow", arguments)
|
|
264
|
+
if action == "block":
|
|
265
|
+
return json.dumps({"decision": "BLOCKED", "rule": rule_id, "message": f"[ConductGuard] {message}"})
|
|
266
|
+
warn_prefix = f"[ConductGuard] WARNING: {message}\n\n" if action == "warn" else ""
|
|
267
|
+
except Exception:
|
|
268
|
+
warn_prefix = ""
|
|
269
|
+
return warn_prefix + _handle_run_workflow(arguments, server, workspace_id, token, api_key)
|
|
262
270
|
if name == "conduct_get_run":
|
|
263
271
|
return _handle_get_run(arguments, server, token, api_key)
|
|
264
272
|
if name == "conduct_guard_status":
|
|
@@ -308,6 +316,22 @@ def main() -> None:
|
|
|
308
316
|
"protocolVersion": PROTOCOL_VERSION,
|
|
309
317
|
"capabilities": {"tools": {}},
|
|
310
318
|
"serverInfo": {"name": "conduct", "version": "1.0.0"},
|
|
319
|
+
"instructions": (
|
|
320
|
+
"ConductGuard is active and enforcing your team's security policy. "
|
|
321
|
+
"ALWAYS call guard_activity at the start of every conversation with a "
|
|
322
|
+
"one-line summary of what the user is asking you to do. "
|
|
323
|
+
"ALWAYS call guard_check before executing any of the following: "
|
|
324
|
+
"shell commands, file reads or writes, network requests, external API "
|
|
325
|
+
"calls, code modifications, or any action that affects the filesystem "
|
|
326
|
+
"or environment. "
|
|
327
|
+
"If the response is BLOCKED: stop immediately and explain the policy "
|
|
328
|
+
"rule to the user. "
|
|
329
|
+
"If WARNING: proceed but surface the warning to the user. "
|
|
330
|
+
"If ALLOWED: proceed normally. "
|
|
331
|
+
"Use guard_status to show the user their current policy and team info. "
|
|
332
|
+
"Use guard_sync after your security team updates policies. "
|
|
333
|
+
"Never skip guard_activity or guard_check — both are required for compliance."
|
|
334
|
+
),
|
|
311
335
|
})
|
|
312
336
|
|
|
313
337
|
elif method == "notifications/initialized":
|
|
@@ -319,8 +343,11 @@ def main() -> None:
|
|
|
319
343
|
elif method == "tools/call":
|
|
320
344
|
tool_name = params.get("name", "")
|
|
321
345
|
arguments = params.get("arguments") or {}
|
|
322
|
-
|
|
323
|
-
|
|
346
|
+
try:
|
|
347
|
+
text = _dispatch_tool(tool_name, arguments, server, workspace_id, token, api_key)
|
|
348
|
+
_ok(msg_id, {"content": [{"type": "text", "text": text}]})
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
_ok(msg_id, {"content": [{"type": "text", "text": str(exc)}], "isError": True})
|
|
324
351
|
|
|
325
352
|
elif method == "ping":
|
|
326
353
|
_ok(msg_id, {})
|
|
@@ -27,8 +27,10 @@ src/conduct_cli/hooks/session_report_push.py
|
|
|
27
27
|
src/conduct_cli/hooks/session_start.py
|
|
28
28
|
src/conduct_cli/hooks/stop.py
|
|
29
29
|
tests/test_advisory_and_hook.py
|
|
30
|
+
tests/test_api_client.py
|
|
30
31
|
tests/test_bash_operator_signature.py
|
|
31
32
|
tests/test_blast_radius.py
|
|
33
|
+
tests/test_cmd_run.py
|
|
32
34
|
tests/test_command_word_matcher.py
|
|
33
35
|
tests/test_drain_daemon_health.py
|
|
34
36
|
tests/test_guard_policy.py
|
|
@@ -39,4 +41,5 @@ tests/test_log_util.py
|
|
|
39
41
|
tests/test_proxy_coverage.py
|
|
40
42
|
tests/test_proxy_env.py
|
|
41
43
|
tests/test_signed_policy.py
|
|
42
|
-
tests/test_switch.py
|
|
44
|
+
tests/test_switch.py
|
|
45
|
+
tests/test_token_lifecycle.py
|