conduct-cli 0.11.0__tar.gz → 0.13.0__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.11.0 → conduct_cli-0.13.0}/PKG-INFO +1 -1
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/pyproject.toml +1 -1
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/guard.py +2 -2
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/hooks/base.py +8 -1
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/hooks/posttooluse.py +1 -135
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/main.py +9 -404
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_cli_matrix.py +0 -1
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_token_lifecycle.py +3 -3
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/LICENSE +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/README.md +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/setup.cfg +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/setup.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/guard_policy.md +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/hooks/pretooluse.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_api_client.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_bash_scan_target.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_cmd_run.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_guard_approvals.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_hook_receipt.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_log_util.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_pretooluse_approval_e2e.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_pretooluse_check_policy.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_report_tool_coverage.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.11.0 → conduct_cli-0.13.0}/tests/test_switch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conduct-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
|
|
5
5
|
Author-email: Conduct AI <hello@conductai.ai>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.13.0"
|
|
8
8
|
description = "Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
@@ -498,7 +498,7 @@ def _patch_copilot_mcp(agent_token: str, api_url: str) -> None:
|
|
|
498
498
|
import shutil
|
|
499
499
|
sse_entry = {
|
|
500
500
|
"type": "http",
|
|
501
|
-
"url": f"{api_url}/
|
|
501
|
+
"url": f"{api_url}/mcp",
|
|
502
502
|
"headers": {"Authorization": f"Bearer {agent_token}"},
|
|
503
503
|
}
|
|
504
504
|
booster_entry = {"command": "booster", "args": ["serve"]} if shutil.which("booster") else None
|
|
@@ -1618,7 +1618,7 @@ def cmd_guard_sync(args):
|
|
|
1618
1618
|
# Print remote MCP URL for any MCP-compatible client
|
|
1619
1619
|
agent_token = cfg.get("agent_token", "")
|
|
1620
1620
|
if workspace_id and agent_token:
|
|
1621
|
-
mcp_url = "https://api.conductai.ai/
|
|
1621
|
+
mcp_url = "https://api.conductai.ai/mcp"
|
|
1622
1622
|
masked = agent_token[:13] + "•" * 20
|
|
1623
1623
|
print(f"\n{BOLD}MCP server{RESET} (Claude.ai, Copilot, Cursor, Windsurf, any MCP client):")
|
|
1624
1624
|
print(f" URL: {CYAN}{mcp_url}{RESET}")
|
|
@@ -394,7 +394,14 @@ def hook_receipt_url(receipt_id: str) -> Optional[str]:
|
|
|
394
394
|
"""Build the receipt URL for a hook-side pre-minted block.
|
|
395
395
|
|
|
396
396
|
Returns None if no workspace/config is set. Uses `web_url` from config
|
|
397
|
-
when present (self-host), otherwise derives from `api_url`.
|
|
397
|
+
when present (self-host), otherwise derives from `api_url`.
|
|
398
|
+
|
|
399
|
+
Path shape mirrors ``apps/api/app/guard/receipts.py::build_receipt_url``.
|
|
400
|
+
Cross-package because the CLI hook mints its own receipt id client-side
|
|
401
|
+
and prints the URL to stderr BEFORE the audit row is written (backend
|
|
402
|
+
writes the row asynchronously after we return). If you change the URL
|
|
403
|
+
shape here, change it there too — no shared code, both are the
|
|
404
|
+
contract."""
|
|
398
405
|
cfg = load_config()
|
|
399
406
|
if not cfg.get("workspace_id"):
|
|
400
407
|
return None
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""ConductGuard PostToolUse hook — token tracking
|
|
1
|
+
"""ConductGuard PostToolUse hook — token tracking."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
@@ -20,139 +20,6 @@ from conduct_cli.hooks.pretooluse import (
|
|
|
20
20
|
check_policy,
|
|
21
21
|
)
|
|
22
22
|
|
|
23
|
-
# ── Security classifier ───────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
SECRET_PATTERNS = [
|
|
26
|
-
(r"sk-[A-Za-z0-9]{20,}", "secret-leak", "high", "Potential OpenAI/Anthropic API key"),
|
|
27
|
-
(r"ghp_[A-Za-z0-9]{36}", "secret-leak", "high", "GitHub Personal Access Token"),
|
|
28
|
-
(r"AKIA[0-9A-Z]{16}", "secret-leak", "critical", "AWS Access Key ID"),
|
|
29
|
-
(r"Bearer\s+[A-Za-z0-9+/=]{20,}", "secret-leak", "high", "Bearer token in output"),
|
|
30
|
-
(r"""password\s*=\s*['"][^'"]{4,}""", "secret-leak", "high", "Hardcoded password"),
|
|
31
|
-
(r"""api[_-]?key\s*=\s*['"][^'"]{4,}""", "secret-leak", "high", "Hardcoded API key"),
|
|
32
|
-
(r"\.\./\.\./\.\./", "path-traversal", "medium", "Path traversal sequence"),
|
|
33
|
-
(r"file://", "path-traversal", "medium", "File URI scheme in output"),
|
|
34
|
-
(r"eval\s*\(", "injection", "high", "eval() in output"),
|
|
35
|
-
(r"exec\s*\(", "injection", "high", "exec() in output"),
|
|
36
|
-
(r"__import__\s*\(", "injection", "high", "__import__() in output"),
|
|
37
|
-
(r"ssl\.CERT_NONE", "crypto", "high", "SSL verification disabled"),
|
|
38
|
-
(r"verify\s*=\s*False", "crypto", "medium", "TLS verification bypassed"),
|
|
39
|
-
]
|
|
40
|
-
OWASP_KEYWORDS = [
|
|
41
|
-
("sql injection", "injection", "high", "SQL injection mentioned in AI output"),
|
|
42
|
-
("cross-site scripting", "injection", "high", "XSS mentioned in AI output"),
|
|
43
|
-
(" xss ", "injection", "high", "XSS mentioned in AI output"),
|
|
44
|
-
("idor", "injection", "medium", "IDOR mentioned in AI output"),
|
|
45
|
-
("ssrf", "injection", "high", "SSRF mentioned in AI output"),
|
|
46
|
-
("command injection", "injection", "high", "Command injection mentioned in AI output"),
|
|
47
|
-
("auth bypass", "auth-bypass", "high", "Auth bypass mentioned in AI output"),
|
|
48
|
-
]
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _classify_text(text: str):
|
|
52
|
-
import re as _re
|
|
53
|
-
for pattern, ftype, sev, desc in SECRET_PATTERNS:
|
|
54
|
-
if _re.search(pattern, text, _re.IGNORECASE):
|
|
55
|
-
return ftype, sev, desc, pattern
|
|
56
|
-
lower = text.lower()
|
|
57
|
-
for kw, ftype, sev, desc in OWASP_KEYWORDS:
|
|
58
|
-
if kw in lower:
|
|
59
|
-
return ftype, sev, desc, kw
|
|
60
|
-
return None, None, None, None
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _line_number_from_text(text: str, matched_pattern: str):
|
|
64
|
-
import re as _re
|
|
65
|
-
if not matched_pattern:
|
|
66
|
-
return None
|
|
67
|
-
try:
|
|
68
|
-
for raw_line in text.splitlines():
|
|
69
|
-
m = _re.match(r"^\s*(\d+)\t(.*)$", raw_line)
|
|
70
|
-
if m:
|
|
71
|
-
lineno, content = int(m.group(1)), m.group(2)
|
|
72
|
-
try:
|
|
73
|
-
if _re.search(matched_pattern, content, _re.IGNORECASE):
|
|
74
|
-
return lineno
|
|
75
|
-
except Exception:
|
|
76
|
-
if matched_pattern.lower() in content.lower():
|
|
77
|
-
return lineno
|
|
78
|
-
m = _re.search(matched_pattern, text, _re.IGNORECASE)
|
|
79
|
-
if m:
|
|
80
|
-
return text[:m.start()].count(chr(10)) + 1
|
|
81
|
-
except Exception:
|
|
82
|
-
pass
|
|
83
|
-
return None
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _maybe_emit_security_finding(tool_response, session_id, tool_name, tool_input=None) -> None:
|
|
87
|
-
"""Classify tool output + input for security findings; POST if flag ON. Never raises."""
|
|
88
|
-
try:
|
|
89
|
-
cfg = load_config()
|
|
90
|
-
except Exception:
|
|
91
|
-
return
|
|
92
|
-
if not cfg.get("security_emit_enabled", False):
|
|
93
|
-
return
|
|
94
|
-
workspace_id = cfg.get("workspace_id")
|
|
95
|
-
agent_token = cfg.get("agent_token", "")
|
|
96
|
-
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
97
|
-
if not workspace_id:
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
ti = tool_input or {}
|
|
101
|
-
candidates = [("response", str(tool_response))]
|
|
102
|
-
if tool_name in ("edit", "multiedit"):
|
|
103
|
-
candidates.append(("input", ti.get("new_string", "")))
|
|
104
|
-
elif tool_name == "write":
|
|
105
|
-
candidates.append(("input", ti.get("content", "")))
|
|
106
|
-
elif tool_name in ("bash", "terminal"):
|
|
107
|
-
candidates.append(("input", ti.get("command", "")))
|
|
108
|
-
|
|
109
|
-
finding_type = severity = description = matched_pattern = scan_text = None
|
|
110
|
-
for _src, text in candidates:
|
|
111
|
-
ft, sv, desc, pat = _classify_text(text)
|
|
112
|
-
if ft:
|
|
113
|
-
finding_type, severity, description, matched_pattern, scan_text = ft, sv, desc, pat, text
|
|
114
|
-
break
|
|
115
|
-
|
|
116
|
-
if not finding_type:
|
|
117
|
-
return
|
|
118
|
-
|
|
119
|
-
file_path = (
|
|
120
|
-
ti.get("file_path") or ti.get("path") or
|
|
121
|
-
(ti.get("command", "")[:120] if tool_name in ("bash", "terminal") else None)
|
|
122
|
-
) or None
|
|
123
|
-
|
|
124
|
-
line_no = _line_number_from_text(scan_text, matched_pattern) if scan_text else None
|
|
125
|
-
|
|
126
|
-
payload = json.dumps({
|
|
127
|
-
"tool": detect_ai_tool(),
|
|
128
|
-
"severity": severity,
|
|
129
|
-
"type": finding_type,
|
|
130
|
-
"description": description,
|
|
131
|
-
"source_run_id": session_id,
|
|
132
|
-
"reporter_email": cfg.get("user_email") or "",
|
|
133
|
-
"file": file_path,
|
|
134
|
-
"line": line_no,
|
|
135
|
-
})
|
|
136
|
-
script = (
|
|
137
|
-
"import urllib.request\n"
|
|
138
|
-
"try:\n"
|
|
139
|
-
f" req = urllib.request.Request(\"{api_url}/security-findings?workspace_id={workspace_id}\","
|
|
140
|
-
f" data={repr(payload.encode())}, headers={{\"Content-Type\": \"application/json\","
|
|
141
|
-
f" \"Authorization\": \"Bearer {agent_token}\"}}, method=\"POST\")\n"
|
|
142
|
-
" urllib.request.urlopen(req, timeout=5)\n"
|
|
143
|
-
"except: pass\n"
|
|
144
|
-
)
|
|
145
|
-
try:
|
|
146
|
-
import subprocess as _sp
|
|
147
|
-
_sp.Popen(
|
|
148
|
-
[sys.executable, "-c", script],
|
|
149
|
-
stdout=_sp.DEVNULL,
|
|
150
|
-
stderr=_sp.DEVNULL,
|
|
151
|
-
start_new_session=True,
|
|
152
|
-
)
|
|
153
|
-
except Exception:
|
|
154
|
-
pass
|
|
155
|
-
|
|
156
23
|
|
|
157
24
|
# ── Token reading ─────────────────────────────────────────────────────────────
|
|
158
25
|
|
|
@@ -442,7 +309,6 @@ def main() -> None:
|
|
|
442
309
|
if action == "warn" and session_id and rule_id:
|
|
443
310
|
_record_session_warn(session_id, rule_id)
|
|
444
311
|
post_event(tool_name, {}, decision, rule_id, message, session_id, drain_via=_this_file, blast_radius=blast_radius)
|
|
445
|
-
_maybe_emit_security_finding(str(tool_response), session_id, tool_name, tool_input)
|
|
446
312
|
|
|
447
313
|
sys.exit(0)
|
|
448
314
|
|
|
@@ -524,7 +524,12 @@ def _find_free_port() -> int:
|
|
|
524
524
|
|
|
525
525
|
|
|
526
526
|
def _exchange_clerk_token(api_url: str, clerk_token: str, workspace_id: str) -> dict:
|
|
527
|
-
"""POST /token (RFC 8693) to exchange a Clerk JWT for cond_agt_* + cond_ref_*.
|
|
527
|
+
"""POST /oauth/token (RFC 8693 grant) to exchange a Clerk JWT for cond_agt_* + cond_ref_*.
|
|
528
|
+
|
|
529
|
+
Endpoint moved from /token → /oauth/token in the OAuth 2.1 consolidation.
|
|
530
|
+
/token remains as a backwards-compat alias for older CLI installs; delete
|
|
531
|
+
the alias after ~60 days of zero traffic there.
|
|
532
|
+
"""
|
|
528
533
|
_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
529
534
|
_TYPE = "urn:ietf:params:oauth:token-type:jwt"
|
|
530
535
|
body = urllib.parse.urlencode({
|
|
@@ -534,7 +539,7 @@ def _exchange_clerk_token(api_url: str, clerk_token: str, workspace_id: str) ->
|
|
|
534
539
|
"resource": workspace_id,
|
|
535
540
|
}).encode()
|
|
536
541
|
req = urllib.request.Request(
|
|
537
|
-
f"{api_url}/token",
|
|
542
|
+
f"{api_url}/oauth/token",
|
|
538
543
|
data=body,
|
|
539
544
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
540
545
|
method="POST",
|
|
@@ -1368,7 +1373,6 @@ _ALL_SLUGS = [
|
|
|
1368
1373
|
"release_notes",
|
|
1369
1374
|
"issue_triage",
|
|
1370
1375
|
"copilot_reviewer",
|
|
1371
|
-
"security_scanner",
|
|
1372
1376
|
"security_patch_updater",
|
|
1373
1377
|
"smoke_test",
|
|
1374
1378
|
"flaky_test_detective",
|
|
@@ -1391,7 +1395,6 @@ _FRIENDLY_NAMES = {
|
|
|
1391
1395
|
"release_notes": "Release Notes",
|
|
1392
1396
|
"issue_triage": "Issue Triage",
|
|
1393
1397
|
"copilot_reviewer": "Copilot / AI PR Reviewer",
|
|
1394
|
-
"security_scanner": "Security Scanner",
|
|
1395
1398
|
"security_patch_updater": "Security Patch Updater",
|
|
1396
1399
|
"smoke_test": "Smoke Test",
|
|
1397
1400
|
"flaky_test_detective": "Flaky Test Detective",
|
|
@@ -2239,67 +2242,6 @@ def cmd_sessions(args):
|
|
|
2239
2242
|
print(_render_table(rows))
|
|
2240
2243
|
|
|
2241
2244
|
|
|
2242
|
-
# ── Security finding classifier ───────────────────────────────────────────────
|
|
2243
|
-
|
|
2244
|
-
import re as _re
|
|
2245
|
-
|
|
2246
|
-
_SEVERITY_KEYWORDS = {
|
|
2247
|
-
"critical": ["rce", "remote code execution", "sql injection", "sqli", "command injection"],
|
|
2248
|
-
"high": ["xss", "cross-site", "path traversal", "directory traversal", "auth bypass",
|
|
2249
|
-
"authentication bypass", "idor", "privilege escalation", "ssrf"],
|
|
2250
|
-
"medium": ["csrf", "open redirect", "clickjacking", "insecure deserialization", "xxe"],
|
|
2251
|
-
"low": ["information disclosure", "verbose error", "stack trace exposed", "version disclosure"],
|
|
2252
|
-
"info": ["todo security", "fixme security", "hardcoded", "weak cipher"],
|
|
2253
|
-
}
|
|
2254
|
-
|
|
2255
|
-
_SECRET_PATTERNS = [
|
|
2256
|
-
r'(?i)(api[_-]?key|secret|password|token|private[_-]?key)\s*[:=]\s*["\']?[\w\-]{8,}',
|
|
2257
|
-
r'(?i)sk-[a-zA-Z0-9]{20,}', # OpenAI key pattern
|
|
2258
|
-
r'(?i)ghp_[a-zA-Z0-9]{36}', # GitHub PAT
|
|
2259
|
-
]
|
|
2260
|
-
|
|
2261
|
-
_VULN_TYPES = {
|
|
2262
|
-
"injection": ["sql injection", "sqli", "command injection", "code injection", "ldap injection"],
|
|
2263
|
-
"path-traversal": ["path traversal", "directory traversal", "../"],
|
|
2264
|
-
"secret-leak": ["hardcoded", "secret", "api key", "password", "token", "private key"],
|
|
2265
|
-
"auth-bypass": ["auth bypass", "authentication bypass", "idor", "privilege escalation"],
|
|
2266
|
-
"crypto": ["weak cipher", "md5", "sha1", "tls 1.0", "ssl 2.0", "cert_none"],
|
|
2267
|
-
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
def classify_finding(text: str) -> "dict | None":
|
|
2271
|
-
"""
|
|
2272
|
-
Fast-path classifier. Returns structured finding dict or None if not a security issue.
|
|
2273
|
-
Checks secret patterns first (regex), then keyword severity matching.
|
|
2274
|
-
"""
|
|
2275
|
-
text_lower = text.lower()
|
|
2276
|
-
|
|
2277
|
-
# Secret detection (regex fast path)
|
|
2278
|
-
for pattern in _SECRET_PATTERNS:
|
|
2279
|
-
if _re.search(pattern, text):
|
|
2280
|
-
return {
|
|
2281
|
-
"severity": "high",
|
|
2282
|
-
"type": "secret-leak",
|
|
2283
|
-
"description": text[:500],
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
# Severity + type matching
|
|
2287
|
-
for severity, keywords in _SEVERITY_KEYWORDS.items():
|
|
2288
|
-
for kw in keywords:
|
|
2289
|
-
if kw in text_lower:
|
|
2290
|
-
vuln_type = "other"
|
|
2291
|
-
for vtype, vkws in _VULN_TYPES.items():
|
|
2292
|
-
if any(vk in text_lower for vk in vkws):
|
|
2293
|
-
vuln_type = vtype
|
|
2294
|
-
break
|
|
2295
|
-
return {
|
|
2296
|
-
"severity": severity,
|
|
2297
|
-
"type": vuln_type,
|
|
2298
|
-
"description": text[:500],
|
|
2299
|
-
}
|
|
2300
|
-
return None
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
2245
|
def cmd_session_report(args):
|
|
2304
2246
|
"""Run paxel analysis and open an HTML report in the local browser."""
|
|
2305
2247
|
import json as _json
|
|
@@ -2462,46 +2404,6 @@ def cmd_session_report(args):
|
|
|
2462
2404
|
print(f" (Dashboard push failed: {_push_err})")
|
|
2463
2405
|
|
|
2464
2406
|
|
|
2465
|
-
def cmd_emit_finding(args):
|
|
2466
|
-
"""POST a security finding to /security-findings."""
|
|
2467
|
-
server, workspace_id, token = _require_auth(args)
|
|
2468
|
-
hdrs = api.headers(workspace_id, token, "application/json")
|
|
2469
|
-
|
|
2470
|
-
from_stdin = getattr(args, "from_stdin", False)
|
|
2471
|
-
|
|
2472
|
-
if from_stdin:
|
|
2473
|
-
raw = sys.stdin.read()
|
|
2474
|
-
result = classify_finding(raw)
|
|
2475
|
-
if result is None:
|
|
2476
|
-
print("No security finding detected")
|
|
2477
|
-
sys.exit(0)
|
|
2478
|
-
severity = result["severity"]
|
|
2479
|
-
vuln_type = result["type"]
|
|
2480
|
-
description = result["description"]
|
|
2481
|
-
else:
|
|
2482
|
-
severity = args.severity
|
|
2483
|
-
vuln_type = args.type
|
|
2484
|
-
description = args.description
|
|
2485
|
-
|
|
2486
|
-
payload: dict = {
|
|
2487
|
-
"severity": severity,
|
|
2488
|
-
"type": vuln_type,
|
|
2489
|
-
"description": description,
|
|
2490
|
-
}
|
|
2491
|
-
if getattr(args, "file", None):
|
|
2492
|
-
payload["file"] = args.file
|
|
2493
|
-
if getattr(args, "line", None) is not None:
|
|
2494
|
-
payload["line"] = args.line
|
|
2495
|
-
if getattr(args, "repo", None):
|
|
2496
|
-
payload["repo"] = args.repo
|
|
2497
|
-
|
|
2498
|
-
result = api.req("POST", f"{server}/security-findings", hdrs, payload)
|
|
2499
|
-
finding_id = result.get("id", result.get("finding_id", ""))
|
|
2500
|
-
print(f"[FINDING] {severity} · {vuln_type} · {finding_id}")
|
|
2501
|
-
if finding_id:
|
|
2502
|
-
print(finding_id)
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
2407
|
def _gh_api_get(url: str, token: str):
|
|
2506
2408
|
import urllib.request, urllib.error
|
|
2507
2409
|
req = urllib.request.Request(url, headers={
|
|
@@ -2710,7 +2612,7 @@ def cmd_run(args):
|
|
|
2710
2612
|
_stream_run(server, workflow_id, run_id, workspace_id, token)
|
|
2711
2613
|
|
|
2712
2614
|
|
|
2713
|
-
# ── conduct sync / test-guard
|
|
2615
|
+
# ── conduct sync / test-guard ────────────────────────────────
|
|
2714
2616
|
|
|
2715
2617
|
def _refresh_agent_token() -> bool:
|
|
2716
2618
|
"""Silently rotate agent_token using refresh_token. Returns True if refreshed."""
|
|
@@ -2759,271 +2661,6 @@ def cmd_sync(args):
|
|
|
2759
2661
|
print(f"\n{GREEN}Sync complete.{RESET}\n")
|
|
2760
2662
|
|
|
2761
2663
|
|
|
2762
|
-
_SECURITY_TEST_CASES = [
|
|
2763
|
-
# (name, type, severity, description, file, line)
|
|
2764
|
-
("AWS Access Key", "secret-leak", "critical", "AKIA1234567890ABCDEF found in output", "test_vuln.py", 7),
|
|
2765
|
-
("OpenAI API Key", "secret-leak", "high", "sk-abcdefghijklmnopqrstuvwx1234567890 in response", "test_vuln.py", 8),
|
|
2766
|
-
("GitHub PAT", "secret-leak", "high", "ghp_" + "A" * 36 + " token present", "test_vuln.py", 8),
|
|
2767
|
-
("Bearer Token", "secret-leak", "high", "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test.sig", "test_vuln.py", None),
|
|
2768
|
-
("Hardcoded Password", "secret-leak", "high", "password = 'hardcoded_secret_here'", "test_vuln.py", 11),
|
|
2769
|
-
("Hardcoded API Key", "secret-leak", "high", "api_key = 'abc123def456ghi789'", "test_vuln.py", 12),
|
|
2770
|
-
("Path Traversal", "path-traversal", "medium", "../../etc/passwd accessed", "test_vuln.py", 32),
|
|
2771
|
-
("File URI", "path-traversal", "medium", "file:///etc/passwd read", "test_vuln.py", None),
|
|
2772
|
-
("eval() Injection", "injection", "high", "eval(user_input) called in output", "test_vuln.py", 16),
|
|
2773
|
-
("exec() Injection", "injection", "high", "exec(command) called in output", "test_vuln.py", 20),
|
|
2774
|
-
("SSL Disabled", "crypto", "high", "ssl.CERT_NONE used — verification disabled", "test_vuln.py", 28),
|
|
2775
|
-
("TLS Bypass", "crypto", "medium", "verify=False passed to requests", "test_vuln.py", 23),
|
|
2776
|
-
("SQL Injection", "injection", "high", "sql injection vulnerability in query", "test_vuln.py", None),
|
|
2777
|
-
("XSS", "injection", "high", "cross-site scripting detected in output", "test_vuln.py", None),
|
|
2778
|
-
("Auth Bypass", "auth-bypass", "high", "auth bypass possible via missing check", "test_vuln.py", None),
|
|
2779
|
-
]
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
def cmd_test_security(args):
|
|
2783
|
-
"""Fire synthetic security findings for every classifier pattern."""
|
|
2784
|
-
from conduct_cli.guard import CONFIG_PATH
|
|
2785
|
-
try:
|
|
2786
|
-
import json as _json
|
|
2787
|
-
cfg = _json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
|
|
2788
|
-
except Exception:
|
|
2789
|
-
cfg = {}
|
|
2790
|
-
|
|
2791
|
-
workspace_id = cfg.get("workspace_id")
|
|
2792
|
-
api_key = cfg.get("agent_token", "")
|
|
2793
|
-
user_email = cfg.get("user_email", "")
|
|
2794
|
-
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
2795
|
-
|
|
2796
|
-
if not workspace_id:
|
|
2797
|
-
print(f"{RED}Not configured. Run: conduct guard setup{RESET}")
|
|
2798
|
-
sys.exit(1)
|
|
2799
|
-
|
|
2800
|
-
import urllib.request
|
|
2801
|
-
import json as _json
|
|
2802
|
-
|
|
2803
|
-
print(f"\n{BOLD}▶ conduct test-security — {len(_SECURITY_TEST_CASES)} patterns{RESET}\n")
|
|
2804
|
-
|
|
2805
|
-
# Clean up previous test run findings before inserting fresh ones
|
|
2806
|
-
try:
|
|
2807
|
-
req = urllib.request.Request(
|
|
2808
|
-
f"{api_url}/security-findings?workspace_id={workspace_id}&source_run_id=conduct-test-security",
|
|
2809
|
-
headers={"Authorization": f"Bearer {api_key}"},
|
|
2810
|
-
method="DELETE",
|
|
2811
|
-
)
|
|
2812
|
-
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
2813
|
-
r = _json.loads(resp.read())
|
|
2814
|
-
n = r.get("deleted", 0)
|
|
2815
|
-
if n:
|
|
2816
|
-
print(f" {GRAY}↺ Cleaned {n} previous test finding{'s' if n != 1 else ''}{RESET}\n")
|
|
2817
|
-
except Exception:
|
|
2818
|
-
pass # cleanup is best-effort
|
|
2819
|
-
|
|
2820
|
-
passed = 0
|
|
2821
|
-
failed = 0
|
|
2822
|
-
for name, vtype, severity, description, test_file, test_line in _SECURITY_TEST_CASES:
|
|
2823
|
-
body: dict = {
|
|
2824
|
-
"tool": "claude-code",
|
|
2825
|
-
"severity": severity,
|
|
2826
|
-
"type": vtype,
|
|
2827
|
-
"description": f"[TEST] {description}",
|
|
2828
|
-
"reporter_email": user_email,
|
|
2829
|
-
"source_run_id": "conduct-test-security",
|
|
2830
|
-
}
|
|
2831
|
-
if test_file:
|
|
2832
|
-
body["file"] = test_file
|
|
2833
|
-
if test_line is not None:
|
|
2834
|
-
body["line"] = test_line
|
|
2835
|
-
payload = _json.dumps(body).encode()
|
|
2836
|
-
try:
|
|
2837
|
-
req = urllib.request.Request(
|
|
2838
|
-
f"{api_url}/security-findings?workspace_id={workspace_id}",
|
|
2839
|
-
data=payload,
|
|
2840
|
-
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
|
2841
|
-
method="POST",
|
|
2842
|
-
)
|
|
2843
|
-
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
2844
|
-
r = _json.loads(resp.read())
|
|
2845
|
-
fid = r.get("id", "")[:8]
|
|
2846
|
-
sev_color = RED if severity == "critical" else (YELLOW if severity == "high" else CYAN)
|
|
2847
|
-
print(f" {GREEN}✓{RESET} {name:<22} {sev_color}{severity:<8}{RESET} {GRAY}{fid}{RESET}")
|
|
2848
|
-
passed += 1
|
|
2849
|
-
except Exception as e:
|
|
2850
|
-
print(f" {RED}✕{RESET} {name:<22} {RED}FAILED — {e}{RESET}")
|
|
2851
|
-
failed += 1
|
|
2852
|
-
|
|
2853
|
-
print(f"\n {passed} posted · {failed} failed")
|
|
2854
|
-
print(f"\n {CYAN}→ View findings: {api_url.replace('api.', 'app.')}/secure/activity{RESET}\n")
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
def cmd_test_security_verify(args):
|
|
2858
|
-
"""Post test findings and verify the full triage pipeline end-to-end."""
|
|
2859
|
-
from conduct_cli.guard import CONFIG_PATH
|
|
2860
|
-
import json as _json
|
|
2861
|
-
import time as _time
|
|
2862
|
-
|
|
2863
|
-
try:
|
|
2864
|
-
cfg = _json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
|
|
2865
|
-
except Exception:
|
|
2866
|
-
cfg = {}
|
|
2867
|
-
|
|
2868
|
-
workspace_id = cfg.get("workspace_id")
|
|
2869
|
-
api_key = cfg.get("agent_token", "")
|
|
2870
|
-
user_email = cfg.get("user_email", "")
|
|
2871
|
-
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
2872
|
-
|
|
2873
|
-
if not workspace_id:
|
|
2874
|
-
print(f"{RED}Not configured. Run: conduct guard setup{RESET}")
|
|
2875
|
-
sys.exit(1)
|
|
2876
|
-
|
|
2877
|
-
import urllib.request
|
|
2878
|
-
|
|
2879
|
-
TIMEOUT = 300 # 15 runs × ~28s / 4 workers ≈ 105s; 300s gives headroom for queue variance
|
|
2880
|
-
POLL_SECS = 5
|
|
2881
|
-
|
|
2882
|
-
# ── Step 0: fresh Security Automation project ─────────────────────────
|
|
2883
|
-
print(f"\n{BOLD}▶ conduct test-security-verify{RESET}")
|
|
2884
|
-
print(f" {GRAY}Step 0/3 — refreshing Security Automation project…{RESET}")
|
|
2885
|
-
try:
|
|
2886
|
-
req = urllib.request.Request(
|
|
2887
|
-
f"{api_url}/secure/refresh-automation?workspace_id={workspace_id}",
|
|
2888
|
-
data=b"{}",
|
|
2889
|
-
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
|
2890
|
-
method="POST",
|
|
2891
|
-
)
|
|
2892
|
-
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
2893
|
-
_json.loads(resp.read())
|
|
2894
|
-
print(f" {GREEN}✓{RESET} Fresh project created with latest YAML\n")
|
|
2895
|
-
except Exception as e:
|
|
2896
|
-
print(f" {YELLOW}⚠ refresh failed ({e}) — using existing project{RESET}\n")
|
|
2897
|
-
|
|
2898
|
-
# ── Step 1: post test findings ────────────────────────────────────────
|
|
2899
|
-
print(f" {GRAY}Step 1/3 — posting {len(_SECURITY_TEST_CASES)} test findings…{RESET}\n")
|
|
2900
|
-
|
|
2901
|
-
# Clean previous run
|
|
2902
|
-
try:
|
|
2903
|
-
req = urllib.request.Request(
|
|
2904
|
-
f"{api_url}/security-findings?workspace_id={workspace_id}&source_run_id=conduct-test-security",
|
|
2905
|
-
headers={"Authorization": f"Bearer {api_key}"},
|
|
2906
|
-
method="DELETE",
|
|
2907
|
-
)
|
|
2908
|
-
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
2909
|
-
r = _json.loads(resp.read())
|
|
2910
|
-
n = r.get("deleted", 0)
|
|
2911
|
-
if n:
|
|
2912
|
-
print(f" {GRAY}↺ Cleaned {n} previous test finding{'s' if n != 1 else ''}{RESET}\n")
|
|
2913
|
-
except Exception:
|
|
2914
|
-
pass
|
|
2915
|
-
|
|
2916
|
-
finding_ids: list[str] = []
|
|
2917
|
-
for name, vtype, severity, description, test_file, test_line in _SECURITY_TEST_CASES:
|
|
2918
|
-
body: dict = {
|
|
2919
|
-
"tool": "claude-code",
|
|
2920
|
-
"severity": severity,
|
|
2921
|
-
"type": vtype,
|
|
2922
|
-
"description": f"[TEST] {description}",
|
|
2923
|
-
"reporter_email": user_email,
|
|
2924
|
-
"source_run_id": "conduct-test-security",
|
|
2925
|
-
}
|
|
2926
|
-
if test_file:
|
|
2927
|
-
body["file"] = test_file
|
|
2928
|
-
if test_line is not None:
|
|
2929
|
-
body["line"] = test_line
|
|
2930
|
-
payload = _json.dumps(body).encode()
|
|
2931
|
-
try:
|
|
2932
|
-
req = urllib.request.Request(
|
|
2933
|
-
f"{api_url}/security-findings?workspace_id={workspace_id}",
|
|
2934
|
-
data=payload,
|
|
2935
|
-
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
|
2936
|
-
method="POST",
|
|
2937
|
-
)
|
|
2938
|
-
with urllib.request.urlopen(req, timeout=8) as resp:
|
|
2939
|
-
r = _json.loads(resp.read())
|
|
2940
|
-
fid = r.get("id", "")
|
|
2941
|
-
finding_ids.append(fid)
|
|
2942
|
-
sev_color = RED if severity == "critical" else (YELLOW if severity == "high" else CYAN)
|
|
2943
|
-
print(f" {GREEN}✓{RESET} {name:<22} {sev_color}{severity:<8}{RESET} {GRAY}{fid[:8]}{RESET}")
|
|
2944
|
-
except Exception as e:
|
|
2945
|
-
print(f" {RED}✕{RESET} {name:<22} {RED}FAILED — {e}{RESET}")
|
|
2946
|
-
|
|
2947
|
-
if not finding_ids:
|
|
2948
|
-
print(f"\n{RED}✗ No findings posted — aborting.{RESET}\n")
|
|
2949
|
-
sys.exit(1)
|
|
2950
|
-
|
|
2951
|
-
# ── Step 2: poll until all findings move off "open" ───────────────────
|
|
2952
|
-
print(f"\n {GRAY}Step 2/3 — waiting for triage pipeline (timeout {TIMEOUT}s)…{RESET}\n")
|
|
2953
|
-
|
|
2954
|
-
deadline = _time.time() + TIMEOUT
|
|
2955
|
-
final_statuses: dict[str, str] = {}
|
|
2956
|
-
|
|
2957
|
-
while _time.time() < deadline:
|
|
2958
|
-
try:
|
|
2959
|
-
req = urllib.request.Request(
|
|
2960
|
-
f"{api_url}/security-findings?workspace_id={workspace_id}&source_run_id=conduct-test-security&limit=100",
|
|
2961
|
-
headers={"Authorization": f"Bearer {api_key}"},
|
|
2962
|
-
)
|
|
2963
|
-
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
2964
|
-
findings = _json.loads(resp.read())
|
|
2965
|
-
except Exception as e:
|
|
2966
|
-
print(f" {YELLOW}⚠ poll error: {e}{RESET}")
|
|
2967
|
-
_time.sleep(POLL_SECS)
|
|
2968
|
-
continue
|
|
2969
|
-
|
|
2970
|
-
for f in findings:
|
|
2971
|
-
if f["id"] in finding_ids:
|
|
2972
|
-
final_statuses[f["id"]] = f["status"]
|
|
2973
|
-
|
|
2974
|
-
still_open = [fid for fid in finding_ids if final_statuses.get(fid) == "open"]
|
|
2975
|
-
done_count = len(finding_ids) - len(still_open)
|
|
2976
|
-
elapsed = int(_time.time() - (deadline - TIMEOUT))
|
|
2977
|
-
print(f" {GRAY}[{elapsed:>3}s] {done_count}/{len(finding_ids)} processed…{RESET}", end="\r")
|
|
2978
|
-
|
|
2979
|
-
if not still_open:
|
|
2980
|
-
print() # newline after \r
|
|
2981
|
-
break
|
|
2982
|
-
_time.sleep(POLL_SECS)
|
|
2983
|
-
else:
|
|
2984
|
-
print(f"\n\n {RED}✗ Timeout — {len(still_open)} finding(s) still 'open' after {TIMEOUT}s{RESET}\n")
|
|
2985
|
-
|
|
2986
|
-
# ── Step 3: report per-finding results ────────────────────────────────
|
|
2987
|
-
print(f"\n {GRAY}Step 3/3 — results{RESET}\n")
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
all_pass = True
|
|
2991
|
-
name_by_id = {}
|
|
2992
|
-
for i, (name, *_) in enumerate(_SECURITY_TEST_CASES):
|
|
2993
|
-
if i < len(finding_ids):
|
|
2994
|
-
name_by_id[finding_ids[i]] = name
|
|
2995
|
-
|
|
2996
|
-
for fid in finding_ids:
|
|
2997
|
-
status = final_statuses.get(fid, "open")
|
|
2998
|
-
name = name_by_id.get(fid, fid[:8])
|
|
2999
|
-
if status == "open":
|
|
3000
|
-
icon = f"{RED}✗{RESET}"
|
|
3001
|
-
note = f"{RED}still open — triage did not run{RESET}"
|
|
3002
|
-
all_pass = False
|
|
3003
|
-
elif status == "dismissed":
|
|
3004
|
-
icon = f"{CYAN}○{RESET}"
|
|
3005
|
-
note = f"{CYAN}dismissed (false positive){RESET}"
|
|
3006
|
-
elif status == "triaging":
|
|
3007
|
-
icon = f"{YELLOW}◑{RESET}"
|
|
3008
|
-
note = f"{YELLOW}triaging (real finding, no autopilot fix){RESET}"
|
|
3009
|
-
elif status == "fixed":
|
|
3010
|
-
icon = f"{GREEN}✓{RESET}"
|
|
3011
|
-
note = f"{GREEN}fixed{RESET}"
|
|
3012
|
-
else:
|
|
3013
|
-
icon = f"{GRAY}?{RESET}"
|
|
3014
|
-
note = f"{GRAY}{status}{RESET}"
|
|
3015
|
-
print(f" {icon} {name:<22} → {note}")
|
|
3016
|
-
|
|
3017
|
-
print()
|
|
3018
|
-
if all_pass:
|
|
3019
|
-
print(f" {GREEN}{BOLD}✓ All findings processed — triage pipeline OK{RESET}\n")
|
|
3020
|
-
else:
|
|
3021
|
-
print(f" {RED}{BOLD}✗ Some findings were not processed — check Security Automation project runs{RESET}")
|
|
3022
|
-
app_url = api_url.replace("api.", "app.")
|
|
3023
|
-
print(f" {CYAN}→ {app_url}/projects{RESET}\n")
|
|
3024
|
-
sys.exit(1)
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
2664
|
def cmd_test_guard(args):
|
|
3028
2665
|
"""Test each guard policy rule with a matching synthetic tool call."""
|
|
3029
2666
|
import json as _json
|
|
@@ -3413,22 +3050,6 @@ def main():
|
|
|
3413
3050
|
ia_p.add_argument("--input", action="append", metavar="key=value",
|
|
3414
3051
|
help="Input value applied to all playbooks (repeatable)")
|
|
3415
3052
|
|
|
3416
|
-
# conduct emit finding
|
|
3417
|
-
emit_p = sub.add_parser("emit", help="Emit events to Conduct (e.g. security findings)")
|
|
3418
|
-
emit_sub = emit_p.add_subparsers(dest="emit_command")
|
|
3419
|
-
|
|
3420
|
-
finding_p = emit_sub.add_parser("finding", help="Emit a security finding to POST /security-findings")
|
|
3421
|
-
finding_p.add_argument("--severity", choices=["critical", "high", "medium", "low", "info"],
|
|
3422
|
-
help="Finding severity")
|
|
3423
|
-
finding_p.add_argument("--type", dest="type", metavar="TYPE",
|
|
3424
|
-
help="Vulnerability type (e.g. secret-leak, injection, path-traversal)")
|
|
3425
|
-
finding_p.add_argument("--file", metavar="PATH", help="Source file path where finding was detected")
|
|
3426
|
-
finding_p.add_argument("--line", type=int, metavar="N", help="Line number of finding")
|
|
3427
|
-
finding_p.add_argument("--description", metavar="TEXT", help="Human-readable description of the finding")
|
|
3428
|
-
finding_p.add_argument("--repo", metavar="owner/repo", help="Repository where finding was detected")
|
|
3429
|
-
finding_p.add_argument("--from-stdin", dest="from_stdin", action="store_true",
|
|
3430
|
-
help="Read raw tool output from stdin and auto-classify the finding")
|
|
3431
|
-
|
|
3432
3053
|
# conduct run (existing)
|
|
3433
3054
|
run_p = sub.add_parser("run", help="Run an installed agent by name")
|
|
3434
3055
|
run_p.add_argument("agent", help="Agent name (e.g. 'security_autopilot_fix')")
|
|
@@ -3486,7 +3107,7 @@ def main():
|
|
|
3486
3107
|
# conduct sync
|
|
3487
3108
|
sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
|
|
3488
3109
|
|
|
3489
|
-
# conduct test-guard /
|
|
3110
|
+
# conduct test-guard / verify
|
|
3490
3111
|
verify_p = sub.add_parser("verify", help="OWASP Agentic Top 10 coverage + governance grade")
|
|
3491
3112
|
verify_p.add_argument("--evidence", metavar="FILE", default=None, help="Write evidence artifact to FILE.")
|
|
3492
3113
|
verify_p.add_argument("--badge", action="store_true", help="Print markdown badge and exit.")
|
|
@@ -3496,8 +3117,6 @@ def main():
|
|
|
3496
3117
|
verify_p.add_argument("--since", default="24h", help="Time window for blocked event check (e.g. 7d, 24h)")
|
|
3497
3118
|
verify_p.add_argument("--run", action="store_true", help="Fire live adversarial test battery and show per-test verdicts.")
|
|
3498
3119
|
sub.add_parser("test-guard", help="Fire a synthetic event per guard policy rule and show decisions")
|
|
3499
|
-
sub.add_parser("test-security", help="Post a synthetic finding per security classifier pattern")
|
|
3500
|
-
sub.add_parser("test-security-verify", help="Post test findings and verify full triage pipeline end-to-end")
|
|
3501
3120
|
sr_p = sub.add_parser("session-report", help="Analyse local AI coding sessions with paxel and send report to admin")
|
|
3502
3121
|
sr_p.add_argument("--developer", default=None, help="Developer name (defaults to OS username)")
|
|
3503
3122
|
|
|
@@ -3579,16 +3198,6 @@ def main():
|
|
|
3579
3198
|
cmd_sessions(args)
|
|
3580
3199
|
elif args.command == "guard":
|
|
3581
3200
|
_guard.dispatch_guard(args, guard_p)
|
|
3582
|
-
elif args.command == "emit":
|
|
3583
|
-
emit_command = getattr(args, "emit_command", None)
|
|
3584
|
-
if emit_command == "finding":
|
|
3585
|
-
from_stdin = getattr(args, "from_stdin", False)
|
|
3586
|
-
if not from_stdin and not (args.severity and args.type and args.description):
|
|
3587
|
-
finding_p.print_help()
|
|
3588
|
-
sys.exit(1)
|
|
3589
|
-
cmd_emit_finding(args)
|
|
3590
|
-
else:
|
|
3591
|
-
emit_p.print_help()
|
|
3592
3201
|
elif args.command == "mcp":
|
|
3593
3202
|
if getattr(args, "mcp_command", None) == "install":
|
|
3594
3203
|
cmd_mcp_install(args)
|
|
@@ -3604,10 +3213,6 @@ def main():
|
|
|
3604
3213
|
_guard.cmd_verify(args)
|
|
3605
3214
|
elif args.command == "test-guard":
|
|
3606
3215
|
cmd_test_guard(args)
|
|
3607
|
-
elif args.command == "test-security":
|
|
3608
|
-
cmd_test_security(args)
|
|
3609
|
-
elif args.command == "test-security-verify":
|
|
3610
|
-
cmd_test_security_verify(args)
|
|
3611
3216
|
elif args.command == "session-report":
|
|
3612
3217
|
cmd_session_report(args)
|
|
3613
3218
|
elif args.command == "memory":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conduct-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Runtime governance for AI agents. CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
|
|
5
5
|
Author-email: Conduct AI <hello@conductai.ai>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -34,7 +34,6 @@ OFFLINE_ONLY: set[tuple[str, ...]] = {
|
|
|
34
34
|
("mcp", "install"), # writes local Claude/Codex config
|
|
35
35
|
("session-report",), # scans local files
|
|
36
36
|
("test-guard",), # local synthetic events
|
|
37
|
-
("test-security",), # local synthetic findings
|
|
38
37
|
("sessions",), # reads local Claude Code session directory
|
|
39
38
|
("whoami",), # status query — prints config state, no API call when unauth'd
|
|
40
39
|
("guard", "audit"), # reads local ~/.conduct/audit.log
|
|
@@ -55,7 +55,7 @@ def test_exchange_refresh_token_defaults_to_empty_string():
|
|
|
55
55
|
|
|
56
56
|
def test_exchange_http_error_json_body_exits(capsys):
|
|
57
57
|
body = json.dumps({"detail": "invalid clerk token"}).encode()
|
|
58
|
-
err = _make_http_error("https://api.example.com/token", 401, body)
|
|
58
|
+
err = _make_http_error("https://api.example.com/oauth/token", 401, body)
|
|
59
59
|
with patch("urllib.request.urlopen", side_effect=err):
|
|
60
60
|
with pytest.raises(SystemExit):
|
|
61
61
|
_exchange_clerk_token("https://api.example.com", "bad_tok", "ws_1")
|
|
@@ -66,7 +66,7 @@ def test_exchange_http_error_json_body_exits(capsys):
|
|
|
66
66
|
|
|
67
67
|
def test_exchange_http_error_non_json_body_exits(capsys):
|
|
68
68
|
body = b"Service Unavailable"
|
|
69
|
-
err = _make_http_error("https://api.example.com/token", 503, body)
|
|
69
|
+
err = _make_http_error("https://api.example.com/oauth/token", 503, body)
|
|
70
70
|
with patch("urllib.request.urlopen", side_effect=err):
|
|
71
71
|
with pytest.raises(SystemExit):
|
|
72
72
|
_exchange_clerk_token("https://api.example.com", "bad_tok", "ws_1")
|
|
@@ -89,7 +89,7 @@ def test_exchange_posts_to_correct_url():
|
|
|
89
89
|
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
|
90
90
|
_exchange_clerk_token("https://api.example.com", "clerk_tok", "ws_1")
|
|
91
91
|
|
|
92
|
-
assert captured_req["url"] == "https://api.example.com/token"
|
|
92
|
+
assert captured_req["url"] == "https://api.example.com/oauth/token"
|
|
93
93
|
assert captured_req["method"] == "POST"
|
|
94
94
|
assert captured_req["headers"].get("Content-type") == "application/x-www-form-urlencoded"
|
|
95
95
|
|
|
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
|
|
File without changes
|