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.
Files changed (48) hide show
  1. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/api.py +18 -5
  4. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/guard.py +83 -0
  5. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/guardmcp.py +12 -4
  6. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/base.py +6 -0
  7. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/posttooluse.py +55 -12
  8. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/main.py +18 -6
  9. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/mcp_server.py +30 -3
  10. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  11. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/SOURCES.txt +4 -1
  12. conduct_cli-0.7.35/tests/test_api_client.py +350 -0
  13. conduct_cli-0.7.35/tests/test_blast_radius.py +81 -0
  14. conduct_cli-0.7.35/tests/test_cmd_run.py +291 -0
  15. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_guard_policy.py +55 -2
  16. conduct_cli-0.7.35/tests/test_token_lifecycle.py +232 -0
  17. conduct_cli-0.7.33/tests/test_blast_radius.py +0 -44
  18. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/README.md +0 -0
  19. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/setup.cfg +0 -0
  20. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/setup.py +0 -0
  21. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/__init__.py +0 -0
  22. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/__init__.py +0 -0
  23. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/precompact.py +0 -0
  24. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/pretooluse.py +0 -0
  25. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/session_parser.py +0 -0
  26. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/session_report_push.py +0 -0
  27. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/session_start.py +0 -0
  28. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/hooks/stop.py +0 -0
  29. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/log_util.py +0 -0
  30. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/memory.py +0 -0
  31. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/paxel.py +0 -0
  32. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli/tool_groups.py +0 -0
  33. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  34. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  35. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/requires.txt +0 -0
  36. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/src/conduct_cli.egg-info/top_level.txt +0 -0
  37. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_advisory_and_hook.py +0 -0
  38. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_bash_operator_signature.py +0 -0
  39. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_command_word_matcher.py +0 -0
  40. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_drain_daemon_health.py +0 -0
  41. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_guard_savings.py +0 -0
  42. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_hook_dispatch.py +0 -0
  43. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_journal_drain.py +0 -0
  44. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_log_util.py +0 -0
  45. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_proxy_coverage.py +0 -0
  46. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_proxy_env.py +0 -0
  47. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_signed_policy.py +0 -0
  48. {conduct_cli-0.7.33 → conduct_cli-0.7.35}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.33
3
+ Version: 0.7.35
4
4
  Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.7.33"
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
- DEV_WORKSPACE = "00000000-0000-0000-0000-000000000001"
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}HTTP {e.code}: {detail} [{url}]{RESET}")
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: {url}{RESET}")
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}HTTP {e.code}: {detail} [{url}]{RESET}")
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: {url}{RESET}")
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 = params.get("name", "")
472
- arguments = params.get("arguments") or {}
473
- text = _dispatch_tool(tool_name, arguments, workspace_id, token, ai_tool)
474
- _ok(msg_id, {"content": [{"type": "text", "text": text}]})
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
- files = min(sum(1 for l in lines if "/" in l or ("." in l and len(l) < 200)), 50)
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
- return {"files": files, "symbols": symbols, "tier": tier}
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": workspace_id,
302
- "hook_session_id": session_id,
303
- "tool_name": tool_name,
304
- "tokens_input": tokens_input,
305
- "tokens_output": tokens_output,
306
- "duration_ms": duration_ms,
307
- "ai_tool": detect_ai_tool(),
308
- "blast_radius": 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
- for w in payload.get("warnings", []):
165
- print(f"{YELLOW} ⚠ [guard] {w.get('rule_id', '')}: {w.get('message', '')}{RESET}")
166
- for a in payload.get("audited", []):
167
- print(f"{BLUE} ● [guard] {a.get('rule_id', '')}: {a.get('message', '')}{RESET}")
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
- print(f"{RED}Token exchange failed ({e.code}): {body_text[:120]}{RESET}")
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
- print(f"{RED}Error {e.code}: {msg}{RESET}")
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
- return _handle_run_workflow(arguments, server, workspace_id, token, api_key)
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
- text = _dispatch_tool(tool_name, arguments, server, workspace_id, token, api_key)
323
- _ok(msg_id, {"content": [{"type": "text", "text": text}]})
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, {})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.33
3
+ Version: 0.7.35
4
4
  Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -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