conduct-cli 0.9.2__tar.gz → 0.9.4__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.9.2 → conduct_cli-0.9.4}/PKG-INFO +1 -1
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/pyproject.toml +1 -1
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/guard.py +73 -2
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/hooks/pretooluse.py +99 -2
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_cli_matrix.py +18 -5
- conduct_cli-0.9.4/tests/test_conductguard_mcp_stdio.py +68 -0
- conduct_cli-0.9.4/tests/test_guard_approvals.py +117 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/README.md +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/setup.cfg +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/setup.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/guard_policy.md +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/hooks/base.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_api_client.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_bash_scan_target.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_cmd_run.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_log_util.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_report_tool_coverage.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_switch.py +0 -0
- {conduct_cli-0.9.2 → conduct_cli-0.9.4}/tests/test_token_lifecycle.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.9.
|
|
7
|
+
version = "0.9.4"
|
|
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" }
|
|
@@ -353,10 +353,10 @@ def _require_guard_config() -> dict:
|
|
|
353
353
|
ws = cfg.get("workspace_id") or cfg.get("workspace")
|
|
354
354
|
if not cfg or not ws:
|
|
355
355
|
print(f"{RED}Guard not connected. Run: conduct login{RESET}", file=sys.stderr)
|
|
356
|
-
sys.exit(
|
|
356
|
+
sys.exit(1)
|
|
357
357
|
if not cfg.get("agent_token"):
|
|
358
358
|
print(f"{RED}Guard config is missing credentials. Run: conduct login{RESET}", file=sys.stderr)
|
|
359
|
-
sys.exit(
|
|
359
|
+
sys.exit(1)
|
|
360
360
|
return cfg
|
|
361
361
|
|
|
362
362
|
|
|
@@ -2374,6 +2374,21 @@ def register_guard_parser(sub):
|
|
|
2374
2374
|
session_sub.add_parser("stop", help="End the current goal session")
|
|
2375
2375
|
p_session.set_defaults(func=cmd_guard_session)
|
|
2376
2376
|
|
|
2377
|
+
# conduct guard approvals list|approve|reject — HITL decide from CLI (#1140)
|
|
2378
|
+
approv_p = guard_sub.add_parser("approvals", help="List and decide Guard HITL approval requests")
|
|
2379
|
+
approv_sub = approv_p.add_subparsers(dest="approvals_command")
|
|
2380
|
+
list_p = approv_sub.add_parser("list", help="List approval requests")
|
|
2381
|
+
list_p.add_argument("--status", default="pending",
|
|
2382
|
+
choices=["pending", "approved", "rejected", "timed_out", "all"],
|
|
2383
|
+
help="Filter by status (default: pending)")
|
|
2384
|
+
list_p.add_argument("--limit", type=int, default=50, help="Max rows (default: 50)")
|
|
2385
|
+
approve_p = approv_sub.add_parser("approve", help="Approve a pending request by id")
|
|
2386
|
+
approve_p.add_argument("request_id", help="Approval request UUID")
|
|
2387
|
+
approve_p.add_argument("--reason", default=None, help="Optional decision reason")
|
|
2388
|
+
reject_p = approv_sub.add_parser("reject", help="Reject a pending request by id")
|
|
2389
|
+
reject_p.add_argument("request_id", help="Approval request UUID")
|
|
2390
|
+
reject_p.add_argument("--reason", default=None, help="Optional decision reason")
|
|
2391
|
+
|
|
2377
2392
|
return guard_p, guard_sub
|
|
2378
2393
|
|
|
2379
2394
|
|
|
@@ -3178,6 +3193,60 @@ def cmd_guard_simulate(args):
|
|
|
3178
3193
|
print("[simulation] identity : (none — token did not resolve to an AgentIdentity)")
|
|
3179
3194
|
|
|
3180
3195
|
|
|
3196
|
+
def cmd_guard_approvals(args, guard_p):
|
|
3197
|
+
"""List and decide Guard HITL approval requests (#1140)."""
|
|
3198
|
+
sub = getattr(args, "approvals_command", None)
|
|
3199
|
+
if sub not in ("list", "approve", "reject"):
|
|
3200
|
+
guard_p.print_help()
|
|
3201
|
+
sys.exit(1)
|
|
3202
|
+
|
|
3203
|
+
cfg = _require_guard_config()
|
|
3204
|
+
workspace_id = cfg.get("workspace_id")
|
|
3205
|
+
agent_token = cfg.get("agent_token", "")
|
|
3206
|
+
base_url = _api_url(cfg)
|
|
3207
|
+
|
|
3208
|
+
if sub == "list":
|
|
3209
|
+
url = f"{base_url}/guard/approvals?workspace_id={workspace_id}&status={args.status}&limit={args.limit}"
|
|
3210
|
+
resp = _req("GET", url, token=agent_token or None)
|
|
3211
|
+
items = resp.get("items", []) if isinstance(resp, dict) else []
|
|
3212
|
+
if not items:
|
|
3213
|
+
print(f"{GRAY}No approvals with status={args.status}.{RESET}")
|
|
3214
|
+
return
|
|
3215
|
+
id_w, req_w, rule_w, msg_w = 38, 26, 24, 40
|
|
3216
|
+
print()
|
|
3217
|
+
print(f"{BOLD}{'ID':<{id_w}} {'Requester':<{req_w}} {'Rule':<{rule_w}} {'Message':<{msg_w}} Status{RESET}")
|
|
3218
|
+
print("─" * (id_w + req_w + rule_w + msg_w + 12))
|
|
3219
|
+
colors = {"pending": YELLOW, "approved": GREEN, "rejected": RED, "timed_out": GRAY}
|
|
3220
|
+
for it in items:
|
|
3221
|
+
st = it.get("status", "?")
|
|
3222
|
+
print(
|
|
3223
|
+
f"{it.get('id',''):<{id_w}} "
|
|
3224
|
+
f"{(it.get('requester_email') or '—')[:req_w-1]:<{req_w}} "
|
|
3225
|
+
f"{(it.get('rule_id') or '—')[:rule_w-1]:<{rule_w}} "
|
|
3226
|
+
f"{(it.get('rule_message') or '—')[:msg_w-1]:<{msg_w}} "
|
|
3227
|
+
f"{colors.get(st, '')}{st}{RESET}"
|
|
3228
|
+
)
|
|
3229
|
+
return
|
|
3230
|
+
|
|
3231
|
+
# approve / reject share a body shape
|
|
3232
|
+
decision = "approved" if sub == "approve" else "rejected"
|
|
3233
|
+
body = {"decision": decision}
|
|
3234
|
+
if args.reason:
|
|
3235
|
+
body["reason"] = args.reason
|
|
3236
|
+
url = f"{base_url}/guard/approvals/{args.request_id}/decide?workspace_id={workspace_id}"
|
|
3237
|
+
resp = _req("POST", url, body=body, token=agent_token or None)
|
|
3238
|
+
req = resp.get("request") if isinstance(resp, dict) else None
|
|
3239
|
+
if not req:
|
|
3240
|
+
print(f"{RED}Decision failed — no response body.{RESET}")
|
|
3241
|
+
sys.exit(1)
|
|
3242
|
+
color = GREEN if decision == "approved" else RED
|
|
3243
|
+
print(f"{color}{decision.upper()}{RESET} — {req.get('rule_id','')} "
|
|
3244
|
+
f"by {req.get('decided_by_email','?')} "
|
|
3245
|
+
f"latency={req.get('latency_ms','?')}ms "
|
|
3246
|
+
f"run_resumed={resp.get('run_resumed')}")
|
|
3247
|
+
|
|
3248
|
+
|
|
3249
|
+
|
|
3181
3250
|
def dispatch_guard(args, guard_p):
|
|
3182
3251
|
"""Dispatch to the correct guard handler. Called from main()."""
|
|
3183
3252
|
guard_command = getattr(args, "guard_command", None)
|
|
@@ -3205,6 +3274,8 @@ def dispatch_guard(args, guard_p):
|
|
|
3205
3274
|
cmd_guard_session(args)
|
|
3206
3275
|
elif guard_command == "simulate":
|
|
3207
3276
|
cmd_guard_simulate(args)
|
|
3277
|
+
elif guard_command == "approvals":
|
|
3278
|
+
cmd_guard_approvals(args, guard_p)
|
|
3208
3279
|
else:
|
|
3209
3280
|
guard_p.print_help()
|
|
3210
3281
|
sys.exit(1)
|
|
@@ -670,6 +670,80 @@ def _record_session_warn(session_id: str, rule_id: str) -> None:
|
|
|
670
670
|
|
|
671
671
|
# ── Hook entrypoint ───────────────────────────────────────────────────────────
|
|
672
672
|
|
|
673
|
+
|
|
674
|
+
def _guard_approval_request(
|
|
675
|
+
tool_name: str,
|
|
676
|
+
tool_input: dict,
|
|
677
|
+
rule_id: str,
|
|
678
|
+
message: str,
|
|
679
|
+
session_id: str | None,
|
|
680
|
+
) -> str:
|
|
681
|
+
"""Create a Guard approval request and long-poll for a decision.
|
|
682
|
+
|
|
683
|
+
Returns one of: "approved" | "rejected" | "timed_out" | "unavailable".
|
|
684
|
+
Fail-open on unavailable — the caller decides whether to allow (fail_open)
|
|
685
|
+
or block (fail_closed) based on Guard's configured fail mode."""
|
|
686
|
+
try:
|
|
687
|
+
cfg = load_config()
|
|
688
|
+
workspace_id = cfg.get("workspace_id") or cfg.get("workspace")
|
|
689
|
+
agent_token = cfg.get("agent_token", "")
|
|
690
|
+
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
691
|
+
if not workspace_id or not agent_token:
|
|
692
|
+
return "unavailable"
|
|
693
|
+
|
|
694
|
+
body = json.dumps({
|
|
695
|
+
"rule_id": rule_id or "unknown",
|
|
696
|
+
"rule_message": message,
|
|
697
|
+
"tool_name": tool_name,
|
|
698
|
+
"tool_input": tool_input,
|
|
699
|
+
"surface": "claude_code_hook",
|
|
700
|
+
"session_id": session_id,
|
|
701
|
+
}).encode()
|
|
702
|
+
create_req = urllib.request.Request(
|
|
703
|
+
f"{api_url}/guard/approvals?workspace_id={workspace_id}",
|
|
704
|
+
data=body,
|
|
705
|
+
method="POST",
|
|
706
|
+
headers={
|
|
707
|
+
"Authorization": f"Bearer {agent_token}",
|
|
708
|
+
"Content-Type": "application/json",
|
|
709
|
+
"X-Workspace-ID": workspace_id,
|
|
710
|
+
},
|
|
711
|
+
)
|
|
712
|
+
with urllib.request.urlopen(create_req, timeout=8) as resp:
|
|
713
|
+
created = json.loads(resp.read())
|
|
714
|
+
request_id = created.get("id")
|
|
715
|
+
url = created.get("url") or ""
|
|
716
|
+
if not request_id:
|
|
717
|
+
return "unavailable"
|
|
718
|
+
|
|
719
|
+
print(
|
|
720
|
+
f"[ConductGuard] PENDING approval - {message}\n"
|
|
721
|
+
f" rule: {rule_id}\n"
|
|
722
|
+
f" decide: {url}\n"
|
|
723
|
+
f" waiting up to 5min for a decision...",
|
|
724
|
+
file=sys.stderr,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
deadline = time.time() + 300
|
|
728
|
+
poll_url = f"{api_url}/guard/approvals/{request_id}?workspace_id={workspace_id}"
|
|
729
|
+
headers = {"Authorization": f"Bearer {agent_token}", "X-Workspace-ID": workspace_id}
|
|
730
|
+
while time.time() < deadline:
|
|
731
|
+
time.sleep(3.0)
|
|
732
|
+
try:
|
|
733
|
+
poll_req = urllib.request.Request(poll_url, headers=headers)
|
|
734
|
+
with urllib.request.urlopen(poll_req, timeout=5) as resp:
|
|
735
|
+
row = json.loads(resp.read())
|
|
736
|
+
st = row.get("status", "pending")
|
|
737
|
+
if st in ("approved", "rejected", "timed_out"):
|
|
738
|
+
return st
|
|
739
|
+
except Exception:
|
|
740
|
+
continue
|
|
741
|
+
return "timed_out"
|
|
742
|
+
except Exception as exc:
|
|
743
|
+
print(f"[ConductGuard] approval request failed: {exc}", file=sys.stderr)
|
|
744
|
+
return "unavailable"
|
|
745
|
+
|
|
746
|
+
|
|
673
747
|
def main() -> None:
|
|
674
748
|
try:
|
|
675
749
|
data = json.load(sys.stdin)
|
|
@@ -735,7 +809,30 @@ def main() -> None:
|
|
|
735
809
|
post_event(tool_name, tool_input, "audited", rule_id, f"[advisory] {message}", session_id, drain_via=_this_file)
|
|
736
810
|
sys.exit(0)
|
|
737
811
|
|
|
738
|
-
|
|
812
|
+
if action == "approval":
|
|
813
|
+
outcome = _guard_approval_request(tool_name, tool_input, rule_id, message, session_id)
|
|
814
|
+
if outcome == "approved":
|
|
815
|
+
post_event(tool_name, tool_input, "allowed", rule_id, f"[approval:approved] {message}", session_id, drain_via=_this_file)
|
|
816
|
+
print("[ConductGuard] approval granted - proceeding.", file=sys.stderr)
|
|
817
|
+
sys.exit(0)
|
|
818
|
+
if outcome == "rejected":
|
|
819
|
+
post_event(tool_name, tool_input, "blocked", rule_id, f"[approval:rejected] {message}", session_id, drain_via=_this_file)
|
|
820
|
+
print("[ConductGuard] approval rejected - tool call blocked.", file=sys.stderr)
|
|
821
|
+
sys.exit(2)
|
|
822
|
+
if outcome == "timed_out":
|
|
823
|
+
post_event(tool_name, tool_input, "blocked", rule_id, f"[approval:timed_out] {message}", session_id, drain_via=_this_file)
|
|
824
|
+
print("[ConductGuard] approval timed out - tool call blocked.", file=sys.stderr)
|
|
825
|
+
sys.exit(2)
|
|
826
|
+
# unavailable - respect Guard's fail mode. Default fail-closed.
|
|
827
|
+
fail_mode = _get_fail_mode()
|
|
828
|
+
post_event(tool_name, tool_input, "blocked", rule_id, f"[approval:unavailable/{fail_mode}] {message}", session_id, drain_via=_this_file)
|
|
829
|
+
if fail_mode == "fail_open":
|
|
830
|
+
print("[ConductGuard] approval service unavailable - fail-open allows.", file=sys.stderr)
|
|
831
|
+
sys.exit(0)
|
|
832
|
+
print("[ConductGuard] approval service unavailable - fail-closed blocks.", file=sys.stderr)
|
|
833
|
+
sys.exit(2)
|
|
834
|
+
|
|
835
|
+
decision = {"block": "blocked", "warn": "warned"}.get(action, "allowed")
|
|
739
836
|
if action == "warn" and session_id and rule_id and _already_warned_this_session(session_id, rule_id):
|
|
740
837
|
sys.exit(0)
|
|
741
838
|
if action == "warn" and session_id and rule_id:
|
|
@@ -747,7 +844,7 @@ def main() -> None:
|
|
|
747
844
|
print(msg)
|
|
748
845
|
print(msg, file=sys.stderr)
|
|
749
846
|
sys.exit(2)
|
|
750
|
-
if action
|
|
847
|
+
if action == "warn":
|
|
751
848
|
print(f"[ConductGuard] {message}")
|
|
752
849
|
|
|
753
850
|
sys.exit(0)
|
|
@@ -35,7 +35,9 @@ tests/test_blast_radius.py
|
|
|
35
35
|
tests/test_cli_matrix.py
|
|
36
36
|
tests/test_cmd_run.py
|
|
37
37
|
tests/test_command_word_matcher.py
|
|
38
|
+
tests/test_conductguard_mcp_stdio.py
|
|
38
39
|
tests/test_drain_daemon_health.py
|
|
40
|
+
tests/test_guard_approvals.py
|
|
39
41
|
tests/test_guard_policy.py
|
|
40
42
|
tests/test_guard_savings.py
|
|
41
43
|
tests/test_hook_dispatch.py
|
|
@@ -30,11 +30,24 @@ CONDUCT = shutil.which("conduct") or "conduct"
|
|
|
30
30
|
# Commands that intentionally do NOT touch the API (local ops only).
|
|
31
31
|
# Kept small on purpose — add sparingly, with a comment.
|
|
32
32
|
OFFLINE_ONLY: set[tuple[str, ...]] = {
|
|
33
|
-
("login",),
|
|
34
|
-
("mcp", "install"),
|
|
35
|
-
("session-report",),
|
|
36
|
-
("test-guard",),
|
|
37
|
-
("test-security",),
|
|
33
|
+
("login",), # opens browser, no direct API from CLI process
|
|
34
|
+
("mcp", "install"), # writes local Claude/Codex config
|
|
35
|
+
("session-report",), # scans local files
|
|
36
|
+
("test-guard",), # local synthetic events
|
|
37
|
+
("test-security",), # local synthetic findings
|
|
38
|
+
("sessions",), # reads local Claude Code session directory
|
|
39
|
+
("whoami",), # status query — prints config state, no API call when unauth'd
|
|
40
|
+
("guard", "audit"), # reads local ~/.conduct/audit.log
|
|
41
|
+
("guard", "booster-status"), # checks local booster install
|
|
42
|
+
("guard", "discover"), # scans local system for AI tools
|
|
43
|
+
("guard", "install"), # writes local hook + policy files
|
|
44
|
+
("guard", "lint"), # lints local policy YAML
|
|
45
|
+
("guard", "savings"), # reads local telemetry file
|
|
46
|
+
("guard", "session", "start"), # local session state file
|
|
47
|
+
("guard", "session", "stop"), # local session state file
|
|
48
|
+
("guard", "skip-setup"), # writes local "skip" flag
|
|
49
|
+
("guard", "status"), # reads local guard config
|
|
50
|
+
("guard", "watch"), # local daemon
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
# Match `{a,b,c}` in the argparse "positional arguments:" section only.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""conductguard-mcp stdio smoke test — runs in cli-matrix on every OS x Python combo.
|
|
2
|
+
|
|
3
|
+
Spawns the installed `conductguard-mcp` binary, sends `initialize` + `tools/list`
|
|
4
|
+
over stdin, asserts the responses parse and expose the standard tool set. The
|
|
5
|
+
CLI matrix would catch packaging regressions, stdio-loop crashes, or a broken
|
|
6
|
+
initialize handshake that would otherwise only surface in Claude Desktop.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
pytestmark = pytest.mark.skipif(
|
|
18
|
+
shutil.which("conductguard-mcp") is None,
|
|
19
|
+
reason="conductguard-mcp not on PATH (install the package first)",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(messages, timeout=8):
|
|
24
|
+
"""Feed newline-delimited JSON-RPC messages, collect newline-delimited responses."""
|
|
25
|
+
payload = "\n".join(json.dumps(m) for m in messages) + "\n"
|
|
26
|
+
proc = subprocess.run(
|
|
27
|
+
["conductguard-mcp", "--workspace", "00000000-0000-0000-0000-000000000000",
|
|
28
|
+
"--token", "dummy-for-stdio-smoke"],
|
|
29
|
+
input=payload,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
)
|
|
34
|
+
lines = [ln for ln in proc.stdout.splitlines() if ln.strip()]
|
|
35
|
+
return proc, [json.loads(ln) for ln in lines]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_initialize_returns_protocol_and_capabilities():
|
|
39
|
+
proc, replies = _run([
|
|
40
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
41
|
+
"params": {"protocolVersion": "2024-11-05", "clientInfo": {"name": "cli-matrix"}}},
|
|
42
|
+
])
|
|
43
|
+
assert proc.returncode == 0, "conductguard-mcp exited " + str(proc.returncode) + ": " + proc.stderr[-400:]
|
|
44
|
+
assert len(replies) == 1
|
|
45
|
+
result = replies[0]["result"]
|
|
46
|
+
assert "protocolVersion" in result
|
|
47
|
+
assert "capabilities" in result and "tools" in result["capabilities"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_tools_list_exposes_guard_tools():
|
|
51
|
+
proc, replies = _run([
|
|
52
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
53
|
+
"params": {"protocolVersion": "2024-11-05", "clientInfo": {"name": "cli-matrix"}}},
|
|
54
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
|
|
55
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
|
56
|
+
])
|
|
57
|
+
assert proc.returncode == 0
|
|
58
|
+
tool_lists = [r for r in replies if isinstance(r, dict) and r.get("id") == 2]
|
|
59
|
+
assert tool_lists, "no tools/list response found in: " + str(replies)
|
|
60
|
+
tools = tool_lists[0]["result"]["tools"]
|
|
61
|
+
names = {t["name"] for t in tools}
|
|
62
|
+
assert {"guard_check", "guard_activity"}.issubset(names), (
|
|
63
|
+
"expected the standard guard tool set, got: " + str(sorted(names))
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
sys.exit(pytest.main([__file__, "-v"]))
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Tests for `conduct guard approvals list|approve|reject` CLI (#1140).
|
|
2
|
+
|
|
3
|
+
Mocks _req + _require_guard_config so no network. Verifies URL/body shape
|
|
4
|
+
for decide, output for list (populated + empty), and dispatcher routing.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
HERE = Path(__file__).resolve()
|
|
16
|
+
CLI_PKG = HERE.parent.parent / "src"
|
|
17
|
+
if str(CLI_PKG) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(CLI_PKG))
|
|
19
|
+
|
|
20
|
+
_FAKE_CFG = {
|
|
21
|
+
"workspace_id": "ws-abc-123",
|
|
22
|
+
"agent_token": "cond_agt_test",
|
|
23
|
+
"api_url": "https://api.example.com",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture(autouse=True)
|
|
28
|
+
def patch_config(monkeypatch):
|
|
29
|
+
import conduct_cli.guard as g
|
|
30
|
+
monkeypatch.setattr(g, "_require_guard_config", lambda: _FAKE_CFG)
|
|
31
|
+
monkeypatch.setattr(g, "_api_url", lambda cfg: cfg["api_url"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse(*argv):
|
|
35
|
+
import conduct_cli.guard as g
|
|
36
|
+
p = argparse.ArgumentParser()
|
|
37
|
+
s = p.add_subparsers(dest="command")
|
|
38
|
+
guard_p, _ = g.register_guard_parser(s)
|
|
39
|
+
return p.parse_args(list(argv)), guard_p
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_parser_routes_list_approve_reject():
|
|
43
|
+
args, _ = _parse("guard", "approvals", "list", "--status", "approved", "--limit", "5")
|
|
44
|
+
assert args.approvals_command == "list"
|
|
45
|
+
assert args.status == "approved" and args.limit == 5
|
|
46
|
+
|
|
47
|
+
args, _ = _parse("guard", "approvals", "approve", "req-1", "--reason", "ok")
|
|
48
|
+
assert args.approvals_command == "approve" and args.request_id == "req-1" and args.reason == "ok"
|
|
49
|
+
|
|
50
|
+
args, _ = _parse("guard", "approvals", "reject", "req-2")
|
|
51
|
+
assert args.approvals_command == "reject" and args.reason is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_list_renders_rows(capsys):
|
|
55
|
+
import conduct_cli.guard as g
|
|
56
|
+
resp = {"items": [
|
|
57
|
+
{"id":"req-1","requester_email":"a@x.io","rule_id":"r.one","rule_message":"m1","status":"pending"},
|
|
58
|
+
{"id":"req-2","requester_email":"b@x.io","rule_id":"r.two","rule_message":"m2","status":"approved"},
|
|
59
|
+
]}
|
|
60
|
+
args, guard_p = _parse("guard", "approvals", "list")
|
|
61
|
+
with patch.object(g, "_req", return_value=resp) as mock:
|
|
62
|
+
g.dispatch_guard(args, guard_p)
|
|
63
|
+
call_url = mock.call_args.args[1]
|
|
64
|
+
assert "/guard/approvals" in call_url and "status=pending" in call_url and "workspace_id=ws-abc-123" in call_url
|
|
65
|
+
out = capsys.readouterr().out
|
|
66
|
+
assert "req-1" in out and "req-2" in out
|
|
67
|
+
assert "pending" in out and "approved" in out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_list_empty_state(capsys):
|
|
71
|
+
import conduct_cli.guard as g
|
|
72
|
+
args, guard_p = _parse("guard", "approvals", "list", "--status", "all")
|
|
73
|
+
with patch.object(g, "_req", return_value={"items": []}):
|
|
74
|
+
g.dispatch_guard(args, guard_p)
|
|
75
|
+
out = capsys.readouterr().out
|
|
76
|
+
assert "No approvals" in out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_approve_sends_correct_body_and_url(capsys):
|
|
80
|
+
import conduct_cli.guard as g
|
|
81
|
+
decision_resp = {
|
|
82
|
+
"request": {"rule_id":"r.x","decided_by_email":"me@x.io","latency_ms":42,"status":"approved"},
|
|
83
|
+
"run_resumed": True,
|
|
84
|
+
}
|
|
85
|
+
args, guard_p = _parse("guard", "approvals", "approve", "req-9", "--reason", "looks ok")
|
|
86
|
+
with patch.object(g, "_req", return_value=decision_resp) as mock:
|
|
87
|
+
g.dispatch_guard(args, guard_p)
|
|
88
|
+
method, url = mock.call_args.args[0], mock.call_args.args[1]
|
|
89
|
+
body = mock.call_args.kwargs["body"]
|
|
90
|
+
assert method == "POST"
|
|
91
|
+
assert "/guard/approvals/req-9/decide" in url and "workspace_id=ws-abc-123" in url
|
|
92
|
+
assert body == {"decision": "approved", "reason": "looks ok"}
|
|
93
|
+
out = capsys.readouterr().out
|
|
94
|
+
assert "APPROVED" in out and "run_resumed=True" in out and "42ms" in out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_reject_without_reason_omits_field(capsys):
|
|
98
|
+
import conduct_cli.guard as g
|
|
99
|
+
decision_resp = {
|
|
100
|
+
"request": {"rule_id":"r.x","decided_by_email":"me@x.io","latency_ms":10,"status":"rejected"},
|
|
101
|
+
"run_resumed": False,
|
|
102
|
+
}
|
|
103
|
+
args, guard_p = _parse("guard", "approvals", "reject", "req-9")
|
|
104
|
+
with patch.object(g, "_req", return_value=decision_resp) as mock:
|
|
105
|
+
g.dispatch_guard(args, guard_p)
|
|
106
|
+
body = mock.call_args.kwargs["body"]
|
|
107
|
+
assert body == {"decision": "rejected"} # no reason key when None
|
|
108
|
+
out = capsys.readouterr().out
|
|
109
|
+
assert "REJECTED" in out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_bare_approvals_prints_help(capsys):
|
|
113
|
+
import conduct_cli.guard as g
|
|
114
|
+
args, guard_p = _parse("guard", "approvals")
|
|
115
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
116
|
+
g.dispatch_guard(args, guard_p)
|
|
117
|
+
assert excinfo.value.code == 1
|
|
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
|
|
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
|