conduct-cli 0.9.2__tar.gz → 0.9.3__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 (53) hide show
  1. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/PKG-INFO +1 -1
  2. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/pyproject.toml +1 -1
  3. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/guard.py +2 -2
  4. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/hooks/pretooluse.py +99 -2
  5. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  6. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
  7. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_cli_matrix.py +18 -5
  8. conduct_cli-0.9.3/tests/test_conductguard_mcp_stdio.py +68 -0
  9. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/README.md +0 -0
  10. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/setup.cfg +0 -0
  11. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/setup.py +0 -0
  12. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/__init__.py +0 -0
  13. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/api.py +0 -0
  14. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/guard_policy.md +0 -0
  15. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/guardmcp.py +0 -0
  16. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/hooks/__init__.py +0 -0
  17. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/hooks/base.py +0 -0
  18. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/hooks/posttooluse.py +0 -0
  19. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/hooks/precompact.py +0 -0
  20. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/hooks/session_parser.py +0 -0
  21. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/hooks/session_report_push.py +0 -0
  22. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/hooks/session_start.py +0 -0
  23. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/hooks/stop.py +0 -0
  24. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/log_util.py +0 -0
  25. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/main.py +0 -0
  26. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/mcp_server.py +0 -0
  27. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/memory.py +0 -0
  28. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/paxel.py +0 -0
  29. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli/tool_groups.py +0 -0
  30. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  31. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  32. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli.egg-info/requires.txt +0 -0
  33. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/src/conduct_cli.egg-info/top_level.txt +0 -0
  34. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_advisory_and_hook.py +0 -0
  35. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_api_client.py +0 -0
  36. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_bash_operator_signature.py +0 -0
  37. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_bash_scan_target.py +0 -0
  38. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_blast_radius.py +0 -0
  39. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_cmd_run.py +0 -0
  40. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_command_word_matcher.py +0 -0
  41. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_drain_daemon_health.py +0 -0
  42. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_guard_policy.py +0 -0
  43. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_guard_savings.py +0 -0
  44. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_hook_dispatch.py +0 -0
  45. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_instruction_files.py +0 -0
  46. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_journal_drain.py +0 -0
  47. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_log_util.py +0 -0
  48. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_proxy_coverage.py +0 -0
  49. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_proxy_env.py +0 -0
  50. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_report_tool_coverage.py +0 -0
  51. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_signed_policy.py +0 -0
  52. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/tests/test_switch.py +0 -0
  53. {conduct_cli-0.9.2 → conduct_cli-0.9.3}/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.2
3
+ Version: 0.9.3
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.2"
7
+ version = "0.9.3"
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(0)
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(0)
359
+ sys.exit(1)
360
360
  return cfg
361
361
 
362
362
 
@@ -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
- decision = {"block": "blocked", "warn": "warned", "approval": "blocked"}.get(action, "allowed")
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 in ("warn", "approval"):
847
+ if action == "warn":
751
848
  print(f"[ConductGuard] {message}")
752
849
 
753
850
  sys.exit(0)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.9.2
3
+ Version: 0.9.3
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
@@ -35,6 +35,7 @@ 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
39
40
  tests/test_guard_policy.py
40
41
  tests/test_guard_savings.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",), # 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
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"]))
File without changes
File without changes
File without changes