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.
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/PKG-INFO +1 -1
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/pyproject.toml +1 -1
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/guard.py +273 -48
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/main.py +160 -19
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- conduct_cli-0.4.53/tests/test_guard_policy.py +388 -0
- conduct_cli-0.4.53/tests/test_guard_savings.py +195 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/README.md +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/setup.cfg +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/setup.py +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.4.47 → conduct_cli-0.4.53}/tests/test_switch.py +0 -0
|
@@ -107,44 +107,51 @@ def _fetch_budget_status():
|
|
|
107
107
|
return False, None
|
|
108
108
|
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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":
|
|
961
|
-
"member_token":
|
|
962
|
-
"user_email":
|
|
963
|
-
"clerk_user_id":
|
|
964
|
-
"api_key":
|
|
965
|
-
"api_url":
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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":
|
|
1205
|
-
"autopilot_full":
|
|
1206
|
-
"autopilot_approved":
|
|
1207
|
-
"pr_reviewer":
|
|
1208
|
-
"ci_notify":
|
|
1209
|
-
"incident_responder":
|
|
1210
|
-
"dependency_updater":
|
|
1211
|
-
"release_notes":
|
|
1212
|
-
"issue_triage":
|
|
1213
|
-
"copilot_reviewer":
|
|
1214
|
-
"security_scanner":
|
|
1215
|
-
"security_patch_updater":
|
|
1216
|
-
"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
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
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["
|
|
2098
|
-
run = api.req("POST", f"{server}/workflows/{workflow_id}/
|
|
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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|