conduct-cli 0.4.47__tar.gz → 0.4.53__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 (20) hide show
  1. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/PKG-INFO +1 -1
  2. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/pyproject.toml +1 -1
  3. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/guard.py +273 -48
  4. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/main.py +160 -19
  5. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  6. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  7. conduct_cli-0.4.53/tests/test_guard_policy.py +388 -0
  8. conduct_cli-0.4.53/tests/test_guard_savings.py +195 -0
  9. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/README.md +0 -0
  10. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/setup.cfg +0 -0
  11. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/setup.py +0 -0
  12. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/__init__.py +0 -0
  13. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/api.py +0 -0
  14. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/guardmcp.py +0 -0
  15. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/mcp_server.py +0 -0
  16. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  17. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  18. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/requires.txt +0 -0
  19. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/top_level.txt +0 -0
  20. {conduct_cli-0.4.47 → conduct_cli-0.4.53}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.47
3
+ Version: 0.4.53
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.4.47"
7
+ version = "0.4.53"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -107,44 +107,51 @@ def _fetch_budget_status():
107
107
  return False, None
108
108
 
109
109
 
110
- def _check_policy(tool_name, tool_input):
111
- """Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
112
- if not POLICY_PATH.exists():
113
- return None, "allow", None, None
114
- try:
115
- policy = json.loads(POLICY_PATH.read_text())
116
- except Exception:
117
- return None, "allow", None, None
110
+ try:
111
+ from conduct_cli.guard import _check_policy
112
+ except Exception:
113
+ def _check_policy(tool_name, tool_input, tokens_before=0):
114
+ """Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
115
+ if not POLICY_PATH.exists():
116
+ return None, "allow", None, None
117
+ try:
118
+ policy = json.loads(POLICY_PATH.read_text())
119
+ except Exception:
120
+ return None, "allow", None, None
118
121
 
119
- rules = policy.get("rules", [])
120
- input_text = json.dumps(tool_input)
121
- path_text = " ".join(str(tool_input.get(f, "")) for f in ["file_path", "path", "command"])
122
+ rules = policy.get("rules", [])
123
+ input_text = json.dumps(tool_input)
124
+ path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
122
125
 
123
- for rule in rules:
124
- match_tool = (rule.get("match_tool") or "*").lower()
125
- if match_tool != "*":
126
- if tool_name not in [t.strip() for t in match_tool.split(",")]:
127
- continue
128
- pattern = rule.get("match_pattern")
129
- if pattern:
130
- try:
131
- if not re.search(pattern, input_text, re.IGNORECASE):
126
+ for rule in rules:
127
+ match_tool = (rule.get("match_tool") or "*").lower()
128
+ if match_tool != "*":
129
+ if tool_name not in [t.strip() for t in match_tool.split(",")]:
132
130
  continue
133
- except re.error:
134
- continue
135
- path_pattern = rule.get("match_path_pattern")
136
- if path_pattern:
137
- try:
138
- if not re.search(path_pattern, path_text, re.IGNORECASE):
131
+ pattern = rule.get("match_pattern")
132
+ if pattern:
133
+ try:
134
+ if not re.search(pattern, input_text, re.IGNORECASE):
135
+ continue
136
+ except re.error:
139
137
  continue
140
- except re.error:
141
- continue
142
- action = rule.get("action", "audit")
143
- rule_id = rule.get("rule_id", "unknown")
144
- message = rule.get("message") or f"Policy violation: {rule_id}"
145
- return rule, action, rule_id, message
138
+ path_pattern = rule.get("match_path_pattern")
139
+ if path_pattern:
140
+ try:
141
+ if not any(re.search(path_pattern, f, re.IGNORECASE) for f in path_fields if f):
142
+ continue
143
+ except re.error:
144
+ continue
145
+ min_tokens = rule.get("match_tokens_before_gt")
146
+ if min_tokens is not None:
147
+ if tokens_before <= int(min_tokens):
148
+ continue
149
+ action = rule.get("action", "audit")
150
+ rule_id = rule.get("rule_id", "unknown")
151
+ message = rule.get("message") or f"Policy violation: {rule_id}"
152
+ return rule, action, rule_id, message
146
153
 
147
- return None, "allow", None, None
154
+ return None, "allow", None, None
148
155
 
149
156
 
150
157
  def _detect_ai_tool():
@@ -234,6 +241,90 @@ def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms)
234
241
  )
235
242
 
236
243
 
244
+ def _maybe_emit_security_finding(tool_response, session_id, tool_name):
245
+ """Classify tool_response for security findings; POST to /security-findings if flag ON. Never raises."""
246
+ try:
247
+ cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
248
+ except Exception:
249
+ return
250
+ if not cfg.get("security_emit_enabled", False):
251
+ return
252
+ workspace_id = cfg.get("workspace_id")
253
+ api_key = cfg.get("api_key", "")
254
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
255
+ if not workspace_id:
256
+ return
257
+
258
+ import re as _re2
259
+ text = str(tool_response)
260
+
261
+ # Fast-path classifier
262
+ SECRET_PATTERNS = [
263
+ (r"sk-[A-Za-z0-9]{20,}", "secret-leak", "high", "Potential OpenAI/Anthropic API key"),
264
+ (r"ghp_[A-Za-z0-9]{36}", "secret-leak", "high", "GitHub Personal Access Token"),
265
+ (r"AKIA[0-9A-Z]{16}", "secret-leak", "critical", "AWS Access Key ID"),
266
+ (r"Bearer\s+[A-Za-z0-9+/=]{20,}", "secret-leak", "high", "Bearer token in output"),
267
+ (r"""password\s*=\s*['"][^'"]{4,}""", "secret-leak", "high", "Hardcoded password"),
268
+ (r"""api[_-]?key\s*=\s*['"][^'"]{4,}""", "secret-leak", "high", "Hardcoded API key"),
269
+ (r"\.\./\.\./\.\./", "path-traversal", "medium", "Path traversal sequence"),
270
+ (r"file://", "path-traversal", "medium", "File URI scheme in output"),
271
+ (r"\beval\s*\(", "injection", "high", "eval() in output"),
272
+ (r"\bexec\s*\(", "injection", "high", "exec() in output"),
273
+ (r"\b__import__\s*\(", "injection", "high", "__import__() in output"),
274
+ (r"ssl\.CERT_NONE", "crypto", "high", "SSL verification disabled"),
275
+ (r"verify\s*=\s*False", "crypto", "medium", "TLS verification bypassed"),
276
+ ]
277
+ OWASP_KEYWORDS = [
278
+ ("sql injection", "injection", "high", "SQL injection mentioned in AI output"),
279
+ ("cross-site scripting", "injection", "high", "XSS mentioned in AI output"),
280
+ (" xss ", "injection", "high", "XSS mentioned in AI output"),
281
+ ("idor", "injection", "medium", "IDOR mentioned in AI output"),
282
+ ("ssrf", "injection", "high", "SSRF mentioned in AI output"),
283
+ ("command injection", "injection", "high", "Command injection mentioned in AI output"),
284
+ ("auth bypass", "auth-bypass", "high", "Auth bypass mentioned in AI output"),
285
+ ]
286
+
287
+ finding_type = severity = description = None
288
+ for pattern, ftype, sev, desc in SECRET_PATTERNS:
289
+ if _re2.search(pattern, text, _re2.IGNORECASE):
290
+ finding_type, severity, description = ftype, sev, desc
291
+ break
292
+ if not finding_type:
293
+ lower = text.lower()
294
+ for kw, ftype, sev, desc in OWASP_KEYWORDS:
295
+ if kw in lower:
296
+ finding_type, severity, description = ftype, sev, desc
297
+ break
298
+ if not finding_type:
299
+ return
300
+
301
+ payload = json.dumps({
302
+ "tool": _detect_ai_tool(),
303
+ "severity": severity,
304
+ "type": finding_type,
305
+ "description": description,
306
+ "source_run_id": session_id,
307
+ })
308
+ script = (
309
+ "import urllib.request\\n"
310
+ "try:\\n"
311
+ f" req = urllib.request.Request(\\"{api_url}/security-findings\\","
312
+ f" data={repr(payload.encode())}, headers={{\\\"Content-Type\\\": \\\"application/json\\\","
313
+ f" \\\"Authorization\\\": \\\"Bearer {api_key}\\\"}}, method=\\"POST\\")\\n"
314
+ " urllib.request.urlopen(req, timeout=5)\\n"
315
+ "except: pass\\n"
316
+ )
317
+ try:
318
+ subprocess.Popen(
319
+ [sys.executable, "-c", script],
320
+ stdout=subprocess.DEVNULL,
321
+ stderr=subprocess.DEVNULL,
322
+ start_new_session=True,
323
+ )
324
+ except Exception:
325
+ pass
326
+
327
+
237
328
  def _tail_lines(path, n=200):
238
329
  """Read last n lines of a file efficiently without loading the whole file."""
239
330
  size = path.stat().st_size
@@ -389,6 +480,14 @@ def post_usage_main():
389
480
  elif transcript_path:
390
481
  tokens_input, tokens_output = _read_tokens_from_transcript(transcript_path, tool_use_id)
391
482
  _post_usage(session_id, tool_name, tokens_input, tokens_output, None)
483
+ # Token-threshold policy check (PostToolUse only — tokens not known at PreToolUse)
484
+ _, action, rule_id, message = _check_policy(tool_name, {}, tokens_before=tokens_input)
485
+ if action in ("warn", "block"):
486
+ decision = "warned" if action == "warn" else "blocked"
487
+ _post_event(tool_name, {}, decision, rule_id, message, session_id=session_id)
488
+ # Security classifier — emit finding if flag ON
489
+ tool_response = data.get("tool_response") or data.get("output") or ""
490
+ _maybe_emit_security_finding(str(tool_response), session_id, tool_name)
392
491
 
393
492
  sys.exit(0)
394
493
 
@@ -584,6 +683,55 @@ if __name__ == "__main__":
584
683
  '''
585
684
 
586
685
 
686
+ # ── Policy engine (also embedded in _HOOK_SCRIPT for standalone use) ──────────
687
+
688
+ import json as _json
689
+ import re as _re
690
+
691
+ def _check_policy(tool_name, tool_input, tokens_before=0):
692
+ """Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
693
+ if not POLICY_PATH.exists():
694
+ return None, "allow", None, None
695
+ try:
696
+ policy = _json.loads(POLICY_PATH.read_text())
697
+ except Exception:
698
+ return None, "allow", None, None
699
+
700
+ rules = policy.get("rules", [])
701
+ input_text = _json.dumps(tool_input)
702
+ path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
703
+
704
+ for rule in rules:
705
+ match_tool = (rule.get("match_tool") or "*").lower()
706
+ if match_tool != "*":
707
+ if tool_name not in [t.strip() for t in match_tool.split(",")]:
708
+ continue
709
+ pattern = rule.get("match_pattern")
710
+ if pattern:
711
+ try:
712
+ if not _re.search(pattern, input_text, _re.IGNORECASE):
713
+ continue
714
+ except _re.error:
715
+ continue
716
+ path_pattern = rule.get("match_path_pattern")
717
+ if path_pattern:
718
+ try:
719
+ if not any(_re.search(path_pattern, f, _re.IGNORECASE) for f in path_fields if f):
720
+ continue
721
+ except _re.error:
722
+ continue
723
+ min_tokens = rule.get("match_tokens_before_gt")
724
+ if min_tokens is not None:
725
+ if tokens_before <= int(min_tokens):
726
+ continue
727
+ action = rule.get("action", "audit")
728
+ rule_id = rule.get("rule_id", "unknown")
729
+ message = rule.get("message") or f"Policy violation: {rule_id}"
730
+ return rule, action, rule_id, message
731
+
732
+ return None, "allow", None, None
733
+
734
+
587
735
  # ── Python interpreter selection ─────────────────────────────────────────────
588
736
 
589
737
  def _best_python() -> str:
@@ -669,11 +817,11 @@ def _require_guard_config() -> dict:
669
817
  cfg = _load_guard_config()
670
818
  ws = cfg.get("workspace_id")
671
819
  if not cfg or not ws:
672
- print(f"{RED}Guard not connected. Run: conduct login --api-key <key>{RESET}")
673
- sys.exit(1)
820
+ print(f"{RED}Guard not connected. Run: conduct login --api-key <key>{RESET}", file=sys.stderr)
821
+ sys.exit(0)
674
822
  if not cfg.get("api_key"):
675
- print(f"{RED}Guard config is missing API key. Re-run: conduct login --api-key <key>{RESET}")
676
- sys.exit(1)
823
+ print(f"{RED}Guard config is missing API key. Re-run: conduct login --api-key <key>{RESET}", file=sys.stderr)
824
+ sys.exit(0)
677
825
  return cfg
678
826
 
679
827
 
@@ -955,15 +1103,27 @@ def cmd_guard_install(args):
955
1103
  user_email = result.get("user_email") or ""
956
1104
  clerk_user_id = result.get("clerk_user_id") or ""
957
1105
 
1106
+ # Check if Security Loop module is installed for this workspace
1107
+ security_emit = False
1108
+ try:
1109
+ sec = _req("GET", f"{server}/secure/installed?workspace_id={workspace_id}", api_key=api_key)
1110
+ if sec.get("installed"):
1111
+ security_emit = True
1112
+ except Exception:
1113
+ pass
1114
+
958
1115
  # Persist guard config — include api_key so CLI commands can authenticate
959
1116
  _save_guard_config({
960
- "workspace_id": workspace_id,
961
- "member_token": member_token,
962
- "user_email": user_email,
963
- "clerk_user_id": clerk_user_id,
964
- "api_key": api_key,
965
- "api_url": server,
1117
+ "workspace_id": workspace_id,
1118
+ "member_token": member_token,
1119
+ "user_email": user_email,
1120
+ "clerk_user_id": clerk_user_id,
1121
+ "api_key": api_key,
1122
+ "api_url": server,
1123
+ "security_emit_enabled": security_emit,
966
1124
  })
1125
+ if security_emit:
1126
+ print(f" {GREEN}Security Loop:{RESET} installed — classifier active")
967
1127
 
968
1128
  # Download policies
969
1129
  try:
@@ -1184,15 +1344,35 @@ def cmd_guard_sync(args):
1184
1344
 
1185
1345
  print(f"Syncing policy…")
1186
1346
 
1187
- policy = _req(
1188
- "GET",
1189
- f"{base_url}/guard/policies/sync?workspace_id={workspace_id}",
1190
- api_key=api_key,
1191
- )
1347
+ try:
1348
+ policy = _req(
1349
+ "GET",
1350
+ f"{base_url}/guard/policies/sync?workspace_id={workspace_id}",
1351
+ api_key=api_key,
1352
+ )
1353
+ except Exception as e:
1354
+ print(f"Guard sync skipped: {e}", file=sys.stderr)
1355
+ sys.exit(0)
1192
1356
  _save_policy(policy)
1193
1357
  rule_count = len(policy.get("rules", []))
1194
1358
  print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
1195
1359
 
1360
+ # Re-check Security Loop install status
1361
+ try:
1362
+ sec = _req("GET", f"{base_url}/secure/installed?workspace_id={workspace_id}", api_key=api_key)
1363
+ cfg["security_emit_enabled"] = bool(sec.get("installed"))
1364
+ _save_guard_config(cfg)
1365
+ if sec.get("installed"):
1366
+ print(f" {GREEN}Security Loop:{RESET} installed — classifier active")
1367
+ try:
1368
+ policies = _req("GET", f"{base_url}/secure/policies?workspace_id={workspace_id}", api_key=api_key)
1369
+ policy_count = len(policies) if isinstance(policies, list) else 0
1370
+ print(f" {GREEN}Security Loop policies:{RESET} {policy_count} rule(s) synced")
1371
+ except Exception:
1372
+ pass
1373
+ except Exception:
1374
+ pass
1375
+
1196
1376
  # Refresh hook script + re-register in all tools
1197
1377
  hook_path = GUARD_DIR / "hook.py"
1198
1378
  _write_hook(hook_path)
@@ -1391,6 +1571,46 @@ def cmd_guard_status(args):
1391
1571
  print()
1392
1572
 
1393
1573
 
1574
+ def cmd_guard_savings(args):
1575
+ cfg = _require_guard_config()
1576
+ workspace_id = cfg.get("workspace_id")
1577
+ api_key = cfg.get("api_key", "")
1578
+ base_url = _api_url(cfg)
1579
+
1580
+ try:
1581
+ data = _req(
1582
+ "GET",
1583
+ f"{base_url}/guard/savings/team-summary?workspace_id={workspace_id}",
1584
+ api_key=api_key,
1585
+ )
1586
+ except Exception:
1587
+ print(f"{RED}Failed to fetch team savings.{RESET}")
1588
+ return
1589
+ if not isinstance(data, dict):
1590
+ print(f"{RED}Failed to fetch team savings.{RESET}")
1591
+ return
1592
+
1593
+ dev_count = data.get("developer_count", 0)
1594
+ total_tok = data.get("total_tokens_saved", 0)
1595
+ per_day = data.get("per_day_usd", 0.0)
1596
+ per_month = data.get("per_month_usd", 0.0)
1597
+ per_year = data.get("per_year_usd", 0.0)
1598
+ tools = data.get("tools_installed", [])
1599
+ avg_tok = total_tok // dev_count if dev_count else 0
1600
+ avg_day_usd = round(per_day / dev_count, 2) if dev_count else 0.0
1601
+
1602
+ print()
1603
+ print(f"{BOLD}Team Token Savings{RESET} ({dev_count} developer{'s' if dev_count != 1 else ''})")
1604
+ print("─" * 52)
1605
+ print(f" Total tokens saved: {total_tok:>14,}")
1606
+ print(f" Estimated savings: ${per_day:>8.2f}/day · ${per_month:,.0f}/month · ${per_year:,.0f}/year")
1607
+ if dev_count:
1608
+ print(f" Avg per developer: {avg_tok:>14,} tokens · ${avg_day_usd:.2f}/day")
1609
+ if tools:
1610
+ print(f" Tools contributing: {', '.join(tools)}")
1611
+ print()
1612
+
1613
+
1394
1614
  def cmd_guard_audit(args):
1395
1615
  cfg = _require_guard_config()
1396
1616
  workspace_id = cfg.get("workspace_id")
@@ -1468,6 +1688,9 @@ def register_guard_parser(sub):
1468
1688
  # conduct guard status
1469
1689
  guard_sub.add_parser("status", help="Show today's spend and violations")
1470
1690
 
1691
+ # conduct guard savings --team
1692
+ guard_sub.add_parser("savings", help="Show org-level token savings across all developers")
1693
+
1471
1694
  # conduct guard audit [--since 7d]
1472
1695
  audit_p = guard_sub.add_parser("audit", help="Show recent guard events")
1473
1696
  audit_p.add_argument(
@@ -1487,6 +1710,8 @@ def dispatch_guard(args, guard_p):
1487
1710
  cmd_guard_sync(args)
1488
1711
  elif guard_command == "status":
1489
1712
  cmd_guard_status(args)
1713
+ elif guard_command == "savings":
1714
+ cmd_guard_savings(args)
1490
1715
  elif guard_command == "audit":
1491
1716
  cmd_guard_audit(args)
1492
1717
  else:
@@ -1198,22 +1198,36 @@ _ALL_SLUGS = [
1198
1198
  "security_scanner",
1199
1199
  "security_patch_updater",
1200
1200
  "smoke_test",
1201
+ "flaky_test_detective",
1202
+ "release_readiness",
1203
+ "postmortem_drafter",
1204
+ "docs_drift_detector",
1205
+ "terraform_reviewer",
1206
+ "thirdparty_autopilot_fix",
1207
+ "factory",
1201
1208
  ]
1202
1209
 
1203
1210
  _FRIENDLY_NAMES = {
1204
- "autopilot_quick": "Autopilot Quick",
1205
- "autopilot_full": "Autopilot Full",
1206
- "autopilot_approved": "Autopilot + Approval",
1207
- "pr_reviewer": "PR Reviewer",
1208
- "ci_notify": "CI Failure Alert",
1209
- "incident_responder": "Incident Responder",
1210
- "dependency_updater": "Dependency Updater",
1211
- "release_notes": "Release Notes",
1212
- "issue_triage": "Issue Triage",
1213
- "copilot_reviewer": "Copilot / AI PR Reviewer",
1214
- "security_scanner": "Security Scanner",
1215
- "security_patch_updater": "Security Patch Updater",
1216
- "smoke_test": "Smoke Test",
1211
+ "autopilot_quick": "Autopilot Quick",
1212
+ "autopilot_full": "Autopilot Full",
1213
+ "autopilot_approved": "Autopilot + Approval",
1214
+ "pr_reviewer": "PR Reviewer",
1215
+ "ci_notify": "CI Failure Alert",
1216
+ "incident_responder": "Incident Responder",
1217
+ "dependency_updater": "Dependency Updater",
1218
+ "release_notes": "Release Notes",
1219
+ "issue_triage": "Issue Triage",
1220
+ "copilot_reviewer": "Copilot / AI PR Reviewer",
1221
+ "security_scanner": "Security Scanner",
1222
+ "security_patch_updater": "Security Patch Updater",
1223
+ "smoke_test": "Smoke Test",
1224
+ "flaky_test_detective": "Flaky Test Detective",
1225
+ "release_readiness": "Release Readiness Reviewer",
1226
+ "postmortem_drafter": "Postmortem Drafter",
1227
+ "docs_drift_detector": "Docs Drift Detector",
1228
+ "terraform_reviewer": "Terraform Plan Reviewer",
1229
+ "thirdparty_autopilot_fix": "Third-Party Autopilot Fix",
1230
+ "factory": "Factory",
1217
1231
  }
1218
1232
 
1219
1233
 
@@ -2051,6 +2065,107 @@ def cmd_sessions(args):
2051
2065
  print(_render_table(rows))
2052
2066
 
2053
2067
 
2068
+ # ── Security finding classifier ───────────────────────────────────────────────
2069
+
2070
+ import re as _re
2071
+
2072
+ _SEVERITY_KEYWORDS = {
2073
+ "critical": ["rce", "remote code execution", "sql injection", "sqli", "command injection"],
2074
+ "high": ["xss", "cross-site", "path traversal", "directory traversal", "auth bypass",
2075
+ "authentication bypass", "idor", "privilege escalation", "ssrf"],
2076
+ "medium": ["csrf", "open redirect", "clickjacking", "insecure deserialization", "xxe"],
2077
+ "low": ["information disclosure", "verbose error", "stack trace exposed", "version disclosure"],
2078
+ "info": ["todo security", "fixme security", "hardcoded", "weak cipher"],
2079
+ }
2080
+
2081
+ _SECRET_PATTERNS = [
2082
+ r'(?i)(api[_-]?key|secret|password|token|private[_-]?key)\s*[:=]\s*["\']?[\w\-]{8,}',
2083
+ r'(?i)sk-[a-zA-Z0-9]{20,}', # OpenAI key pattern
2084
+ r'(?i)ghp_[a-zA-Z0-9]{36}', # GitHub PAT
2085
+ ]
2086
+
2087
+ _VULN_TYPES = {
2088
+ "injection": ["sql injection", "sqli", "command injection", "code injection", "ldap injection"],
2089
+ "path-traversal": ["path traversal", "directory traversal", "../"],
2090
+ "secret-leak": ["hardcoded", "secret", "api key", "password", "token", "private key"],
2091
+ "auth-bypass": ["auth bypass", "authentication bypass", "idor", "privilege escalation"],
2092
+ "crypto": ["weak cipher", "md5", "sha1", "tls 1.0", "ssl 2.0", "cert_none"],
2093
+ }
2094
+
2095
+
2096
+ def classify_finding(text: str) -> "dict | None":
2097
+ """
2098
+ Fast-path classifier. Returns structured finding dict or None if not a security issue.
2099
+ Checks secret patterns first (regex), then keyword severity matching.
2100
+ """
2101
+ text_lower = text.lower()
2102
+
2103
+ # Secret detection (regex fast path)
2104
+ for pattern in _SECRET_PATTERNS:
2105
+ if _re.search(pattern, text):
2106
+ return {
2107
+ "severity": "high",
2108
+ "type": "secret-leak",
2109
+ "description": text[:500],
2110
+ }
2111
+
2112
+ # Severity + type matching
2113
+ for severity, keywords in _SEVERITY_KEYWORDS.items():
2114
+ for kw in keywords:
2115
+ if kw in text_lower:
2116
+ vuln_type = "other"
2117
+ for vtype, vkws in _VULN_TYPES.items():
2118
+ if any(vk in text_lower for vk in vkws):
2119
+ vuln_type = vtype
2120
+ break
2121
+ return {
2122
+ "severity": severity,
2123
+ "type": vuln_type,
2124
+ "description": text[:500],
2125
+ }
2126
+ return None
2127
+
2128
+
2129
+ def cmd_emit_finding(args):
2130
+ """POST a security finding to /security-findings."""
2131
+ server, workspace_id, api_key, token = _require_auth(args)
2132
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
2133
+
2134
+ from_stdin = getattr(args, "from_stdin", False)
2135
+
2136
+ if from_stdin:
2137
+ raw = sys.stdin.read()
2138
+ result = classify_finding(raw)
2139
+ if result is None:
2140
+ print("No security finding detected")
2141
+ sys.exit(0)
2142
+ severity = result["severity"]
2143
+ vuln_type = result["type"]
2144
+ description = result["description"]
2145
+ else:
2146
+ severity = args.severity
2147
+ vuln_type = args.type
2148
+ description = args.description
2149
+
2150
+ payload: dict = {
2151
+ "severity": severity,
2152
+ "type": vuln_type,
2153
+ "description": description,
2154
+ }
2155
+ if getattr(args, "file", None):
2156
+ payload["file"] = args.file
2157
+ if getattr(args, "line", None) is not None:
2158
+ payload["line"] = args.line
2159
+ if getattr(args, "repo", None):
2160
+ payload["repo"] = args.repo
2161
+
2162
+ result = api.req("POST", f"{server}/security-findings", hdrs, payload)
2163
+ finding_id = result.get("id", result.get("finding_id", ""))
2164
+ print(f"[FINDING] {severity} · {vuln_type} · {finding_id}")
2165
+ if finding_id:
2166
+ print(finding_id)
2167
+
2168
+
2054
2169
  def cmd_run(args):
2055
2170
  server, workspace_id, api_key, token = _require_auth(args)
2056
2171
  json_h = api.headers(workspace_id, token, "application/json", api_key)
@@ -2089,13 +2204,13 @@ def cmd_run(args):
2089
2204
  print(f" {GRAY}{k}={v}{RESET}")
2090
2205
  print()
2091
2206
 
2092
- body: dict = {
2093
- "triggered_by": "cli",
2094
- "initial_state": {"__manual": True, "inputs": initial_state},
2095
- }
2207
+ # Call the test-trigger endpoint so the YAML's built-in test_trigger.payload
2208
+ # (PR fixture, issue fixture, etc.) is loaded and the configured repo is
2209
+ # injected instead of running with an empty trigger context.
2210
+ body: dict = {**initial_state}
2096
2211
  if getattr(args, "max_turns", None):
2097
- body["max_turns"] = args.max_turns
2098
- run = api.req("POST", f"{server}/workflows/{workflow_id}/runs", json_h, body)
2212
+ body["__max_turns"] = args.max_turns
2213
+ run = api.req("POST", f"{server}/workflows/{workflow_id}/trigger", json_h, body)
2099
2214
  _stream_run(server, workflow_id, run["id"], workspace_id, token, api_key)
2100
2215
 
2101
2216
 
@@ -2198,6 +2313,22 @@ def main():
2198
2313
  ia_p.add_argument("--input", action="append", metavar="key=value",
2199
2314
  help="Input value applied to all playbooks (repeatable)")
2200
2315
 
2316
+ # conduct emit finding
2317
+ emit_p = sub.add_parser("emit", help="Emit events to Conduct (e.g. security findings)")
2318
+ emit_sub = emit_p.add_subparsers(dest="emit_command")
2319
+
2320
+ finding_p = emit_sub.add_parser("finding", help="Emit a security finding to POST /security-findings")
2321
+ finding_p.add_argument("--severity", choices=["critical", "high", "medium", "low", "info"],
2322
+ help="Finding severity")
2323
+ finding_p.add_argument("--type", dest="type", metavar="TYPE",
2324
+ help="Vulnerability type (e.g. secret-leak, injection, path-traversal)")
2325
+ finding_p.add_argument("--file", metavar="PATH", help="Source file path where finding was detected")
2326
+ finding_p.add_argument("--line", type=int, metavar="N", help="Line number of finding")
2327
+ finding_p.add_argument("--description", metavar="TEXT", help="Human-readable description of the finding")
2328
+ finding_p.add_argument("--repo", metavar="owner/repo", help="Repository where finding was detected")
2329
+ finding_p.add_argument("--from-stdin", dest="from_stdin", action="store_true",
2330
+ help="Read raw tool output from stdin and auto-classify the finding")
2331
+
2201
2332
  # conduct run (existing)
2202
2333
  run_p = sub.add_parser("run", help="Run an installed agent by name")
2203
2334
  run_p.add_argument("agent", help="Agent name (e.g. 'security_autopilot_fix')")
@@ -2277,6 +2408,16 @@ def main():
2277
2408
  cmd_sessions(args)
2278
2409
  elif args.command == "guard":
2279
2410
  _guard.dispatch_guard(args, guard_p)
2411
+ elif args.command == "emit":
2412
+ emit_command = getattr(args, "emit_command", None)
2413
+ if emit_command == "finding":
2414
+ from_stdin = getattr(args, "from_stdin", False)
2415
+ if not from_stdin and not (args.severity and args.type and args.description):
2416
+ finding_p.print_help()
2417
+ sys.exit(1)
2418
+ cmd_emit_finding(args)
2419
+ else:
2420
+ emit_p.print_help()
2280
2421
  elif args.command == "mcp":
2281
2422
  if getattr(args, "mcp_command", None) == "install":
2282
2423
  cmd_mcp_install(args)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.47
3
+ Version: 0.4.53
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -13,4 +13,6 @@ src/conduct_cli.egg-info/dependency_links.txt
13
13
  src/conduct_cli.egg-info/entry_points.txt
14
14
  src/conduct_cli.egg-info/requires.txt
15
15
  src/conduct_cli.egg-info/top_level.txt
16
+ tests/test_guard_policy.py
17
+ tests/test_guard_savings.py
16
18
  tests/test_switch.py
@@ -0,0 +1,388 @@
1
+ """
2
+ Tests for conduct_cli.guard._check_policy
3
+
4
+ Covers:
5
+ - No policy file → allow
6
+ - match_tool: wildcard, exact, comma-separated, no-match
7
+ - match_pattern: hit, miss, invalid regex
8
+ - match_path_pattern: hit, miss
9
+ - match_tokens_before_gt: above threshold, at threshold, below threshold
10
+ - action pass-through (block, warn, audit, approval)
11
+ - First-match-wins ordering
12
+ - Builtin policy rules from builtin_policies.yaml
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+ # ── ensure conduct_cli is importable ─────────────────────────────────────────
23
+ CONDUCT_CLI_SRC = Path(__file__).resolve().parent.parent / "src"
24
+ if str(CONDUCT_CLI_SRC) not in sys.path:
25
+ sys.path.insert(0, str(CONDUCT_CLI_SRC))
26
+
27
+ import conduct_cli.guard as guard_mod
28
+ from conduct_cli.guard import _check_policy, POLICY_PATH
29
+
30
+ BUILTIN_POLICIES_YAML = (
31
+ Path(__file__).resolve().parents[3]
32
+ / "apps/api/app/modules/guard/builtin_policies.yaml"
33
+ )
34
+
35
+
36
+ # ── helpers ───────────────────────────────────────────────────────────────────
37
+
38
+ def _write_policy(tmp_path: Path, rules: list[dict]) -> Path:
39
+ p = tmp_path / "policy.json"
40
+ p.write_text(json.dumps({"version": "test", "rules": rules}))
41
+ return p
42
+
43
+
44
+ def _rule(**kw) -> dict:
45
+ return {
46
+ "rule_id": kw.get("rule_id", "test-rule"),
47
+ "match_tool": kw.get("match_tool", "*"),
48
+ "match_pattern": kw.get("match_pattern", None),
49
+ "match_path_pattern": kw.get("match_path_pattern", None),
50
+ "action": kw.get("action", "block"),
51
+ "message": kw.get("message", "Test violation"),
52
+ **{k: v for k, v in kw.items()
53
+ if k not in ("rule_id", "match_tool", "match_pattern",
54
+ "match_path_pattern", "action", "message")},
55
+ }
56
+
57
+
58
+ @pytest.fixture()
59
+ def policy_path(tmp_path, monkeypatch):
60
+ """Returns a factory; call it with rules to write policy and patch the path."""
61
+ def _make(rules):
62
+ p = _write_policy(tmp_path, rules)
63
+ monkeypatch.setattr(guard_mod, "POLICY_PATH", p)
64
+ return p
65
+ return _make
66
+
67
+
68
+ @pytest.fixture()
69
+ def no_policy(tmp_path, monkeypatch):
70
+ """Point POLICY_PATH at a non-existent file."""
71
+ p = tmp_path / "missing.json"
72
+ monkeypatch.setattr(guard_mod, "POLICY_PATH", p)
73
+
74
+
75
+ # ── no policy file ────────────────────────────────────────────────────────────
76
+
77
+ class TestNoPolicyFile:
78
+ def test_allow_when_file_missing(self, no_policy):
79
+ _, action, rule_id, _ = _check_policy("bash", {"command": "ls"})
80
+ assert action == "allow"
81
+ assert rule_id is None
82
+
83
+ def test_allow_when_file_corrupt(self, tmp_path, monkeypatch):
84
+ p = tmp_path / "policy.json"
85
+ p.write_text("not json {{{{")
86
+ monkeypatch.setattr(guard_mod, "POLICY_PATH", p)
87
+ _, action, _, _ = _check_policy("bash", {"command": "ls"})
88
+ assert action == "allow"
89
+
90
+
91
+ # ── match_tool ────────────────────────────────────────────────────────────────
92
+
93
+ class TestMatchTool:
94
+ def test_wildcard_matches_any_tool(self, policy_path):
95
+ policy_path([_rule(match_tool="*", action="warn")])
96
+ _, action, _, _ = _check_policy("bash", {})
97
+ assert action == "warn"
98
+
99
+ def test_exact_tool_match(self, policy_path):
100
+ policy_path([_rule(match_tool="bash", action="warn")])
101
+ _, action, _, _ = _check_policy("bash", {})
102
+ assert action == "warn"
103
+
104
+ def test_exact_tool_no_match(self, policy_path):
105
+ policy_path([_rule(match_tool="edit", action="block")])
106
+ _, action, _, _ = _check_policy("bash", {})
107
+ assert action == "allow"
108
+
109
+ def test_comma_separated_includes_tool(self, policy_path):
110
+ policy_path([_rule(match_tool="bash,edit,write", action="warn")])
111
+ _, action, _, _ = _check_policy("edit", {})
112
+ assert action == "warn"
113
+
114
+ def test_comma_separated_excludes_tool(self, policy_path):
115
+ policy_path([_rule(match_tool="bash,edit,write", action="warn")])
116
+ _, action, _, _ = _check_policy("read", {})
117
+ assert action == "allow"
118
+
119
+ def test_tool_name_case_insensitive(self, policy_path):
120
+ policy_path([_rule(match_tool="Bash", action="warn")])
121
+ _, action, _, _ = _check_policy("bash", {})
122
+ assert action == "warn"
123
+
124
+
125
+ # ── match_pattern ─────────────────────────────────────────────────────────────
126
+
127
+ class TestMatchPattern:
128
+ def test_pattern_hit(self, policy_path):
129
+ policy_path([_rule(match_pattern=r"rm\s+-rf", action="block")])
130
+ _, action, _, _ = _check_policy("bash", {"command": "rm -rf /tmp/foo"})
131
+ assert action == "block"
132
+
133
+ def test_pattern_miss(self, policy_path):
134
+ policy_path([_rule(match_pattern=r"DROP TABLE", action="block")])
135
+ _, action, _, _ = _check_policy("bash", {"command": "select * from users"})
136
+ assert action == "allow"
137
+
138
+ def test_pattern_case_insensitive(self, policy_path):
139
+ policy_path([_rule(match_pattern=r"drop table", action="block")])
140
+ _, action, _, _ = _check_policy("bash", {"command": "DROP TABLE users;"})
141
+ assert action == "block"
142
+
143
+ def test_invalid_regex_skips_rule(self, policy_path):
144
+ policy_path([_rule(match_pattern=r"[invalid(", action="block")])
145
+ _, action, _, _ = _check_policy("bash", {"command": "anything"})
146
+ assert action == "allow"
147
+
148
+ def test_pattern_searches_full_input_json(self, policy_path):
149
+ policy_path([_rule(match_pattern=r"secret_value", action="warn")])
150
+ _, action, _, _ = _check_policy("write", {"content": "secret_value = 'abc'"})
151
+ assert action == "warn"
152
+
153
+
154
+ # ── match_path_pattern ────────────────────────────────────────────────────────
155
+
156
+ class TestMatchPathPattern:
157
+ def test_path_hit_via_file_path(self, policy_path):
158
+ policy_path([_rule(match_path_pattern=r"\.(pem|key)$", action="block")])
159
+ _, action, _, _ = _check_policy("write", {"file_path": "/project/id_rsa.key"})
160
+ assert action == "block"
161
+
162
+ def test_path_hit_via_path_field(self, policy_path):
163
+ policy_path([_rule(match_path_pattern=r"\.env$", action="block")])
164
+ _, action, _, _ = _check_policy("edit", {"path": "/project/.env"})
165
+ assert action == "block"
166
+
167
+ def test_path_miss(self, policy_path):
168
+ policy_path([_rule(match_path_pattern=r"\.env$", action="block")])
169
+ _, action, _, _ = _check_policy("edit", {"file_path": "/project/app.py"})
170
+ assert action == "allow"
171
+
172
+ def test_invalid_path_regex_skips(self, policy_path):
173
+ policy_path([_rule(match_path_pattern=r"[bad(", action="block")])
174
+ _, action, _, _ = _check_policy("write", {"file_path": "/any/file.py"})
175
+ assert action == "allow"
176
+
177
+
178
+ # ── match_tokens_before_gt ────────────────────────────────────────────────────
179
+
180
+ class TestMatchTokensBeforeGt:
181
+ def test_above_threshold_fires(self, policy_path):
182
+ policy_path([_rule(match_tokens_before_gt=50000, action="warn")])
183
+ _, action, _, _ = _check_policy("bash", {}, tokens_before=60000)
184
+ assert action == "warn"
185
+
186
+ def test_at_threshold_does_not_fire(self, policy_path):
187
+ policy_path([_rule(match_tokens_before_gt=50000, action="warn")])
188
+ _, action, _, _ = _check_policy("bash", {}, tokens_before=50000)
189
+ assert action == "allow"
190
+
191
+ def test_below_threshold_does_not_fire(self, policy_path):
192
+ policy_path([_rule(match_tokens_before_gt=50000, action="warn")])
193
+ _, action, _, _ = _check_policy("bash", {}, tokens_before=10000)
194
+ assert action == "allow"
195
+
196
+ def test_no_threshold_field_ignores_tokens(self, policy_path):
197
+ policy_path([_rule(match_tool="*", action="warn")])
198
+ _, action, _, _ = _check_policy("bash", {}, tokens_before=999999)
199
+ assert action == "warn"
200
+
201
+ def test_threshold_string_value_coerced(self, policy_path):
202
+ policy_path([_rule(match_tokens_before_gt="50000", action="warn")])
203
+ _, action, _, _ = _check_policy("bash", {}, tokens_before=60000)
204
+ assert action == "warn"
205
+
206
+
207
+ # ── actions and return values ─────────────────────────────────────────────────
208
+
209
+ class TestReturnValues:
210
+ def test_rule_id_returned(self, policy_path):
211
+ policy_path([_rule(rule_id="no-rm-rf", action="block")])
212
+ _, action, rule_id, _ = _check_policy("bash", {})
213
+ assert rule_id == "no-rm-rf"
214
+
215
+ def test_message_returned(self, policy_path):
216
+ policy_path([_rule(action="block", message="Custom message")])
217
+ _, _, _, msg = _check_policy("bash", {})
218
+ assert msg == "Custom message"
219
+
220
+ def test_action_block(self, policy_path):
221
+ policy_path([_rule(action="block")])
222
+ _, action, _, _ = _check_policy("bash", {})
223
+ assert action == "block"
224
+
225
+ def test_action_warn(self, policy_path):
226
+ policy_path([_rule(action="warn")])
227
+ _, action, _, _ = _check_policy("bash", {})
228
+ assert action == "warn"
229
+
230
+ def test_action_audit(self, policy_path):
231
+ policy_path([_rule(action="audit")])
232
+ _, action, _, _ = _check_policy("bash", {})
233
+ assert action == "audit"
234
+
235
+ def test_action_approval(self, policy_path):
236
+ policy_path([_rule(action="approval")])
237
+ _, action, _, _ = _check_policy("bash", {})
238
+ assert action == "approval"
239
+
240
+ def test_first_match_wins(self, policy_path):
241
+ policy_path([
242
+ _rule(rule_id="first", match_pattern=r"secret", action="block"),
243
+ _rule(rule_id="second", match_pattern=r"secret", action="warn"),
244
+ ])
245
+ _, action, rule_id, _ = _check_policy("bash", {"cmd": "secret"})
246
+ assert action == "block"
247
+ assert rule_id == "first"
248
+
249
+ def test_allow_returns_none_rule(self, policy_path):
250
+ policy_path([_rule(match_pattern=r"no-match-ever-xyz", action="block")])
251
+ rule, action, rule_id, msg = _check_policy("bash", {})
252
+ assert rule is None
253
+ assert action == "allow"
254
+ assert rule_id is None
255
+ assert msg is None
256
+
257
+
258
+ # ── builtin policies integration ──────────────────────────────────────────────
259
+
260
+ @pytest.fixture(scope="module")
261
+ def builtin_rules():
262
+ """Load rules from builtin_policies.yaml (requires PyYAML)."""
263
+ pytest.importorskip("yaml")
264
+ import yaml # noqa: PLC0415
265
+
266
+ if not BUILTIN_POLICIES_YAML.exists():
267
+ pytest.skip(f"builtin_policies.yaml not found at {BUILTIN_POLICIES_YAML}")
268
+ return yaml.safe_load(BUILTIN_POLICIES_YAML.read_text())
269
+
270
+
271
+ @pytest.fixture()
272
+ def builtin_policy_path(tmp_path, monkeypatch, builtin_rules):
273
+ p = tmp_path / "policy.json"
274
+ p.write_text(json.dumps({"version": "builtin", "rules": builtin_rules}))
275
+ monkeypatch.setattr(guard_mod, "POLICY_PATH", p)
276
+
277
+
278
+ class TestBuiltinPolicies:
279
+ # ── Destructive ───────────────────────────────────────────────────────────
280
+ def test_no_rm_rf_blocks(self, builtin_policy_path):
281
+ _, action, rule_id, _ = _check_policy("bash", {"command": "rm -rf /tmp/test"})
282
+ assert action == "block"
283
+ assert rule_id == "no-rm-rf"
284
+
285
+ def test_no_rm_rf_misses_plain_rm(self, builtin_policy_path):
286
+ _, _, rule_id, _ = _check_policy("bash", {"command": "rm file.txt"})
287
+ assert rule_id != "no-rm-rf"
288
+
289
+ def test_no_git_reset_hard_blocks(self, builtin_policy_path):
290
+ _, action, rule_id, _ = _check_policy("bash", {"command": "git reset --hard HEAD~1"})
291
+ assert action == "block"
292
+ assert rule_id == "no-git-reset-hard"
293
+
294
+ def test_no_force_push_blocks(self, builtin_policy_path):
295
+ _, action, rule_id, _ = _check_policy("bash", {"command": "git push --force origin main"})
296
+ assert action == "block"
297
+ assert rule_id == "no-force-push"
298
+
299
+ def test_no_force_push_short_flag(self, builtin_policy_path):
300
+ _, action, rule_id, _ = _check_policy("bash", {"command": "git push -f origin main"})
301
+ assert action == "block"
302
+ assert rule_id == "no-force-push"
303
+
304
+ def test_no_drop_table_blocks(self, builtin_policy_path):
305
+ _, action, rule_id, _ = _check_policy("bash", {"command": "DROP TABLE users;"})
306
+ assert action == "block"
307
+ assert rule_id == "no-drop-table"
308
+
309
+ # ── Permissions ───────────────────────────────────────────────────────────
310
+ def test_no_sudo_blocks(self, builtin_policy_path):
311
+ _, action, rule_id, _ = _check_policy("bash", {"command": "sudo apt-get install pkg"})
312
+ assert action == "block"
313
+ assert rule_id == "no-sudo"
314
+
315
+ def test_no_chmod_777_warns(self, builtin_policy_path):
316
+ _, action, rule_id, _ = _check_policy("bash", {"command": "chmod 777 /etc/passwd"})
317
+ assert action == "warn"
318
+ assert rule_id == "no-chmod-permissive"
319
+
320
+ # ── Secrets ───────────────────────────────────────────────────────────────
321
+ def test_no_env_commits_blocks(self, builtin_policy_path):
322
+ _, action, rule_id, _ = _check_policy("bash", {"command": "git add .env && git commit -m fix"})
323
+ assert action == "block"
324
+ assert rule_id == "no-env-commits"
325
+
326
+ def test_no_hardcoded_secrets_warns(self, builtin_policy_path):
327
+ _, action, rule_id, _ = _check_policy(
328
+ "write",
329
+ {"content": "API_KEY='Abc123XyzLong456AbcXyz789'"},
330
+ )
331
+ assert action == "warn"
332
+ assert rule_id == "no-hardcoded-secrets"
333
+
334
+ def test_no_private_key_files_blocks(self, builtin_policy_path):
335
+ _, action, rule_id, _ = _check_policy("write", {"file_path": "/project/server.pem"})
336
+ assert action == "block"
337
+ assert rule_id == "no-private-key-files"
338
+
339
+ # ── Production gates ──────────────────────────────────────────────────────
340
+ def test_approve_prod_deploy(self, builtin_policy_path):
341
+ _, action, rule_id, _ = _check_policy("bash", {"command": "deploy to production"})
342
+ assert action == "approval"
343
+ assert rule_id == "approve-prod-deploy"
344
+
345
+ def test_approve_db_migration(self, builtin_policy_path):
346
+ _, action, rule_id, _ = _check_policy("bash", {"command": "alembic upgrade head"})
347
+ assert action == "approval"
348
+ assert rule_id == "approve-db-migration-prod"
349
+
350
+ # ── Token efficiency ──────────────────────────────────────────────────────
351
+ def test_warn_deterministic_compute_sort_uniq(self, builtin_policy_path):
352
+ _, action, rule_id, _ = _check_policy("bash", {"command": "sort users.csv | uniq -c"})
353
+ assert action == "warn"
354
+ assert rule_id == "warn-deterministic-compute"
355
+
356
+ def test_warn_deterministic_compute_grep_count(self, builtin_policy_path):
357
+ _, action, rule_id, _ = _check_policy("bash", {"command": "grep -c 'ERROR' app.log"})
358
+ assert action == "warn"
359
+ assert rule_id == "warn-deterministic-compute"
360
+
361
+ def test_warn_deterministic_compute_wc_l(self, builtin_policy_path):
362
+ _, action, rule_id, _ = _check_policy("bash", {"command": "cat data.txt | wc -l"})
363
+ assert action == "warn"
364
+ assert rule_id == "warn-deterministic-compute"
365
+
366
+ def test_warn_deterministic_compute_python_oneliner(self, builtin_policy_path):
367
+ _, action, rule_id, _ = _check_policy("bash", {"command": "python -c 'print(sum([1,2,3]))'"} )
368
+ assert action == "warn"
369
+ assert rule_id == "warn-deterministic-compute"
370
+
371
+ def test_warn_deterministic_compute_no_false_positive(self, builtin_policy_path):
372
+ _, action, rule_id, _ = _check_policy("bash", {"command": "git status"})
373
+ assert rule_id != "warn-deterministic-compute"
374
+
375
+ def test_warn_large_context_above_threshold(self, builtin_policy_path):
376
+ _, action, rule_id, _ = _check_policy("bash", {}, tokens_before=60000)
377
+ assert action == "warn"
378
+ assert rule_id == "warn-large-context-dump"
379
+
380
+ def test_warn_large_context_at_threshold_does_not_fire(self, builtin_policy_path):
381
+ _, action, rule_id, _ = _check_policy("bash", {}, tokens_before=50000)
382
+ assert rule_id != "warn-large-context-dump"
383
+
384
+ def test_warn_large_context_below_threshold_does_not_fire(self, builtin_policy_path):
385
+ _, action, rule_id, _ = _check_policy("bash", {}, tokens_before=1000)
386
+ assert rule_id != "warn-large-context-dump"
387
+
388
+
@@ -0,0 +1,195 @@
1
+ """
2
+ Tests for `conduct guard savings` CLI command (cmd_guard_savings).
3
+
4
+ Mocks _req and _require_guard_config to avoid real network calls.
5
+ Verifies output formatting for various API response shapes.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ import types
11
+ from pathlib import Path
12
+ from unittest.mock import MagicMock, patch
13
+
14
+ import pytest
15
+
16
+ # Ensure conduct_cli package is importable
17
+ HERE = Path(__file__).resolve()
18
+ CLI_PKG = HERE.parent.parent / "src"
19
+ if str(CLI_PKG) not in sys.path:
20
+ sys.path.insert(0, str(CLI_PKG))
21
+
22
+
23
+ def _args():
24
+ return types.SimpleNamespace()
25
+
26
+
27
+ _FAKE_CFG = {
28
+ "workspace_id": "ws-abc-123",
29
+ "api_key": "cond_live_test",
30
+ "api_url": "https://api.example.com",
31
+ }
32
+
33
+ _TEAM_RESPONSE = {
34
+ "workspace_id": "ws-abc-123",
35
+ "developer_count": 2,
36
+ "total_tokens_saved": 1_500_000,
37
+ "total_cost_saved_usd": 8.1,
38
+ "per_day_usd": 8.10,
39
+ "per_month_usd": 243.00,
40
+ "per_year_usd": 2956.50,
41
+ "tools_installed": ["rtk", "booster"],
42
+ }
43
+
44
+
45
+ @pytest.fixture(autouse=True)
46
+ def patch_config_and_url(monkeypatch):
47
+ """Patch _require_guard_config and _api_url for all tests in this module."""
48
+ import conduct_cli.guard as g
49
+ monkeypatch.setattr(g, "_require_guard_config", lambda: _FAKE_CFG)
50
+ monkeypatch.setattr(g, "_api_url", lambda cfg: cfg.get("api_url", ""))
51
+
52
+
53
+ class TestCmdGuardSavingsOutput:
54
+ def test_prints_team_header(self, capsys):
55
+ import conduct_cli.guard as g
56
+ with patch.object(g, "_req", return_value=_TEAM_RESPONSE):
57
+ g.cmd_guard_savings(_args())
58
+ out = capsys.readouterr().out
59
+ assert "Team Token Savings" in out
60
+
61
+ def test_prints_developer_count(self, capsys):
62
+ import conduct_cli.guard as g
63
+ with patch.object(g, "_req", return_value=_TEAM_RESPONSE):
64
+ g.cmd_guard_savings(_args())
65
+ out = capsys.readouterr().out
66
+ assert "2 developer" in out
67
+
68
+ def test_prints_total_tokens_formatted(self, capsys):
69
+ import conduct_cli.guard as g
70
+ with patch.object(g, "_req", return_value=_TEAM_RESPONSE):
71
+ g.cmd_guard_savings(_args())
72
+ out = capsys.readouterr().out
73
+ # 1_500_000 formatted with commas
74
+ assert "1,500,000" in out
75
+
76
+ def test_prints_per_day_amount(self, capsys):
77
+ import conduct_cli.guard as g
78
+ with patch.object(g, "_req", return_value=_TEAM_RESPONSE):
79
+ g.cmd_guard_savings(_args())
80
+ out = capsys.readouterr().out
81
+ assert "8.10" in out
82
+ assert "/day" in out
83
+
84
+ def test_prints_per_month_amount(self, capsys):
85
+ import conduct_cli.guard as g
86
+ with patch.object(g, "_req", return_value=_TEAM_RESPONSE):
87
+ g.cmd_guard_savings(_args())
88
+ out = capsys.readouterr().out
89
+ assert "243" in out
90
+ assert "/month" in out
91
+
92
+ def test_prints_per_year_amount(self, capsys):
93
+ import conduct_cli.guard as g
94
+ with patch.object(g, "_req", return_value=_TEAM_RESPONSE):
95
+ g.cmd_guard_savings(_args())
96
+ out = capsys.readouterr().out
97
+ assert "2,956" in out or "2957" in out
98
+ assert "/year" in out
99
+
100
+ def test_prints_tools_contributing(self, capsys):
101
+ import conduct_cli.guard as g
102
+ with patch.object(g, "_req", return_value=_TEAM_RESPONSE):
103
+ g.cmd_guard_savings(_args())
104
+ out = capsys.readouterr().out
105
+ assert "rtk" in out
106
+ assert "booster" in out
107
+
108
+ def test_prints_avg_per_developer(self, capsys):
109
+ import conduct_cli.guard as g
110
+ with patch.object(g, "_req", return_value=_TEAM_RESPONSE):
111
+ g.cmd_guard_savings(_args())
112
+ out = capsys.readouterr().out
113
+ assert "Avg per developer" in out
114
+ # 1_500_000 // 2 = 750_000
115
+ assert "750,000" in out
116
+
117
+
118
+ class TestCmdGuardSavingsZeroDevelopers:
119
+ def test_zero_developer_count(self, capsys):
120
+ import conduct_cli.guard as g
121
+ resp = dict(_TEAM_RESPONSE, developer_count=0, total_tokens_saved=0, per_day_usd=0.0, per_month_usd=0.0, per_year_usd=0.0)
122
+ with patch.object(g, "_req", return_value=resp):
123
+ g.cmd_guard_savings(_args())
124
+ out = capsys.readouterr().out
125
+ assert "Team Token Savings" in out
126
+ assert "0 developer" in out
127
+
128
+ def test_no_avg_line_when_no_developers(self, capsys):
129
+ import conduct_cli.guard as g
130
+ resp = dict(_TEAM_RESPONSE, developer_count=0, total_tokens_saved=0, per_day_usd=0.0)
131
+ with patch.object(g, "_req", return_value=resp):
132
+ g.cmd_guard_savings(_args())
133
+ out = capsys.readouterr().out
134
+ assert "Avg per developer" not in out
135
+
136
+ def test_no_tools_line_when_empty(self, capsys):
137
+ import conduct_cli.guard as g
138
+ resp = dict(_TEAM_RESPONSE, developer_count=0, tools_installed=[])
139
+ with patch.object(g, "_req", return_value=resp):
140
+ g.cmd_guard_savings(_args())
141
+ out = capsys.readouterr().out
142
+ assert "Tools contributing" not in out
143
+
144
+
145
+ class TestCmdGuardSavingsToolsFilter:
146
+ def test_rtk_only_tools(self, capsys):
147
+ import conduct_cli.guard as g
148
+ resp = dict(_TEAM_RESPONSE, tools_installed=["rtk"])
149
+ with patch.object(g, "_req", return_value=resp):
150
+ g.cmd_guard_savings(_args())
151
+ out = capsys.readouterr().out
152
+ assert "rtk" in out
153
+ assert "booster" not in out
154
+
155
+ def test_booster_only_tools(self, capsys):
156
+ import conduct_cli.guard as g
157
+ resp = dict(_TEAM_RESPONSE, tools_installed=["booster"])
158
+ with patch.object(g, "_req", return_value=resp):
159
+ g.cmd_guard_savings(_args())
160
+ out = capsys.readouterr().out
161
+ assert "booster" in out
162
+ assert "rtk" not in out.split("Tools contributing")[1] if "Tools contributing" in out else True
163
+
164
+
165
+ class TestCmdGuardSavingsApiFailure:
166
+ def test_non_dict_response_prints_error(self, capsys):
167
+ import conduct_cli.guard as g
168
+ with patch.object(g, "_req", return_value=None):
169
+ g.cmd_guard_savings(_args())
170
+ out = capsys.readouterr().out
171
+ assert "Failed" in out
172
+
173
+ def test_string_response_prints_error(self, capsys):
174
+ import conduct_cli.guard as g
175
+ with patch.object(g, "_req", return_value="error"):
176
+ g.cmd_guard_savings(_args())
177
+ out = capsys.readouterr().out
178
+ assert "Failed" in out
179
+
180
+ def test_network_error_prints_error(self, capsys):
181
+ import conduct_cli.guard as g
182
+ with patch.object(g, "_req", side_effect=ConnectionError("timeout")):
183
+ g.cmd_guard_savings(_args())
184
+ out = capsys.readouterr().out
185
+ assert "Failed" in out
186
+
187
+ def test_single_developer_singular_label(self, capsys):
188
+ import conduct_cli.guard as g
189
+ resp = dict(_TEAM_RESPONSE, developer_count=1, total_tokens_saved=750_000)
190
+ with patch.object(g, "_req", return_value=resp):
191
+ g.cmd_guard_savings(_args())
192
+ out = capsys.readouterr().out
193
+ # "1 developer" not "1 developers"
194
+ assert "1 developer" in out
195
+ assert "1 developers" not in out
File without changes
File without changes
File without changes