conduct-cli 0.4.57__tar.gz → 0.4.59__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/PKG-INFO +1 -1
  2. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/pyproject.toml +1 -1
  3. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/guard.py +103 -42
  4. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/main.py +2 -2
  5. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  6. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/README.md +0 -0
  7. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/setup.cfg +0 -0
  8. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/setup.py +0 -0
  9. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/__init__.py +0 -0
  10. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/api.py +0 -0
  11. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/guardmcp.py +0 -0
  12. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli/mcp_server.py +0 -0
  13. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
  14. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  15. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  16. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/requires.txt +0 -0
  17. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/src/conduct_cli.egg-info/top_level.txt +0 -0
  18. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/tests/test_guard_policy.py +0 -0
  19. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/tests/test_guard_savings.py +0 -0
  20. {conduct_cli-0.4.57 → conduct_cli-0.4.59}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.57
3
+ Version: 0.4.59
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.4.57"
7
+ version = "0.4.59"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -241,8 +241,78 @@ def _post_usage(session_id, tool_name, tokens_input, tokens_output, duration_ms)
241
241
  )
242
242
 
243
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."""
244
+ SECRET_PATTERNS = [
245
+ (r"sk-[A-Za-z0-9]{20,}", "secret-leak", "high", "Potential OpenAI/Anthropic API key"),
246
+ (r"ghp_[A-Za-z0-9]{36}", "secret-leak", "high", "GitHub Personal Access Token"),
247
+ (r"AKIA[0-9A-Z]{16}", "secret-leak", "critical", "AWS Access Key ID"),
248
+ (r"Bearer\s+[A-Za-z0-9+/=]{20,}", "secret-leak", "high", "Bearer token in output"),
249
+ (r"""password\s*=\s*['"][^'"]{4,}""","secret-leak", "high", "Hardcoded password"),
250
+ (r"""api[_-]?key\s*=\s*['"][^'"]{4,}""","secret-leak","high", "Hardcoded API key"),
251
+ (r"\.\./\.\./\.\./", "path-traversal", "medium", "Path traversal sequence"),
252
+ (r"file://", "path-traversal", "medium", "File URI scheme in output"),
253
+ (r"\beval\s*\(", "injection", "high", "eval() in output"),
254
+ (r"\bexec\s*\(", "injection", "high", "exec() in output"),
255
+ (r"\b__import__\s*\(", "injection", "high", "__import__() in output"),
256
+ (r"ssl\.CERT_NONE", "crypto", "high", "SSL verification disabled"),
257
+ (r"verify\s*=\s*False", "crypto", "medium", "TLS verification bypassed"),
258
+ ]
259
+ OWASP_KEYWORDS = [
260
+ ("sql injection", "injection", "high", "SQL injection mentioned in AI output"),
261
+ ("cross-site scripting","injection","high", "XSS mentioned in AI output"),
262
+ (" xss ", "injection", "high", "XSS mentioned in AI output"),
263
+ ("idor", "injection", "medium", "IDOR mentioned in AI output"),
264
+ ("ssrf", "injection", "high", "SSRF mentioned in AI output"),
265
+ ("command injection","injection", "high", "Command injection mentioned in AI output"),
266
+ ("auth bypass", "auth-bypass", "high", "Auth bypass mentioned in AI output"),
267
+ ]
268
+
269
+
270
+ def _classify_text(text):
271
+ """Return (finding_type, severity, description, matched_pattern) or (None,...) if clean."""
272
+ import re as _re
273
+ for pattern, ftype, sev, desc in SECRET_PATTERNS:
274
+ if _re.search(pattern, text, _re.IGNORECASE):
275
+ return ftype, sev, desc, pattern
276
+ lower = text.lower()
277
+ for kw, ftype, sev, desc in OWASP_KEYWORDS:
278
+ if kw in lower:
279
+ return ftype, sev, desc, kw
280
+ return None, None, None, None
281
+
282
+
283
+ def _line_number_from_text(text, matched_pattern):
284
+ """Extract line number where pattern matched.
285
+
286
+ Handles two formats:
287
+ - Read tool output: ' N\\tcode line' (cat -n style)
288
+ - Plain text: count newlines before match offset
289
+ """
290
+ import re as _re
291
+ if not matched_pattern:
292
+ return None
293
+ try:
294
+ # Try cat-n format first (Read tool)
295
+ for raw_line in text.split("\n"):
296
+ m = _re.match(r"^\s*(\d+)\t(.*)$", raw_line)
297
+ if m:
298
+ lineno, content = int(m.group(1)), m.group(2)
299
+ try:
300
+ if _re.search(matched_pattern, content, _re.IGNORECASE):
301
+ return lineno
302
+ except Exception:
303
+ if matched_pattern.lower() in content.lower():
304
+ return lineno
305
+ # Fallback: count newlines before the first match
306
+ m = _re.search(matched_pattern, text, _re.IGNORECASE)
307
+ if m:
308
+ return text[:m.start()].count("\n") + 1
309
+ except Exception:
310
+ pass
311
+ return None
312
+
313
+
314
+ def _maybe_emit_security_finding(tool_response, session_id, tool_name, tool_input=None):
315
+ """Classify tool output + input for security findings; POST if flag ON. Never raises."""
246
316
  try:
247
317
  cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
248
318
  except Exception:
@@ -255,49 +325,37 @@ def _maybe_emit_security_finding(tool_response, session_id, tool_name):
255
325
  if not workspace_id:
256
326
  return
257
327
 
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
328
+ ti = tool_input or {}
329
+
330
+ # Build scan candidates: (text_to_scan, source_label)
331
+ # Priority: tool_response first (Read), then written content (Edit/Write), then command (Bash)
332
+ candidates = [("response", str(tool_response))]
333
+ if tool_name in ("edit", "multiedit"):
334
+ candidates.append(("input", ti.get("new_string", "")))
335
+ elif tool_name == "write":
336
+ candidates.append(("input", ti.get("content", "")))
337
+ elif tool_name in ("bash", "terminal"):
338
+ candidates.append(("input", ti.get("command", "")))
339
+
340
+ finding_type = severity = description = matched_pattern = scan_text = None
341
+ for _src, text in candidates:
342
+ ft, sv, desc, pat = _classify_text(text)
343
+ if ft:
344
+ finding_type, severity, description, matched_pattern, scan_text = ft, sv, desc, pat, text
291
345
  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
346
+
298
347
  if not finding_type:
299
348
  return
300
349
 
350
+ # File path
351
+ file_path = (
352
+ ti.get("file_path") or ti.get("path") or
353
+ (ti.get("command", "")[:120] if tool_name in ("bash", "terminal") else None)
354
+ ) or None
355
+
356
+ # Line number
357
+ line_no = _line_number_from_text(scan_text, matched_pattern) if scan_text else None
358
+
301
359
  payload = json.dumps({
302
360
  "tool": _detect_ai_tool(),
303
361
  "severity": severity,
@@ -305,6 +363,8 @@ def _maybe_emit_security_finding(tool_response, session_id, tool_name):
305
363
  "description": description,
306
364
  "source_run_id": session_id,
307
365
  "reporter_email": cfg.get("user_email") or "",
366
+ "file": file_path,
367
+ "line": line_no,
308
368
  })
309
369
  script = (
310
370
  "import urllib.request\\n"
@@ -489,7 +549,8 @@ def post_usage_main():
489
549
 
490
550
  # Security classifier runs regardless of transcript_path — scan every tool response
491
551
  tool_response = data.get("tool_response") or data.get("output") or ""
492
- _maybe_emit_security_finding(str(tool_response), session_id, tool_name)
552
+ tool_input = data.get("tool_input") or {}
553
+ _maybe_emit_security_finding(str(tool_response), session_id, tool_name, tool_input)
493
554
 
494
555
  sys.exit(0)
495
556
 
@@ -2217,7 +2217,7 @@ def cmd_run(args):
2217
2217
  # ── conduct sync / test-guard / test-security ────────────────────────────────
2218
2218
 
2219
2219
  def cmd_sync(args):
2220
- """Run guard sync + security policy sync in one shot."""
2220
+ """Sync Guard policies (and Security Loop policies if installed)."""
2221
2221
  import conduct_cli.guard as _g
2222
2222
  print(f"\n{BOLD}▶ conduct sync{RESET}\n")
2223
2223
  _g.cmd_guard_sync(args)
@@ -2533,7 +2533,7 @@ def main():
2533
2533
  mcp_sub.add_parser("install", help="Register conduct-mcp in Claude Code and Codex")
2534
2534
 
2535
2535
  # conduct sync
2536
- sub.add_parser("sync", help="Sync Guard policies + Security Loop in one shot")
2536
+ sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
2537
2537
 
2538
2538
  # conduct test-guard / test-security
2539
2539
  sub.add_parser("test-guard", help="Fire a synthetic event per guard policy rule and show decisions")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.57
3
+ Version: 0.4.59
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
File without changes
File without changes
File without changes