conduct-cli 0.4.48__tar.gz → 0.4.54__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.48 → conduct_cli-0.4.54}/PKG-INFO +1 -1
  2. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/pyproject.toml +1 -1
  3. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/guard.py +269 -52
  4. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/main.py +160 -19
  5. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  6. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  7. conduct_cli-0.4.54/tests/test_guard_policy.py +388 -0
  8. conduct_cli-0.4.54/tests/test_guard_savings.py +195 -0
  9. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/README.md +0 -0
  10. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/setup.cfg +0 -0
  11. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/setup.py +0 -0
  12. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/__init__.py +0 -0
  13. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/api.py +0 -0
  14. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/guardmcp.py +0 -0
  15. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/mcp_server.py +0 -0
  16. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  17. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  18. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/requires.txt +0 -0
  19. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/top_level.txt +0 -0
  20. {conduct_cli-0.4.48 → conduct_cli-0.4.54}/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.48
3
+ Version: 0.4.54
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.48"
7
+ version = "0.4.54"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -107,48 +107,51 @@ def _fetch_budget_status():
107
107
  return False, None
108
108
 
109
109
 
110
- def _check_policy(tool_name, tool_input, tokens_before=0):
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
- min_tokens = rule.get("match_tokens_before_gt")
143
- if min_tokens is not None:
144
- if tokens_before <= int(min_tokens):
145
- continue
146
- action = rule.get("action", "audit")
147
- rule_id = rule.get("rule_id", "unknown")
148
- message = rule.get("message") or f"Policy violation: {rule_id}"
149
- 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
150
153
 
151
- return None, "allow", None, None
154
+ return None, "allow", None, None
152
155
 
153
156
 
154
157
  def _detect_ai_tool():
@@ -238,6 +241,90 @@ def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms)
238
241
  )
239
242
 
240
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
+
241
328
  def _tail_lines(path, n=200):
242
329
  """Read last n lines of a file efficiently without loading the whole file."""
243
330
  size = path.stat().st_size
@@ -399,6 +486,10 @@ def post_usage_main():
399
486
  decision = "warned" if action == "warn" else "blocked"
400
487
  _post_event(tool_name, {}, decision, rule_id, message, session_id=session_id)
401
488
 
489
+ # Security classifier runs regardless of transcript_path — scan every tool response
490
+ tool_response = data.get("tool_response") or data.get("output") or ""
491
+ _maybe_emit_security_finding(str(tool_response), session_id, tool_name)
492
+
402
493
  sys.exit(0)
403
494
 
404
495
 
@@ -593,6 +684,55 @@ if __name__ == "__main__":
593
684
  '''
594
685
 
595
686
 
687
+ # ── Policy engine (also embedded in _HOOK_SCRIPT for standalone use) ──────────
688
+
689
+ import json as _json
690
+ import re as _re
691
+
692
+ def _check_policy(tool_name, tool_input, tokens_before=0):
693
+ """Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
694
+ if not POLICY_PATH.exists():
695
+ return None, "allow", None, None
696
+ try:
697
+ policy = _json.loads(POLICY_PATH.read_text())
698
+ except Exception:
699
+ return None, "allow", None, None
700
+
701
+ rules = policy.get("rules", [])
702
+ input_text = _json.dumps(tool_input)
703
+ path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
704
+
705
+ for rule in rules:
706
+ match_tool = (rule.get("match_tool") or "*").lower()
707
+ if match_tool != "*":
708
+ if tool_name not in [t.strip() for t in match_tool.split(",")]:
709
+ continue
710
+ pattern = rule.get("match_pattern")
711
+ if pattern:
712
+ try:
713
+ if not _re.search(pattern, input_text, _re.IGNORECASE):
714
+ continue
715
+ except _re.error:
716
+ continue
717
+ path_pattern = rule.get("match_path_pattern")
718
+ if path_pattern:
719
+ try:
720
+ if not any(_re.search(path_pattern, f, _re.IGNORECASE) for f in path_fields if f):
721
+ continue
722
+ except _re.error:
723
+ continue
724
+ min_tokens = rule.get("match_tokens_before_gt")
725
+ if min_tokens is not None:
726
+ if tokens_before <= int(min_tokens):
727
+ continue
728
+ action = rule.get("action", "audit")
729
+ rule_id = rule.get("rule_id", "unknown")
730
+ message = rule.get("message") or f"Policy violation: {rule_id}"
731
+ return rule, action, rule_id, message
732
+
733
+ return None, "allow", None, None
734
+
735
+
596
736
  # ── Python interpreter selection ─────────────────────────────────────────────
597
737
 
598
738
  def _best_python() -> str:
@@ -678,11 +818,11 @@ def _require_guard_config() -> dict:
678
818
  cfg = _load_guard_config()
679
819
  ws = cfg.get("workspace_id")
680
820
  if not cfg or not ws:
681
- print(f"{RED}Guard not connected. Run: conduct login --api-key <key>{RESET}")
682
- sys.exit(1)
821
+ print(f"{RED}Guard not connected. Run: conduct login --api-key <key>{RESET}", file=sys.stderr)
822
+ sys.exit(0)
683
823
  if not cfg.get("api_key"):
684
- print(f"{RED}Guard config is missing API key. Re-run: conduct login --api-key <key>{RESET}")
685
- sys.exit(1)
824
+ print(f"{RED}Guard config is missing API key. Re-run: conduct login --api-key <key>{RESET}", file=sys.stderr)
825
+ sys.exit(0)
686
826
  return cfg
687
827
 
688
828
 
@@ -964,15 +1104,27 @@ def cmd_guard_install(args):
964
1104
  user_email = result.get("user_email") or ""
965
1105
  clerk_user_id = result.get("clerk_user_id") or ""
966
1106
 
1107
+ # Check if Security Loop module is installed for this workspace
1108
+ security_emit = False
1109
+ try:
1110
+ sec = _req("GET", f"{server}/secure/installed?workspace_id={workspace_id}", api_key=api_key)
1111
+ if sec.get("installed"):
1112
+ security_emit = True
1113
+ except Exception:
1114
+ pass
1115
+
967
1116
  # Persist guard config — include api_key so CLI commands can authenticate
968
1117
  _save_guard_config({
969
- "workspace_id": workspace_id,
970
- "member_token": member_token,
971
- "user_email": user_email,
972
- "clerk_user_id": clerk_user_id,
973
- "api_key": api_key,
974
- "api_url": server,
1118
+ "workspace_id": workspace_id,
1119
+ "member_token": member_token,
1120
+ "user_email": user_email,
1121
+ "clerk_user_id": clerk_user_id,
1122
+ "api_key": api_key,
1123
+ "api_url": server,
1124
+ "security_emit_enabled": security_emit,
975
1125
  })
1126
+ if security_emit:
1127
+ print(f" {GREEN}Security Loop:{RESET} installed — classifier active")
976
1128
 
977
1129
  # Download policies
978
1130
  try:
@@ -1193,15 +1345,35 @@ def cmd_guard_sync(args):
1193
1345
 
1194
1346
  print(f"Syncing policy…")
1195
1347
 
1196
- policy = _req(
1197
- "GET",
1198
- f"{base_url}/guard/policies/sync?workspace_id={workspace_id}",
1199
- api_key=api_key,
1200
- )
1348
+ try:
1349
+ policy = _req(
1350
+ "GET",
1351
+ f"{base_url}/guard/policies/sync?workspace_id={workspace_id}",
1352
+ api_key=api_key,
1353
+ )
1354
+ except Exception as e:
1355
+ print(f"Guard sync skipped: {e}", file=sys.stderr)
1356
+ sys.exit(0)
1201
1357
  _save_policy(policy)
1202
1358
  rule_count = len(policy.get("rules", []))
1203
1359
  print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
1204
1360
 
1361
+ # Re-check Security Loop install status
1362
+ try:
1363
+ sec = _req("GET", f"{base_url}/secure/installed?workspace_id={workspace_id}", api_key=api_key)
1364
+ cfg["security_emit_enabled"] = bool(sec.get("installed"))
1365
+ _save_guard_config(cfg)
1366
+ if sec.get("installed"):
1367
+ print(f" {GREEN}Security Loop:{RESET} installed — classifier active")
1368
+ try:
1369
+ policies = _req("GET", f"{base_url}/secure/policies?workspace_id={workspace_id}", api_key=api_key)
1370
+ policy_count = len(policies) if isinstance(policies, list) else 0
1371
+ print(f" {GREEN}Security Loop policies:{RESET} {policy_count} rule(s) synced")
1372
+ except Exception:
1373
+ pass
1374
+ except Exception:
1375
+ pass
1376
+
1205
1377
  # Refresh hook script + re-register in all tools
1206
1378
  hook_path = GUARD_DIR / "hook.py"
1207
1379
  _write_hook(hook_path)
@@ -1400,6 +1572,46 @@ def cmd_guard_status(args):
1400
1572
  print()
1401
1573
 
1402
1574
 
1575
+ def cmd_guard_savings(args):
1576
+ cfg = _require_guard_config()
1577
+ workspace_id = cfg.get("workspace_id")
1578
+ api_key = cfg.get("api_key", "")
1579
+ base_url = _api_url(cfg)
1580
+
1581
+ try:
1582
+ data = _req(
1583
+ "GET",
1584
+ f"{base_url}/guard/savings/team-summary?workspace_id={workspace_id}",
1585
+ api_key=api_key,
1586
+ )
1587
+ except Exception:
1588
+ print(f"{RED}Failed to fetch team savings.{RESET}")
1589
+ return
1590
+ if not isinstance(data, dict):
1591
+ print(f"{RED}Failed to fetch team savings.{RESET}")
1592
+ return
1593
+
1594
+ dev_count = data.get("developer_count", 0)
1595
+ total_tok = data.get("total_tokens_saved", 0)
1596
+ per_day = data.get("per_day_usd", 0.0)
1597
+ per_month = data.get("per_month_usd", 0.0)
1598
+ per_year = data.get("per_year_usd", 0.0)
1599
+ tools = data.get("tools_installed", [])
1600
+ avg_tok = total_tok // dev_count if dev_count else 0
1601
+ avg_day_usd = round(per_day / dev_count, 2) if dev_count else 0.0
1602
+
1603
+ print()
1604
+ print(f"{BOLD}Team Token Savings{RESET} ({dev_count} developer{'s' if dev_count != 1 else ''})")
1605
+ print("─" * 52)
1606
+ print(f" Total tokens saved: {total_tok:>14,}")
1607
+ print(f" Estimated savings: ${per_day:>8.2f}/day · ${per_month:,.0f}/month · ${per_year:,.0f}/year")
1608
+ if dev_count:
1609
+ print(f" Avg per developer: {avg_tok:>14,} tokens · ${avg_day_usd:.2f}/day")
1610
+ if tools:
1611
+ print(f" Tools contributing: {', '.join(tools)}")
1612
+ print()
1613
+
1614
+
1403
1615
  def cmd_guard_audit(args):
1404
1616
  cfg = _require_guard_config()
1405
1617
  workspace_id = cfg.get("workspace_id")
@@ -1477,6 +1689,9 @@ def register_guard_parser(sub):
1477
1689
  # conduct guard status
1478
1690
  guard_sub.add_parser("status", help="Show today's spend and violations")
1479
1691
 
1692
+ # conduct guard savings --team
1693
+ guard_sub.add_parser("savings", help="Show org-level token savings across all developers")
1694
+
1480
1695
  # conduct guard audit [--since 7d]
1481
1696
  audit_p = guard_sub.add_parser("audit", help="Show recent guard events")
1482
1697
  audit_p.add_argument(
@@ -1496,6 +1711,8 @@ def dispatch_guard(args, guard_p):
1496
1711
  cmd_guard_sync(args)
1497
1712
  elif guard_command == "status":
1498
1713
  cmd_guard_status(args)
1714
+ elif guard_command == "savings":
1715
+ cmd_guard_savings(args)
1499
1716
  elif guard_command == "audit":
1500
1717
  cmd_guard_audit(args)
1501
1718
  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.48
3
+ Version: 0.4.54
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