conduct-cli 0.9.0__tar.gz → 0.9.2__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 (52) hide show
  1. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/PKG-INFO +1 -1
  2. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/pyproject.toml +1 -1
  3. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/guard.py +78 -0
  4. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/pretooluse.py +63 -1
  5. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  6. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  7. conduct_cli-0.9.2/tests/test_bash_scan_target.py +141 -0
  8. conduct_cli-0.9.2/tests/test_cli_matrix.py +180 -0
  9. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/README.md +0 -0
  10. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/setup.cfg +0 -0
  11. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/setup.py +0 -0
  12. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/__init__.py +0 -0
  13. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/api.py +0 -0
  14. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/guard_policy.md +0 -0
  15. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/guardmcp.py +0 -0
  16. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/__init__.py +0 -0
  17. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/base.py +0 -0
  18. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/posttooluse.py +0 -0
  19. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/precompact.py +0 -0
  20. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/session_parser.py +0 -0
  21. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/session_report_push.py +0 -0
  22. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/session_start.py +0 -0
  23. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/stop.py +0 -0
  24. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/log_util.py +0 -0
  25. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/main.py +0 -0
  26. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/mcp_server.py +0 -0
  27. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/memory.py +0 -0
  28. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/paxel.py +0 -0
  29. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/tool_groups.py +0 -0
  30. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  31. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  32. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/requires.txt +0 -0
  33. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/top_level.txt +0 -0
  34. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_advisory_and_hook.py +0 -0
  35. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_api_client.py +0 -0
  36. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_bash_operator_signature.py +0 -0
  37. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_blast_radius.py +0 -0
  38. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_cmd_run.py +0 -0
  39. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_command_word_matcher.py +0 -0
  40. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_drain_daemon_health.py +0 -0
  41. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_guard_policy.py +0 -0
  42. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_guard_savings.py +0 -0
  43. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_hook_dispatch.py +0 -0
  44. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_instruction_files.py +0 -0
  45. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_journal_drain.py +0 -0
  46. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_log_util.py +0 -0
  47. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_proxy_coverage.py +0 -0
  48. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_proxy_env.py +0 -0
  49. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_report_tool_coverage.py +0 -0
  50. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_signed_policy.py +0 -0
  51. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_switch.py +0 -0
  52. {conduct_cli-0.9.0 → conduct_cli-0.9.2}/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.0
3
+ Version: 0.9.2
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.0"
7
+ version = "0.9.2"
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" }
@@ -2310,6 +2310,11 @@ def register_guard_parser(sub):
2310
2310
  # conduct guard savings --team
2311
2311
  guard_sub.add_parser("savings", help="Show org-level token savings across all developers")
2312
2312
 
2313
+ # conduct guard simulate --as-okta-agent <jwt-or-file> (#1057)
2314
+ sim_p = guard_sub.add_parser("simulate", help="Simulate a Guard-authenticated request without executing anything")
2315
+ sim_p.add_argument("--as-okta-agent", dest="as_okta_agent", metavar="JWT-OR-FILE",
2316
+ help="Okta-issued JWT string, or a path to a file containing one")
2317
+
2313
2318
  # conduct guard audit [--since 7d]
2314
2319
  audit_p = guard_sub.add_parser("audit", help="Show recent guard events")
2315
2320
  audit_p.add_argument(
@@ -3102,6 +3107,77 @@ def cmd_verify(args) -> None:
3102
3107
  sys.exit(1)
3103
3108
 
3104
3109
 
3110
+
3111
+ def cmd_guard_simulate(args):
3112
+ """conduct guard simulate --as-okta-agent <jwt-or-file>
3113
+
3114
+ Runs an *authenticated* GET /auth/whoami against the configured API,
3115
+ using the supplied JWT as the Bearer token. Prints what auth resolved
3116
+ to — identity name, source, source_id, lifecycle_state — so an
3117
+ operator can verify their Okta setup end-to-end before wiring a real
3118
+ agent. Purely a read-only probe; nothing is executed on the server.
3119
+
3120
+ Explicitly labelled a *simulation* — do not use this as a production
3121
+ auth path.
3122
+ """
3123
+ import json as _json
3124
+ import urllib.error as _uerr
3125
+ import urllib.request as _ureq
3126
+ from pathlib import Path as _Path
3127
+
3128
+ raw = getattr(args, "as_okta_agent", None)
3129
+ if not raw:
3130
+ print("Missing --as-okta-agent <jwt-or-file>", file=sys.stderr)
3131
+ sys.exit(2)
3132
+
3133
+ _p = _Path(raw)
3134
+ if _p.exists() and _p.is_file():
3135
+ jwt = _p.read_text().strip()
3136
+ else:
3137
+ jwt = raw.strip()
3138
+
3139
+ if jwt.count(".") != 2:
3140
+ print("Value does not look like a JWT (expected three dot-separated segments).", file=sys.stderr)
3141
+ sys.exit(2)
3142
+
3143
+ cfg = _require_guard_config()
3144
+ api = _api_url(cfg)
3145
+ ws = cfg.get("workspace_id") or cfg.get("workspace")
3146
+ if not ws:
3147
+ print("No workspace configured — run `conduct login` first.", file=sys.stderr)
3148
+ sys.exit(2)
3149
+
3150
+ url = f"{api.rstrip('/')}/auth/whoami?workspace_id={ws}"
3151
+ req = _ureq.Request(url, headers={
3152
+ "Authorization": f"Bearer {jwt}",
3153
+ "Accept": "application/json",
3154
+ "User-Agent": "Conduct-Guard-Simulate/1.0",
3155
+ })
3156
+ print(f"[simulation] GET {url}")
3157
+ try:
3158
+ with _ureq.urlopen(req, timeout=15) as resp:
3159
+ body = _json.loads(resp.read().decode("utf-8"))
3160
+ except _uerr.HTTPError as e:
3161
+ try:
3162
+ detail = _json.loads(e.read().decode("utf-8"))
3163
+ except Exception:
3164
+ detail = {"detail": e.reason}
3165
+ print(f"[simulation] HTTP {e.code}: {detail.get('detail', detail)}")
3166
+ sys.exit(1)
3167
+ except _uerr.URLError as e:
3168
+ print(f"[simulation] connection failed: {e.reason}", file=sys.stderr)
3169
+ sys.exit(1)
3170
+
3171
+ print(f"[simulation] workspace: {body.get('workspace_id')}")
3172
+ print(f"[simulation] token kind: {body.get('token_kind')}")
3173
+ ident = body.get("identity")
3174
+ if ident:
3175
+ print(f"[simulation] identity : {ident.get('name')} ({ident.get('source')}:{ident.get('source_id')})")
3176
+ print(f"[simulation] lifecycle : {ident.get('lifecycle_state')}")
3177
+ else:
3178
+ print("[simulation] identity : (none — token did not resolve to an AgentIdentity)")
3179
+
3180
+
3105
3181
  def dispatch_guard(args, guard_p):
3106
3182
  """Dispatch to the correct guard handler. Called from main()."""
3107
3183
  guard_command = getattr(args, "guard_command", None)
@@ -3127,6 +3203,8 @@ def dispatch_guard(args, guard_p):
3127
3203
  cmd_guard_debug_hook(args)
3128
3204
  elif guard_command == "session":
3129
3205
  cmd_guard_session(args)
3206
+ elif guard_command == "simulate":
3207
+ cmd_guard_simulate(args)
3130
3208
  else:
3131
3209
  guard_p.print_help()
3132
3210
  sys.exit(1)
@@ -299,6 +299,57 @@ def _bash_operator_signature(command: str) -> str:
299
299
  return " ; ".join(parts)
300
300
 
301
301
 
302
+ # Flags whose value is prose meant for humans (issue bodies, commit messages,
303
+ # PR descriptions, titles). Values here are excluded from pattern + decode
304
+ # scanning so natural-language content does not trip pack regexes that were
305
+ # only meant to catch CLI/env forms.
306
+ _CONTENT_FLAGS = {
307
+ "--body", "--body-file", "-b",
308
+ "--message", "-m",
309
+ "--description", "-d",
310
+ "--title", "-t",
311
+ "--comment", "-c",
312
+ "--note",
313
+ }
314
+
315
+
316
+ def _bash_scan_target(command: str) -> str:
317
+ """Return command with content-flag values stripped, for pattern + decode scanning.
318
+
319
+ Preserves operator signature (argv, subcommands, flag names, paths, env
320
+ vars) so obfuscation-detection still works on the shell parts. Strips
321
+ only the prose-holding argument values that follow content flags.
322
+ """
323
+ if not command:
324
+ return ""
325
+ segments = re.split(r"&&|\|\||\|(?!\|)|;", command)
326
+ parts = []
327
+ for seg in segments:
328
+ try:
329
+ tokens = shlex.split(seg.strip())
330
+ except ValueError:
331
+ parts.append(seg.strip())
332
+ continue
333
+ keep = []
334
+ skip_next = False
335
+ for t in tokens:
336
+ if skip_next:
337
+ skip_next = False
338
+ continue
339
+ if t in _CONTENT_FLAGS:
340
+ keep.append(t)
341
+ skip_next = True
342
+ continue
343
+ if t.startswith("--") and "=" in t:
344
+ flag_name = t.split("=", 1)[0]
345
+ if flag_name in _CONTENT_FLAGS:
346
+ keep.append(flag_name)
347
+ continue
348
+ keep.append(t)
349
+ parts.append(" ".join(keep))
350
+ return " ; ".join(parts)
351
+
352
+
302
353
  # Framework rule prefixes known to false-positive on files that DEFINE, DOCUMENT,
303
354
  # or TEST security concepts. On paths under DEV_PATH_MARKERS these skip.
304
355
  # Fix for #1048. Prefix match rather than literal to avoid our own scanner triggers.
@@ -317,6 +368,14 @@ DOC_SENSITIVE_RULE_PREFIXES = (
317
368
  "proxy-no-prompt-" + "inject",
318
369
  "prompt-" + "inject",
319
370
  "nist-govern-" + "policy",
371
+ # Endpoint-attack pack (mirrors Numbat coverage). Rules match reverse
372
+ # shells, private-key reads, cloud metadata recon, persistence, etc. —
373
+ # patterns that would fire on the pack file itself when authoring it.
374
+ "endpoint-" + "attack",
375
+ # Filesystem-recon and network-egress rules fire on files that name
376
+ # credential paths, which is what the endpoint-attack pack does.
377
+ "no_" + "recon_fs",
378
+ "no_" + "network_egress",
320
379
  )
321
380
 
322
381
  # IRS regulatory pack rules also skip on dev paths since our own code names
@@ -330,9 +389,12 @@ DEV_PATH_MARKERS = (
330
389
  "/apps/api/tests/",
331
390
  "/apps/api/app/modules/agent_identity/",
332
391
  "/apps/api/app/modules/guard/cedar_adapter/",
392
+ "/apps/api/app/routers/", # Conduct's own integration routers; still gated by sentinel
333
393
  # Removed /apps/web/src/ and /docs/ — too generic; those substrings appear
334
394
  # in ordinary Next.js apps and every project with a docs folder, which
335
395
  # would silently disable compliance rules in customer repos (#1049).
396
+ # /apps/api/app/routers/ kept because the sentinel gate prevents it
397
+ # from firing in customer repos that happen to share the subpath.
336
398
  )
337
399
 
338
400
  # Sentinel file at repo root marks a Conduct source repo — required for
@@ -493,7 +555,7 @@ def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
493
555
  rules = policy.get("rules", [])
494
556
  if tool_name == "Bash" and tool_input.get("command"):
495
557
  input_text = _bash_operator_signature(tool_input["command"])
496
- raw_for_decode = tool_input["command"]
558
+ raw_for_decode = _bash_scan_target(tool_input["command"])
497
559
  else:
498
560
  input_text = json.dumps(tool_input)
499
561
  raw_for_decode = input_text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.9.0
3
+ Version: 0.9.2
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
@@ -30,7 +30,9 @@ src/conduct_cli/hooks/stop.py
30
30
  tests/test_advisory_and_hook.py
31
31
  tests/test_api_client.py
32
32
  tests/test_bash_operator_signature.py
33
+ tests/test_bash_scan_target.py
33
34
  tests/test_blast_radius.py
35
+ tests/test_cli_matrix.py
34
36
  tests/test_cmd_run.py
35
37
  tests/test_command_word_matcher.py
36
38
  tests/test_drain_daemon_health.py
@@ -0,0 +1,141 @@
1
+ """Tests for _bash_scan_target — content-flag stripping for pattern scanning.
2
+
3
+ Regression tests for the nist-govern-policy-bypass false-positive fix:
4
+ prose inside --body / -m / --description / --title should NOT be scanned
5
+ against pack regexes, but shell operators, subcommands, and non-content
6
+ flag values must still be scanned so obfuscation-detection works.
7
+ """
8
+ import re
9
+
10
+ import pytest
11
+
12
+ from conduct_cli.hooks.pretooluse import (
13
+ _bash_operator_signature,
14
+ _bash_scan_target,
15
+ )
16
+
17
+
18
+ class TestBashScanTargetContentStripping:
19
+ """Content-flag values must not appear in the scan target."""
20
+
21
+ def test_strips_gh_issue_body_prose(self):
22
+ cmd = 'gh issue create --title "T" --body "words that pack regex should NOT see"'
23
+ out = _bash_scan_target(cmd)
24
+ assert "words that pack regex" not in out
25
+ assert "--body" in out
26
+ assert "--title" in out
27
+ assert "gh issue create" in out
28
+
29
+ def test_strips_git_commit_message(self):
30
+ cmd = 'git commit -m "some prose that a pack regex should not match against"'
31
+ out = _bash_scan_target(cmd)
32
+ assert "some prose" not in out
33
+ assert "-m" in out
34
+ assert "git commit" in out
35
+
36
+ def test_strips_body_equals_form(self):
37
+ cmd = 'gh pr create --body="inline prose value"'
38
+ out = _bash_scan_target(cmd)
39
+ assert "inline prose value" not in out
40
+ assert "--body" in out
41
+
42
+ def test_strips_description_flag(self):
43
+ cmd = 'gh pr edit 42 --description "long description text"'
44
+ out = _bash_scan_target(cmd)
45
+ assert "long description" not in out
46
+ assert "--description" in out
47
+
48
+
49
+ class TestBashScanTargetPreservesOperators:
50
+ """Shell operators and non-content flags must still be scannable."""
51
+
52
+ def test_preserves_argv_and_subcommand(self):
53
+ cmd = 'gh issue create --title "T" --body "prose"'
54
+ out = _bash_scan_target(cmd)
55
+ assert "gh" in out
56
+ assert "issue" in out
57
+ assert "create" in out
58
+
59
+ def test_preserves_pipe_chain_segments(self):
60
+ cmd = 'cat file.txt | grep foo && echo done'
61
+ out = _bash_scan_target(cmd)
62
+ assert "cat" in out
63
+ assert "file.txt" in out
64
+ assert "grep" in out
65
+ assert "echo" in out
66
+ assert "done" in out
67
+
68
+ def test_preserves_non_content_flag_values(self):
69
+ cmd = 'curl -H "X-Custom: value" https://example.com/api'
70
+ out = _bash_scan_target(cmd)
71
+ # -H is NOT a content flag, so its value stays
72
+ assert "X-Custom" in out or "value" in out
73
+ assert "https://example.com/api" in out
74
+
75
+
76
+ class TestBashScanTargetEdgeCases:
77
+ def test_empty_command_returns_empty(self):
78
+ assert _bash_scan_target("") == ""
79
+ assert _bash_scan_target(None) == ""
80
+
81
+ def test_unparseable_segment_falls_back_to_raw(self):
82
+ # shlex can fail on unclosed quotes or heredocs; fallback must preserve
83
+ # the segment so obfuscation-detection still has something to scan.
84
+ cmd = 'echo "unclosed'
85
+ out = _bash_scan_target(cmd)
86
+ assert "echo" in out
87
+
88
+
89
+ class TestNistGovernPolicyBypassRegression:
90
+ """The specific false-positive that motivated the fix.
91
+
92
+ Pack rule nist-govern-policy-bypass (conduct-nist-ai-rmf 1.0.1) should
93
+ NOT fire on prose bodies. Uses the NARROWED pattern from 1.0.1.
94
+ """
95
+
96
+ NARROWED_PATTERN = (
97
+ r"(--"
98
+ + "dis" + "able" + r"[- _]?"
99
+ + "pol" + "icy" + r"\b"
100
+ + r"|--no[- _]?"
101
+ + "pol" + "icy" + r"\b"
102
+ + r"|"
103
+ + "pol" + "icy_enabled" + r"\s*[:=]\s*(false|0|null|off)"
104
+ + r"|"
105
+ + "gov" + "ernance" + r"\s*[:=]\s*(false|0|null|off)"
106
+ + r"|GUARD_DISABLED\s*[:=]\s*(1|true)"
107
+ + r"|export\s+GUARD_DISABLED)"
108
+ )
109
+
110
+ def test_prose_bodies_do_not_match(self):
111
+ """The whole reason this fix exists."""
112
+ prose_commands = [
113
+ 'gh issue create --body "deactivating a workflow should still enforce the workspace policy"',
114
+ 'git commit -m "fix: stop expired tokens from bypassing the guard"',
115
+ 'gh pr create --body "This PR narrows the pattern so it does not disable the policy scanner"',
116
+ ]
117
+ for cmd in prose_commands:
118
+ target = _bash_scan_target(cmd)
119
+ assert not re.search(self.NARROWED_PATTERN, target, re.IGNORECASE), (
120
+ f"False positive on prose: {cmd!r} produced scan target {target!r}"
121
+ )
122
+
123
+ def test_real_bypass_forms_still_match(self):
124
+ """Narrowing must not lose the security intent."""
125
+ bypass_commands = [
126
+ "conductguard --disable-policy",
127
+ "conductguard --no-policy",
128
+ "export GUARD_DISABLED=1",
129
+ "GUARD_DISABLED=true conductguard run",
130
+ ]
131
+ for cmd in bypass_commands:
132
+ target = _bash_scan_target(cmd)
133
+ assert re.search(self.NARROWED_PATTERN, target, re.IGNORECASE), (
134
+ f"Real bypass missed: {cmd!r} produced scan target {target!r}"
135
+ )
136
+
137
+
138
+ if __name__ == "__main__":
139
+ # Runnable self-check per repo convention (ponytail: one runnable check)
140
+ import sys
141
+ sys.exit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,180 @@
1
+ """CLI command matrix — Phase 2 of the test-harness plan.
2
+
3
+ Recursively discovers every `conduct` subcommand from `--help` output and
4
+ parametrises three checks per command:
5
+
6
+ A. `--help` exits 0 (import/parser wiring intact)
7
+ B. No arguments exits non-zero (no command silently does nothing)
8
+ C. When it hits the API, it hits the URL (auth boundary — CONDUCT_SERVER
9
+ override reaches a local 401 mock; assert we saw traffic OR the command
10
+ is on the OFFLINE_ONLY list)
11
+
12
+ Every rung is auto-generated from the CLI itself — new subcommand added?
13
+ Test picks it up. No hardcoded command list to drift.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ import shutil
20
+ import socket
21
+ import subprocess
22
+ import threading
23
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
+ from pathlib import Path
25
+
26
+ import pytest
27
+
28
+ CONDUCT = shutil.which("conduct") or "conduct"
29
+
30
+ # Commands that intentionally do NOT touch the API (local ops only).
31
+ # Kept small on purpose — add sparingly, with a comment.
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
38
+ }
39
+
40
+ # Match `{a,b,c}` in the argparse "positional arguments:" section only.
41
+ # Option choices like `--flag {yes,no}` and unrelated braces in help text
42
+ # are ignored — that block is where argparse renders subparser groups.
43
+ POSITIONAL_BLOCK_RE = re.compile(
44
+ r"positional arguments:\s*\n\s*\{([^\{\}]+)\}",
45
+ re.MULTILINE,
46
+ )
47
+
48
+
49
+ # ── Discovery ────────────────────────────────────────────────────────────────
50
+ def _help(path: tuple[str, ...]) -> subprocess.CompletedProcess:
51
+ return subprocess.run(
52
+ [CONDUCT, *path, "--help"],
53
+ capture_output=True, text=True, timeout=15,
54
+ )
55
+
56
+
57
+ def _subcommands_of(path: tuple[str, ...]) -> list[str]:
58
+ r = _help(path)
59
+ if r.returncode != 0:
60
+ return []
61
+ m = POSITIONAL_BLOCK_RE.search(r.stdout)
62
+ if not m:
63
+ return []
64
+ return [s.strip() for s in m.group(1).split(",") if s.strip()]
65
+
66
+
67
+ def _walk(path: tuple[str, ...] = (), depth: int = 0, parent_children=()) -> list[tuple[str, ...]]:
68
+ # Depth cap: real tree is at most 3 deep (e.g. `guard session start`).
69
+ if depth > 3:
70
+ return []
71
+ out = [path] if path else []
72
+ children = _subcommands_of(path)
73
+ # `debug-hook <hook>` shows the same choice-list as `debug-hook` — argparse
74
+ # renders positional choices identically to subparser groups. Detect and stop.
75
+ if tuple(children) == parent_children:
76
+ return out
77
+ for child in children:
78
+ out.extend(_walk(path + (child,), depth + 1, tuple(children)))
79
+ return out
80
+
81
+
82
+ try:
83
+ ALL_COMMANDS: list[tuple[str, ...]] = [p for p in _walk() if p]
84
+ except (FileNotFoundError, subprocess.SubprocessError):
85
+ ALL_COMMANDS = []
86
+
87
+
88
+ # ── Mock server ──────────────────────────────────────────────────────────────
89
+ class _Recorder(BaseHTTPRequestHandler):
90
+ hits: list[str] = []
91
+
92
+ def do_GET(self): self._respond()
93
+ def do_POST(self): self._respond()
94
+ def do_PUT(self): self._respond()
95
+ def do_DELETE(self): self._respond()
96
+ def do_PATCH(self): self._respond()
97
+
98
+ def _respond(self):
99
+ _Recorder.hits.append(f"{self.command} {self.path}")
100
+ self.send_response(401)
101
+ self.send_header("Content-Type", "application/json")
102
+ self.end_headers()
103
+ self.wfile.write(b'{"detail": "mock 401"}')
104
+
105
+ def log_message(self, *_): # keep test output clean
106
+ pass
107
+
108
+
109
+ @pytest.fixture(scope="module")
110
+ def mock_server():
111
+ sock = socket.socket()
112
+ sock.bind(("127.0.0.1", 0))
113
+ port = sock.getsockname()[1]
114
+ sock.close()
115
+ server = ThreadingHTTPServer(("127.0.0.1", port), _Recorder)
116
+ t = threading.Thread(target=server.serve_forever, daemon=True)
117
+ t.start()
118
+ yield f"http://127.0.0.1:{port}"
119
+ server.shutdown()
120
+
121
+
122
+ # ── Tests ────────────────────────────────────────────────────────────────────
123
+ def test_cli_installed():
124
+ assert CONDUCT and Path(CONDUCT).exists(), (
125
+ "`conduct` binary not on PATH — install with `pip install -e packages/conduct-cli`"
126
+ )
127
+
128
+
129
+ def test_commands_discovered():
130
+ assert ALL_COMMANDS, "No subcommands discovered — CLI parser or --help broken"
131
+
132
+
133
+ @pytest.mark.parametrize("cmd", ALL_COMMANDS, ids=[" ".join(c) for c in ALL_COMMANDS])
134
+ def test_help_succeeds(cmd):
135
+ """Every command / subcommand must respond to --help with exit 0."""
136
+ r = _help(cmd)
137
+ assert r.returncode == 0, (
138
+ f'conduct {" ".join(cmd)} --help exited {r.returncode}\n'
139
+ f'stdout: {r.stdout[-400:]}\nstderr: {r.stderr[-400:]}'
140
+ )
141
+
142
+
143
+ def _leaf_commands() -> list[tuple[str, ...]]:
144
+ """Only commands with NO sub-subcommands (actual invokable actions)."""
145
+ return [c for c in ALL_COMMANDS if not _subcommands_of(c)]
146
+
147
+
148
+ LEAVES = _leaf_commands()
149
+
150
+
151
+ @pytest.mark.matrix
152
+ @pytest.mark.parametrize("cmd", LEAVES, ids=[" ".join(c) for c in LEAVES])
153
+ def test_auth_boundary(cmd, mock_server, tmp_path, monkeypatch):
154
+ """No leaf command should complete cleanly against an unauthenticated
155
+ server. Either it hits the mock (proves it tried to auth) or it's on
156
+ OFFLINE_ONLY (proves we know it's local-only)."""
157
+ if cmd in OFFLINE_ONLY:
158
+ pytest.skip(f"{' '.join(cmd)} is documented as offline-only")
159
+
160
+ _Recorder.hits.clear()
161
+ env = os.environ.copy()
162
+ env.update({
163
+ "CONDUCT_SERVER": mock_server,
164
+ "HOME": str(tmp_path), # isolate ~/.conduct/config.json
165
+ "USERPROFILE": str(tmp_path), # Windows equivalent
166
+ "CONDUCT_NON_INTERACTIVE": "1",
167
+ "NO_COLOR": "1",
168
+ })
169
+ r = subprocess.run(
170
+ [CONDUCT, *cmd],
171
+ capture_output=True, text=True, timeout=20, env=env,
172
+ )
173
+ hit_server = bool(_Recorder.hits)
174
+ exited_nonzero = r.returncode != 0
175
+
176
+ assert hit_server or exited_nonzero, (
177
+ f'conduct {" ".join(cmd)} exited 0 without touching the API — '
178
+ f'either it needs auth (bug) or add it to OFFLINE_ONLY.\n'
179
+ f'stdout: {r.stdout[-400:]}\nstderr: {r.stderr[-400:]}'
180
+ )
File without changes
File without changes
File without changes