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.
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/PKG-INFO +1 -1
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/pyproject.toml +1 -1
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/guard.py +269 -52
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/main.py +160 -19
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- conduct_cli-0.4.54/tests/test_guard_policy.py +388 -0
- conduct_cli-0.4.54/tests/test_guard_savings.py +195 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/README.md +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/setup.cfg +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/setup.py +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.4.48 → conduct_cli-0.4.54}/tests/test_switch.py +0 -0
|
@@ -107,48 +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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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":
|
|
970
|
-
"member_token":
|
|
971
|
-
"user_email":
|
|
972
|
-
"clerk_user_id":
|
|
973
|
-
"api_key":
|
|
974
|
-
"api_url":
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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":
|
|
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
|