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.
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/PKG-INFO +1 -1
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/pyproject.toml +1 -1
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/guard.py +78 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/pretooluse.py +63 -1
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- conduct_cli-0.9.2/tests/test_bash_scan_target.py +141 -0
- conduct_cli-0.9.2/tests/test_cli_matrix.py +180 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/README.md +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/setup.cfg +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/setup.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/guard_policy.md +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/base.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_api_client.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_cmd_run.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_log_util.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_report_tool_coverage.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/tests/test_switch.py +0 -0
- {conduct_cli-0.9.0 → conduct_cli-0.9.2}/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.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
|
|
@@ -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
|
|
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
|