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