conduct-cli 0.9.3__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.
Files changed (54) hide show
  1. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/PKG-INFO +1 -1
  2. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/pyproject.toml +1 -1
  3. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/guard.py +71 -0
  4. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  5. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
  6. conduct_cli-0.9.4/tests/test_guard_approvals.py +117 -0
  7. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/README.md +0 -0
  8. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/setup.cfg +0 -0
  9. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/setup.py +0 -0
  10. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/__init__.py +0 -0
  11. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/api.py +0 -0
  12. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/guard_policy.md +0 -0
  13. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/guardmcp.py +0 -0
  14. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/hooks/__init__.py +0 -0
  15. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/hooks/base.py +0 -0
  16. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/hooks/posttooluse.py +0 -0
  17. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/hooks/precompact.py +0 -0
  18. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/hooks/pretooluse.py +0 -0
  19. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/hooks/session_parser.py +0 -0
  20. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/hooks/session_report_push.py +0 -0
  21. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/hooks/session_start.py +0 -0
  22. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/hooks/stop.py +0 -0
  23. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/log_util.py +0 -0
  24. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/main.py +0 -0
  25. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/mcp_server.py +0 -0
  26. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/memory.py +0 -0
  27. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/paxel.py +0 -0
  28. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli/tool_groups.py +0 -0
  29. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  30. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  31. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/requires.txt +0 -0
  32. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/src/conduct_cli.egg-info/top_level.txt +0 -0
  33. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_advisory_and_hook.py +0 -0
  34. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_api_client.py +0 -0
  35. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_bash_operator_signature.py +0 -0
  36. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_bash_scan_target.py +0 -0
  37. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_blast_radius.py +0 -0
  38. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_cli_matrix.py +0 -0
  39. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_cmd_run.py +0 -0
  40. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_command_word_matcher.py +0 -0
  41. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_conductguard_mcp_stdio.py +0 -0
  42. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_drain_daemon_health.py +0 -0
  43. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_guard_policy.py +0 -0
  44. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_guard_savings.py +0 -0
  45. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_hook_dispatch.py +0 -0
  46. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_instruction_files.py +0 -0
  47. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_journal_drain.py +0 -0
  48. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_log_util.py +0 -0
  49. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_proxy_coverage.py +0 -0
  50. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_proxy_env.py +0 -0
  51. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_report_tool_coverage.py +0 -0
  52. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_signed_policy.py +0 -0
  53. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_switch.py +0 -0
  54. {conduct_cli-0.9.3 → conduct_cli-0.9.4}/tests/test_token_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.9.3
3
+ Version: 0.9.4
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.9.3"
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" }
@@ -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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.9.3
3
+ Version: 0.9.4
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
@@ -37,6 +37,7 @@ tests/test_cmd_run.py
37
37
  tests/test_command_word_matcher.py
38
38
  tests/test_conductguard_mcp_stdio.py
39
39
  tests/test_drain_daemon_health.py
40
+ tests/test_guard_approvals.py
40
41
  tests/test_guard_policy.py
41
42
  tests/test_guard_savings.py
42
43
  tests/test_hook_dispatch.py
@@ -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