conduct-cli 0.8.8__tar.gz → 0.9.0__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.8.8 → conduct_cli-0.9.0}/PKG-INFO +1 -1
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/pyproject.toml +1 -1
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/hooks/pretooluse.py +141 -11
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/README.md +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/setup.cfg +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/setup.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/guard.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/guard_policy.md +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/hooks/base.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_api_client.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_cmd_run.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_instruction_files.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_log_util.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_report_tool_coverage.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_switch.py +0 -0
- {conduct_cli-0.8.8 → conduct_cli-0.9.0}/tests/test_token_lifecycle.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -309,6 +309,14 @@ DOC_SENSITIVE_RULE_PREFIXES = (
|
|
|
309
309
|
"nist-govern-doc",
|
|
310
310
|
"eu-ai-pii-",
|
|
311
311
|
"no-" + "env" + "-read", # split literal to avoid triggering the pattern itself
|
|
312
|
+
# Rules that document what an attack looks like will fire on files that
|
|
313
|
+
# define, seed, or test those attacks. On dev paths (with sentinel) these
|
|
314
|
+
# skip so we can maintain the packs themselves. Not doc-sensitive in the
|
|
315
|
+
# customer sense — sensitive to *authoring* the detection rule.
|
|
316
|
+
"no_prompt_" + "inject",
|
|
317
|
+
"proxy-no-prompt-" + "inject",
|
|
318
|
+
"prompt-" + "inject",
|
|
319
|
+
"nist-govern-" + "policy",
|
|
312
320
|
)
|
|
313
321
|
|
|
314
322
|
# IRS regulatory pack rules also skip on dev paths since our own code names
|
|
@@ -321,25 +329,72 @@ DEV_PATH_MARKERS = (
|
|
|
321
329
|
"/apps/api/alembic/versions/",
|
|
322
330
|
"/apps/api/tests/",
|
|
323
331
|
"/apps/api/app/modules/agent_identity/",
|
|
324
|
-
"/apps/api/app/
|
|
325
|
-
|
|
326
|
-
|
|
332
|
+
"/apps/api/app/modules/guard/cedar_adapter/",
|
|
333
|
+
# Removed /apps/web/src/ and /docs/ — too generic; those substrings appear
|
|
334
|
+
# in ordinary Next.js apps and every project with a docs folder, which
|
|
335
|
+
# would silently disable compliance rules in customer repos (#1049).
|
|
327
336
|
)
|
|
328
337
|
|
|
338
|
+
# Sentinel file at repo root marks a Conduct source repo — required for
|
|
339
|
+
# DEV_PATH_MARKERS to activate. Without this file, doc-sensitive rules apply
|
|
340
|
+
# everywhere, including in paths that happen to match Conduct-specific
|
|
341
|
+
# subpaths. Prevents cross-project bypass of compliance rules.
|
|
342
|
+
DEV_SENTINEL_FILENAME = ".conduct-dev-repo"
|
|
343
|
+
|
|
344
|
+
_DEV_SENTINEL_CACHE: dict[str, bool] = {}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _find_dev_sentinel(start_path: str) -> bool:
|
|
348
|
+
"""Walk up from start_path looking for the DEV_SENTINEL_FILENAME marker.
|
|
349
|
+
|
|
350
|
+
Cached per starting directory. Returns False if start_path is empty or
|
|
351
|
+
the walk hits root without finding the sentinel.
|
|
352
|
+
"""
|
|
353
|
+
if not start_path:
|
|
354
|
+
return False
|
|
355
|
+
try:
|
|
356
|
+
p = Path(start_path).resolve()
|
|
357
|
+
except Exception:
|
|
358
|
+
return False
|
|
359
|
+
d = str(p.parent if not p.is_dir() else p)
|
|
360
|
+
if d in _DEV_SENTINEL_CACHE:
|
|
361
|
+
return _DEV_SENTINEL_CACHE[d]
|
|
362
|
+
cur = Path(d)
|
|
363
|
+
seen: list[str] = []
|
|
364
|
+
while True:
|
|
365
|
+
seen.append(str(cur))
|
|
366
|
+
try:
|
|
367
|
+
if (cur / DEV_SENTINEL_FILENAME).is_file():
|
|
368
|
+
for s in seen:
|
|
369
|
+
_DEV_SENTINEL_CACHE[s] = True
|
|
370
|
+
return True
|
|
371
|
+
except Exception:
|
|
372
|
+
break
|
|
373
|
+
parent = cur.parent
|
|
374
|
+
if parent == cur:
|
|
375
|
+
break
|
|
376
|
+
cur = parent
|
|
377
|
+
for s in seen:
|
|
378
|
+
_DEV_SENTINEL_CACHE[s] = False
|
|
379
|
+
return False
|
|
380
|
+
|
|
329
381
|
|
|
330
382
|
def _is_developer_source_path(tool_input: dict) -> bool:
|
|
331
|
-
"""True if the tool_input targets a path
|
|
383
|
+
"""True if the tool_input targets a path in a Conduct source-repo AND
|
|
384
|
+
matches a Conduct-specific dev marker.
|
|
332
385
|
|
|
333
|
-
|
|
334
|
-
|
|
386
|
+
Both conditions required — the sentinel file gates the exclusion so
|
|
387
|
+
customer repos with similar subpaths do not silently bypass rules.
|
|
335
388
|
"""
|
|
336
389
|
for field in ("file_path", "path"):
|
|
337
390
|
p = tool_input.get(field, "") or ""
|
|
338
391
|
if not p:
|
|
339
392
|
continue
|
|
340
|
-
for marker in DEV_PATH_MARKERS
|
|
341
|
-
|
|
342
|
-
|
|
393
|
+
matched_marker = any(marker in p for marker in DEV_PATH_MARKERS)
|
|
394
|
+
if not matched_marker:
|
|
395
|
+
continue
|
|
396
|
+
if _find_dev_sentinel(p):
|
|
397
|
+
return True
|
|
343
398
|
return False
|
|
344
399
|
|
|
345
400
|
|
|
@@ -354,6 +409,67 @@ def _rule_is_doc_sensitive(rid: str) -> bool:
|
|
|
354
409
|
return False
|
|
355
410
|
|
|
356
411
|
|
|
412
|
+
_B64_CHUNK = re.compile(r"[A-Za-z0-9+/]{20,}={0,2}")
|
|
413
|
+
_HEX_CHUNK = re.compile(r"\b[0-9a-fA-F]{40,}\b")
|
|
414
|
+
_URL_ESC = re.compile(r"%[0-9a-fA-F]{2}")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _decoded_variants(text: str) -> list[str]:
|
|
418
|
+
"""Return likely-decoded versions of text for obfuscation-bypass matching.
|
|
419
|
+
|
|
420
|
+
Applies base64, hex, URL-decode, and ROT13. Caps output to keep the scan
|
|
421
|
+
cost bounded regardless of input size. Silent on decode failure — bad
|
|
422
|
+
input just yields no extra variants.
|
|
423
|
+
"""
|
|
424
|
+
variants: list[str] = []
|
|
425
|
+
if not text:
|
|
426
|
+
return variants
|
|
427
|
+
import base64 as _b64
|
|
428
|
+
import codecs as _codecs
|
|
429
|
+
import urllib.parse as _urlparse
|
|
430
|
+
|
|
431
|
+
for m in _B64_CHUNK.findall(text)[:5]:
|
|
432
|
+
try:
|
|
433
|
+
padded = m + "=" * ((4 - len(m) % 4) % 4)
|
|
434
|
+
decoded = _b64.b64decode(padded, validate=False)
|
|
435
|
+
s = decoded.decode("utf-8", errors="ignore")
|
|
436
|
+
if s.strip():
|
|
437
|
+
variants.append(s)
|
|
438
|
+
except Exception:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
for m in _HEX_CHUNK.findall(text)[:3]:
|
|
442
|
+
try:
|
|
443
|
+
s = bytes.fromhex(m).decode("utf-8", errors="ignore")
|
|
444
|
+
if s.strip():
|
|
445
|
+
variants.append(s)
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
if _URL_ESC.search(text):
|
|
450
|
+
try:
|
|
451
|
+
s = _urlparse.unquote(text)
|
|
452
|
+
if s and s != text:
|
|
453
|
+
variants.append(s)
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
variants.append(_codecs.decode(text, "rot_13"))
|
|
459
|
+
except Exception:
|
|
460
|
+
pass
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
import unicodedata as _u
|
|
464
|
+
norm = _u.normalize("NFKC", text)
|
|
465
|
+
if norm != text:
|
|
466
|
+
variants.append(norm)
|
|
467
|
+
except Exception:
|
|
468
|
+
pass
|
|
469
|
+
|
|
470
|
+
return variants[:20]
|
|
471
|
+
|
|
472
|
+
|
|
357
473
|
def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
|
|
358
474
|
"""Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
|
|
359
475
|
pol_path = active_policy_path()
|
|
@@ -377,8 +493,15 @@ def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
|
|
|
377
493
|
rules = policy.get("rules", [])
|
|
378
494
|
if tool_name == "Bash" and tool_input.get("command"):
|
|
379
495
|
input_text = _bash_operator_signature(tool_input["command"])
|
|
496
|
+
raw_for_decode = tool_input["command"]
|
|
380
497
|
else:
|
|
381
498
|
input_text = json.dumps(tool_input)
|
|
499
|
+
raw_for_decode = input_text
|
|
500
|
+
# Obfuscation pre-decode: any variant of the input decoded from base64/
|
|
501
|
+
# hex/URL-encoding/ROT13/NFKC. Rules whose "scan" is "decoded" only match
|
|
502
|
+
# against these; all other rules match raw AND decoded so encoded payloads
|
|
503
|
+
# cannot slip past pattern-based detection.
|
|
504
|
+
decoded_variants = _decoded_variants(raw_for_decode)
|
|
382
505
|
path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
|
|
383
506
|
|
|
384
507
|
is_dev_path = _is_developer_source_path(tool_input)
|
|
@@ -420,9 +543,16 @@ def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
|
|
|
420
543
|
continue
|
|
421
544
|
pattern = rule.get("match_pattern")
|
|
422
545
|
if pattern:
|
|
546
|
+
scan_mode = rule.get("scan")
|
|
423
547
|
try:
|
|
424
|
-
if
|
|
425
|
-
|
|
548
|
+
if scan_mode == "decoded":
|
|
549
|
+
if not any(re.search(pattern, v, re.IGNORECASE) for v in decoded_variants):
|
|
550
|
+
continue
|
|
551
|
+
else:
|
|
552
|
+
if not re.search(pattern, input_text, re.IGNORECASE) and not any(
|
|
553
|
+
re.search(pattern, v, re.IGNORECASE) for v in decoded_variants
|
|
554
|
+
):
|
|
555
|
+
continue
|
|
426
556
|
except re.error:
|
|
427
557
|
continue
|
|
428
558
|
path_pattern = rule.get("match_path_pattern")
|
|
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
|
|
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
|
|
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
|
|
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
|