conduct-cli 0.7.34__tar.gz → 0.7.36__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.34 → conduct_cli-0.7.36}/PKG-INFO +1 -1
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/pyproject.toml +1 -1
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/guard.py +91 -2
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/guardmcp.py +12 -4
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/hooks/base.py +6 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/hooks/posttooluse.py +55 -12
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/main.py +1 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/mcp_server.py +30 -3
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli.egg-info/SOURCES.txt +4 -1
- conduct_cli-0.7.36/tests/test_api_client.py +350 -0
- conduct_cli-0.7.36/tests/test_blast_radius.py +81 -0
- conduct_cli-0.7.36/tests/test_cmd_run.py +291 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_guard_policy.py +55 -2
- conduct_cli-0.7.36/tests/test_token_lifecycle.py +232 -0
- conduct_cli-0.7.34/tests/test_blast_radius.py +0 -44
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/README.md +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/setup.cfg +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/setup.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/hooks/pretooluse.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.7.34 → conduct_cli-0.7.36}/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.36"
|
|
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" }
|
|
@@ -401,11 +401,17 @@ def _register_mcp(workspace_id: str, agent_token: str, api_url: str) -> None:
|
|
|
401
401
|
if shutil.which("booster"):
|
|
402
402
|
servers["agent-booster"] = {"command": "booster", "args": ["serve"]}
|
|
403
403
|
|
|
404
|
-
|
|
404
|
+
vscode_paths = _vscode_mcp_paths()
|
|
405
|
+
targets = list(_MCP_TARGETS) + vscode_paths
|
|
406
|
+
vscode_cfg_paths = {p for p, _ in vscode_paths}
|
|
405
407
|
found_any = False
|
|
406
408
|
for cfg_path, label in targets:
|
|
407
409
|
if not cfg_path.exists():
|
|
408
|
-
|
|
410
|
+
# Create mcp.json for VS Code if Copilot is confirmed installed
|
|
411
|
+
if cfg_path in vscode_cfg_paths:
|
|
412
|
+
cfg_path.write_text("{}")
|
|
413
|
+
else:
|
|
414
|
+
continue
|
|
409
415
|
found_any = True
|
|
410
416
|
try:
|
|
411
417
|
existing = json.loads(cfg_path.read_text())
|
|
@@ -1878,6 +1884,9 @@ def cmd_guard_status(args):
|
|
|
1878
1884
|
proxy_line = f"{RED}not configured{RESET} — run: {BOLD}conduct guard sync{RESET}"
|
|
1879
1885
|
|
|
1880
1886
|
print(f"\n{BOLD}Guard status{RESET} — {user_email}")
|
|
1887
|
+
_active_cfg = _load_guard_config()
|
|
1888
|
+
if _active_cfg.get("current_goal_id"):
|
|
1889
|
+
print(f" Active goal: {CYAN}{_active_cfg.get('current_goal_name', 'unnamed')}{RESET} ({_active_cfg['current_goal_id'][:8]}...)")
|
|
1881
1890
|
print(f"{rule_count} polic{'y' if rule_count == 1 else 'ies'} active")
|
|
1882
1891
|
print()
|
|
1883
1892
|
print(f"Proxy coverage: {proxy_line}")
|
|
@@ -1931,6 +1940,32 @@ def cmd_guard_savings(args):
|
|
|
1931
1940
|
print()
|
|
1932
1941
|
|
|
1933
1942
|
|
|
1943
|
+
def cmd_guard_session(args):
|
|
1944
|
+
sub = getattr(args, "session_cmd", None)
|
|
1945
|
+
if sub == "start":
|
|
1946
|
+
import uuid as _uuid
|
|
1947
|
+
goal = getattr(args, "goal", "") or ""
|
|
1948
|
+
goal_id = str(_uuid.uuid4())
|
|
1949
|
+
cfg = _load_guard_config()
|
|
1950
|
+
cfg["current_goal_id"] = goal_id
|
|
1951
|
+
cfg["current_goal_name"] = goal
|
|
1952
|
+
_save_guard_config(cfg)
|
|
1953
|
+
print(f" {GREEN}Session started{RESET}")
|
|
1954
|
+
if goal:
|
|
1955
|
+
print(f" Goal: {CYAN}{goal}{RESET}")
|
|
1956
|
+
print(f" ID: {goal_id}")
|
|
1957
|
+
print(f"\nAll Guard events in this session will be tagged with this goal.")
|
|
1958
|
+
print(f"Run {BOLD}conduct guard session stop{RESET} when done.")
|
|
1959
|
+
elif sub == "stop":
|
|
1960
|
+
cfg = _load_guard_config()
|
|
1961
|
+
name = cfg.pop("current_goal_name", "")
|
|
1962
|
+
cfg.pop("current_goal_id", None)
|
|
1963
|
+
_save_guard_config(cfg)
|
|
1964
|
+
print(f" {GREEN}Session stopped{RESET}" + (f" — {name}" if name else ""))
|
|
1965
|
+
else:
|
|
1966
|
+
print("Usage: conduct guard session start [--goal GOAL] | stop")
|
|
1967
|
+
|
|
1968
|
+
|
|
1934
1969
|
def cmd_guard_audit(args):
|
|
1935
1970
|
cfg = _require_guard_config()
|
|
1936
1971
|
workspace_id = cfg.get("workspace_id")
|
|
@@ -2085,6 +2120,14 @@ def register_guard_parser(sub):
|
|
|
2085
2120
|
lint_p.add_argument("--file", default=None, metavar="FILE",
|
|
2086
2121
|
help="Path to policy YAML/JSON (default: ~/.conduct/policy.json)")
|
|
2087
2122
|
|
|
2123
|
+
# conduct guard session start|stop
|
|
2124
|
+
p_session = guard_sub.add_parser("session", help="Start or stop a named goal session")
|
|
2125
|
+
session_sub = p_session.add_subparsers(dest="session_cmd")
|
|
2126
|
+
p_sess_start = session_sub.add_parser("start", help="Start a goal session — tags all Guard events with a goal ID")
|
|
2127
|
+
p_sess_start.add_argument("--goal", default="", help="Human-readable goal label")
|
|
2128
|
+
session_sub.add_parser("stop", help="End the current goal session")
|
|
2129
|
+
p_session.set_defaults(func=cmd_guard_session)
|
|
2130
|
+
|
|
2088
2131
|
return guard_p, guard_sub
|
|
2089
2132
|
|
|
2090
2133
|
|
|
@@ -2683,6 +2726,9 @@ def _grade_below(actual: str, minimum: str) -> bool:
|
|
|
2683
2726
|
return _GRADE_ORDER.index(actual) > _GRADE_ORDER.index(minimum.upper())
|
|
2684
2727
|
|
|
2685
2728
|
|
|
2729
|
+
_VERDICT_COLOR = {"held": GREEN, "bypassed": RED, "not_tested": YELLOW}
|
|
2730
|
+
|
|
2731
|
+
|
|
2686
2732
|
def cmd_verify(args) -> None:
|
|
2687
2733
|
"""conduct verify — governance grade + OWASP Agentic Top 10 coverage."""
|
|
2688
2734
|
import json as _json
|
|
@@ -2692,6 +2738,7 @@ def cmd_verify(args) -> None:
|
|
|
2692
2738
|
min_grade = getattr(args, "min_grade", None)
|
|
2693
2739
|
strict = getattr(args, "strict", False)
|
|
2694
2740
|
fmt = getattr(args, "format", "text")
|
|
2741
|
+
run_battery = getattr(args, "run", False)
|
|
2695
2742
|
|
|
2696
2743
|
cfg = _require_guard_config()
|
|
2697
2744
|
workspace_id = cfg.get("workspace_id")
|
|
@@ -2712,6 +2759,46 @@ def cmd_verify(args) -> None:
|
|
|
2712
2759
|
controls = ev.get("controls", [])
|
|
2713
2760
|
generated_at = ev.get("generated_at", "")
|
|
2714
2761
|
|
|
2762
|
+
# ── --run (live adversarial battery) ─────────────────────────────────────
|
|
2763
|
+
run_result = None
|
|
2764
|
+
if run_battery:
|
|
2765
|
+
print(f"\n{BOLD}Running adversarial test battery…{RESET}")
|
|
2766
|
+
run_result = _req(
|
|
2767
|
+
"POST",
|
|
2768
|
+
f"{base_url}/guard/verify/run?workspace_id={workspace_id}",
|
|
2769
|
+
api_key=api_key,
|
|
2770
|
+
)
|
|
2771
|
+
if fmt == "json":
|
|
2772
|
+
print(_json.dumps(run_result, indent=2))
|
|
2773
|
+
return
|
|
2774
|
+
rs = run_result.get("score", 0)
|
|
2775
|
+
rg = run_result.get("grade", "F")
|
|
2776
|
+
passed = run_result.get("passed_tests", 0)
|
|
2777
|
+
total = run_result.get("total_tests", 0)
|
|
2778
|
+
results = run_result.get("results", [])
|
|
2779
|
+
rg_col = _GRADE_COLORS.get(rg, GRAY)
|
|
2780
|
+
print()
|
|
2781
|
+
print(f"{BOLD}conduct verify --run — Live Adversarial Score{RESET}")
|
|
2782
|
+
print("─" * 60)
|
|
2783
|
+
print(f"\n Live Grade: {rg_col}{BOLD}{rg}{RESET} ({rs}/100) · {passed}/{total} held\n")
|
|
2784
|
+
print(f" {BOLD}{'ASI':<8} {'Verdict':<12} {'Expected':<10} {'Actual':<10} Test{RESET}")
|
|
2785
|
+
print(" " + "─" * 56)
|
|
2786
|
+
for r in results:
|
|
2787
|
+
vc = _VERDICT_COLOR.get(r.get("verdict", ""), GRAY)
|
|
2788
|
+
print(
|
|
2789
|
+
f" {r.get('asi',''):<8} "
|
|
2790
|
+
f"{vc}{r.get('verdict',''):<12}{RESET} "
|
|
2791
|
+
f"{r.get('expected',''):<10} "
|
|
2792
|
+
f"{r.get('actual',''):<10} "
|
|
2793
|
+
f"{r.get('name','')}"
|
|
2794
|
+
)
|
|
2795
|
+
print()
|
|
2796
|
+
if min_grade and _grade_below(rg, min_grade):
|
|
2797
|
+
print(f" {RED}✗ Live grade {rg} is below minimum {min_grade.upper()}.{RESET}\n")
|
|
2798
|
+
sys.exit(1)
|
|
2799
|
+
print(f" {GREEN}✓ PASS{RESET}\n")
|
|
2800
|
+
return
|
|
2801
|
+
|
|
2715
2802
|
# ── --badge ───────────────────────────────────────────────────────────────
|
|
2716
2803
|
if badge:
|
|
2717
2804
|
color = {"A": "brightgreen", "B": "green", "C": "yellow", "D": "orange", "F": "red"}.get(grade, "lightgrey")
|
|
@@ -2797,6 +2884,8 @@ def dispatch_guard(args, guard_p):
|
|
|
2797
2884
|
cmd_guard_booster_status(args)
|
|
2798
2885
|
elif guard_command == "debug-hook":
|
|
2799
2886
|
cmd_guard_debug_hook(args)
|
|
2887
|
+
elif guard_command == "session":
|
|
2888
|
+
cmd_guard_session(args)
|
|
2800
2889
|
else:
|
|
2801
2890
|
guard_p.print_help()
|
|
2802
2891
|
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"
|
|
@@ -3238,6 +3238,7 @@ def main():
|
|
|
3238
3238
|
verify_p.add_argument("--strict", action="store_true", help="Exit 1 if any blocked events in last 24h (CI mode).")
|
|
3239
3239
|
verify_p.add_argument("--format", choices=["text", "json"], default="text", help="Output format (default: text)")
|
|
3240
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.")
|
|
3241
3242
|
sub.add_parser("test-guard", help="Fire a synthetic event per guard policy rule and show decisions")
|
|
3242
3243
|
sub.add_parser("test-security", help="Post a synthetic finding per security classifier pattern")
|
|
3243
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
|