conduct-cli 0.6.0__tar.gz → 0.6.2__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.6.0 → conduct_cli-0.6.2}/PKG-INFO +1 -1
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/pyproject.toml +1 -1
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/guard.py +138 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/hook_session_start_template.py +59 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/hook_template.py +110 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/README.md +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/setup.cfg +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/setup.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/hook_precompact_template.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/hook_stop_template.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_log_util.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_switch.py +0 -0
|
@@ -643,6 +643,38 @@ def cmd_guard_install(args):
|
|
|
643
643
|
except Exception:
|
|
644
644
|
pass
|
|
645
645
|
|
|
646
|
+
# Write signing key if provided by the admin at onboarding time.
|
|
647
|
+
signing_key_hex = getattr(args, "signing_key", None)
|
|
648
|
+
if signing_key_hex:
|
|
649
|
+
_write_signing_key(signing_key_hex)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _write_signing_key(hex_key: str) -> None:
|
|
653
|
+
"""Write a hex-encoded signing key to ~/.conductguard/signing.key.
|
|
654
|
+
|
|
655
|
+
Validates that the path resolves within GUARD_DIR (no traversal) and
|
|
656
|
+
that the value is a valid 64-char hex string (32 bytes).
|
|
657
|
+
"""
|
|
658
|
+
import re as _re
|
|
659
|
+
signing_key_path = GUARD_DIR / "signing.key"
|
|
660
|
+
|
|
661
|
+
# Path traversal guard.
|
|
662
|
+
try:
|
|
663
|
+
resolved = signing_key_path.resolve()
|
|
664
|
+
resolved.relative_to(GUARD_DIR.resolve())
|
|
665
|
+
except (ValueError, RuntimeError):
|
|
666
|
+
print(f" {RED}Signing key path rejected (traversal check failed).{RESET}")
|
|
667
|
+
return
|
|
668
|
+
|
|
669
|
+
# Must be exactly 64 hex characters (32 bytes).
|
|
670
|
+
if not _re.fullmatch(r"[0-9a-fA-F]{64}", hex_key.strip()):
|
|
671
|
+
print(f" {RED}Invalid signing key: expected 64 hex characters.{RESET}")
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
GUARD_DIR.mkdir(parents=True, exist_ok=True)
|
|
675
|
+
signing_key_path.write_text(hex_key.strip())
|
|
676
|
+
print(f" {GREEN}Signing key written:{RESET} {signing_key_path}")
|
|
677
|
+
|
|
646
678
|
|
|
647
679
|
def cmd_guard_join(args):
|
|
648
680
|
invite_code = args.invite_code
|
|
@@ -830,6 +862,64 @@ def _report_tools_to_server() -> None:
|
|
|
830
862
|
pass # Never surface errors — this is background telemetry
|
|
831
863
|
|
|
832
864
|
|
|
865
|
+
def _check_and_upgrade_packages() -> None:
|
|
866
|
+
"""Print installed versions and pip-upgrade if PyPI has a newer release."""
|
|
867
|
+
import importlib.metadata
|
|
868
|
+
import urllib.request
|
|
869
|
+
|
|
870
|
+
packages = ["conduct-cli", "agent-booster"]
|
|
871
|
+
installed: dict[str, str] = {}
|
|
872
|
+
for pkg in packages:
|
|
873
|
+
try:
|
|
874
|
+
installed[pkg] = importlib.metadata.version(pkg)
|
|
875
|
+
except importlib.metadata.PackageNotFoundError:
|
|
876
|
+
# agent-booster may live under a different Python interpreter
|
|
877
|
+
try:
|
|
878
|
+
import subprocess as _sp, shutil
|
|
879
|
+
for py in ["python3.11", "python3.12", "python3.10"]:
|
|
880
|
+
if shutil.which(py):
|
|
881
|
+
out = _sp.check_output(
|
|
882
|
+
[py, "-c", f"import importlib.metadata; print(importlib.metadata.version('{pkg}'))"],
|
|
883
|
+
stderr=_sp.DEVNULL, text=True,
|
|
884
|
+
).strip()
|
|
885
|
+
if out:
|
|
886
|
+
installed[pkg] = out
|
|
887
|
+
break
|
|
888
|
+
else:
|
|
889
|
+
installed[pkg] = "unknown"
|
|
890
|
+
except Exception:
|
|
891
|
+
installed[pkg] = "unknown"
|
|
892
|
+
|
|
893
|
+
print(f" conduct-cli {installed['conduct-cli']} · agent-booster {installed['agent-booster']}")
|
|
894
|
+
|
|
895
|
+
stale = []
|
|
896
|
+
for pkg, current in installed.items():
|
|
897
|
+
if current == "unknown":
|
|
898
|
+
continue
|
|
899
|
+
try:
|
|
900
|
+
with urllib.request.urlopen(f"https://pypi.org/pypi/{pkg}/json", timeout=4) as r:
|
|
901
|
+
latest = __import__("json").loads(r.read())["info"]["version"]
|
|
902
|
+
if latest != current:
|
|
903
|
+
stale.append((pkg, current, latest))
|
|
904
|
+
except Exception:
|
|
905
|
+
pass # offline or PyPI down — skip silently
|
|
906
|
+
|
|
907
|
+
if not stale:
|
|
908
|
+
return
|
|
909
|
+
|
|
910
|
+
print(f" {YELLOW}Updates available:{RESET} " + ", ".join(f"{p} {c} → {l}" for p, c, l in stale))
|
|
911
|
+
try:
|
|
912
|
+
import subprocess
|
|
913
|
+
pkgs = [p for p, _, _ in stale]
|
|
914
|
+
subprocess.run(
|
|
915
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "--quiet"] + pkgs,
|
|
916
|
+
check=True,
|
|
917
|
+
)
|
|
918
|
+
print(f" {GREEN}Updated:{RESET} " + ", ".join(f"{p} → {l}" for p, _, l in stale))
|
|
919
|
+
except Exception as e:
|
|
920
|
+
print(f" {YELLOW}Auto-update failed:{RESET} {e} — run: pip install --upgrade {' '.join(p for p,_,_ in stale)}")
|
|
921
|
+
|
|
922
|
+
|
|
833
923
|
def cmd_guard_sync(args):
|
|
834
924
|
cfg = _require_guard_config()
|
|
835
925
|
workspace_id = cfg.get("workspace_id")
|
|
@@ -841,6 +931,7 @@ def cmd_guard_sync(args):
|
|
|
841
931
|
|
|
842
932
|
dry_run = getattr(args, "dry_run", False)
|
|
843
933
|
print(f"{'[dry-run] ' if dry_run else ''}Syncing policy…")
|
|
934
|
+
_check_and_upgrade_packages()
|
|
844
935
|
|
|
845
936
|
try:
|
|
846
937
|
policy = _req(
|
|
@@ -891,6 +982,24 @@ def cmd_guard_sync(args):
|
|
|
891
982
|
_save_policy(policy)
|
|
892
983
|
print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
|
|
893
984
|
|
|
985
|
+
# Refresh member token from server (workspace API key → fresh guard-mt- token).
|
|
986
|
+
# Never rely on the locally-cached value — it may be stale or missing.
|
|
987
|
+
try:
|
|
988
|
+
installed = _req(
|
|
989
|
+
"GET",
|
|
990
|
+
f"{base_url}/guard/config/installed?workspace_id={workspace_id}",
|
|
991
|
+
api_key=api_key,
|
|
992
|
+
)
|
|
993
|
+
fresh_token = installed.get("member_token") or ""
|
|
994
|
+
if fresh_token:
|
|
995
|
+
cfg["member_token"] = fresh_token
|
|
996
|
+
_save_guard_config(cfg)
|
|
997
|
+
else:
|
|
998
|
+
print(f" {YELLOW}Warning: server returned no member token — proxy env may be stale{RESET}")
|
|
999
|
+
except Exception as e:
|
|
1000
|
+
fresh_token = ""
|
|
1001
|
+
print(f" {YELLOW}Warning: could not refresh member token ({e}) — using cached value{RESET}")
|
|
1002
|
+
|
|
894
1003
|
# Write LLM proxy env vars so any AI tool (Claude Code, Cursor, Codex, …)
|
|
895
1004
|
# routes through Conduct Guard. Customer-overridable via --proxy-url or
|
|
896
1005
|
# CONDUCT_PROXY_URL env var; defaults to api.conductai.ai/proxy.
|
|
@@ -1499,6 +1608,23 @@ def cmd_guard_audit(args):
|
|
|
1499
1608
|
decision = ev.get("decision", "—")
|
|
1500
1609
|
rule = (ev.get("rule_id") or ev.get("rule_message") or "—")
|
|
1501
1610
|
|
|
1611
|
+
# policy_signature_invalid events get a distinct BLOCK prefix line.
|
|
1612
|
+
if rule == "policy_signature_invalid":
|
|
1613
|
+
hostname = ""
|
|
1614
|
+
try:
|
|
1615
|
+
import json as _j
|
|
1616
|
+
payload = _j.loads(ev.get("input_summary") or "{}")
|
|
1617
|
+
hostname = payload.get("hostname", ev.get("hostname") or "")
|
|
1618
|
+
except Exception:
|
|
1619
|
+
pass
|
|
1620
|
+
ws_slug = cfg.get("workspace_id", "")
|
|
1621
|
+
print(
|
|
1622
|
+
f" {RED}[BLOCK]{RESET} {GRAY}{ts}{RESET} "
|
|
1623
|
+
f"policy_signature_invalid "
|
|
1624
|
+
f"host={hostname} workspace={ws_slug}"
|
|
1625
|
+
)
|
|
1626
|
+
continue
|
|
1627
|
+
|
|
1502
1628
|
dec_color = RED if decision == "blocked" else GREEN if decision == "allowed" else GRAY
|
|
1503
1629
|
print(
|
|
1504
1630
|
f" {GRAY}{ts:<{ts_w}}{RESET} "
|
|
@@ -1542,6 +1668,16 @@ def register_guard_parser(sub):
|
|
|
1542
1668
|
help="Time window: 1h, 24h, 7d, 30d (default: 24h)",
|
|
1543
1669
|
)
|
|
1544
1670
|
|
|
1671
|
+
# conduct guard install [--signing-key <hex>]
|
|
1672
|
+
install_p = guard_sub.add_parser("install", help="Install Guard hook and download policies")
|
|
1673
|
+
install_p.add_argument(
|
|
1674
|
+
"--signing-key",
|
|
1675
|
+
default=None,
|
|
1676
|
+
metavar="HEX",
|
|
1677
|
+
help="Hex-encoded 32-byte signing key from POST /workspaces/{id}/signing-key. "
|
|
1678
|
+
"Written to ~/.conductguard/signing.key.",
|
|
1679
|
+
)
|
|
1680
|
+
|
|
1545
1681
|
# conduct guard booster-status
|
|
1546
1682
|
guard_sub.add_parser("booster-status", help="Verify Agent Booster intercept is active for this project")
|
|
1547
1683
|
|
|
@@ -1655,6 +1791,8 @@ def dispatch_guard(args, guard_p):
|
|
|
1655
1791
|
cmd_guard_savings(args)
|
|
1656
1792
|
elif guard_command == "audit":
|
|
1657
1793
|
cmd_guard_audit(args)
|
|
1794
|
+
elif guard_command == "install":
|
|
1795
|
+
cmd_guard_install(args)
|
|
1658
1796
|
elif guard_command == "booster-status":
|
|
1659
1797
|
cmd_guard_booster_status(args)
|
|
1660
1798
|
else:
|
|
@@ -9,12 +9,71 @@ SNAPSHOT_PATH = Path.home() / ".conductguard" / "session_snapshot.json"
|
|
|
9
9
|
MAX_AGE_HOURS = 2
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
CONDUCT_ENV_PATH = Path.home() / ".conduct" / "env"
|
|
13
|
+
GUARD_CONFIG_PATH = Path.home() / ".conductguard" / "config.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _check_proxy_token() -> None:
|
|
17
|
+
"""Warn + alert server if the proxy token is missing or malformed.
|
|
18
|
+
|
|
19
|
+
Reads ~/.conduct/env directly (not shell env) so this works regardless
|
|
20
|
+
of whether the user sourced their rc file in this session.
|
|
21
|
+
"""
|
|
22
|
+
if not CONDUCT_ENV_PATH.exists():
|
|
23
|
+
return
|
|
24
|
+
env_text = CONDUCT_ENV_PATH.read_text()
|
|
25
|
+
# Check if proxy is configured and token is valid
|
|
26
|
+
has_base_url = 'ANTHROPIC_BASE_URL=' in env_text
|
|
27
|
+
has_valid_token = 'ANTHROPIC_API_KEY="guard-mt-' in env_text or "ANTHROPIC_API_KEY='guard-mt-" in env_text
|
|
28
|
+
if not has_base_url:
|
|
29
|
+
return # proxy not configured — nothing to check
|
|
30
|
+
if has_valid_token:
|
|
31
|
+
return # all good
|
|
32
|
+
|
|
33
|
+
# Token is missing or malformed — warn locally and ping the server
|
|
34
|
+
print("## ConductGuard: proxy token missing or malformed")
|
|
35
|
+
print("Run `conduct guard sync` to refresh your token before making LLM calls.\n")
|
|
36
|
+
|
|
37
|
+
# Alert the server so the admin can prompt the developer to re-sync
|
|
38
|
+
try:
|
|
39
|
+
cfg: dict = {}
|
|
40
|
+
if GUARD_CONFIG_PATH.exists():
|
|
41
|
+
cfg = json.loads(GUARD_CONFIG_PATH.read_text())
|
|
42
|
+
workspace_id = cfg.get("workspace_id", "")
|
|
43
|
+
clerk_user_id = cfg.get("clerk_user_id", "") or cfg.get("user_email", "")
|
|
44
|
+
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
45
|
+
member_token = cfg.get("member_token", "")
|
|
46
|
+
if workspace_id and member_token:
|
|
47
|
+
import urllib.request
|
|
48
|
+
payload = json.dumps({
|
|
49
|
+
"workspace_id": workspace_id,
|
|
50
|
+
"clerk_user_id": clerk_user_id,
|
|
51
|
+
"ai_tool": "claude_code",
|
|
52
|
+
"tool_call": "session_start",
|
|
53
|
+
"decision": "warned",
|
|
54
|
+
"rule_id": "proxy_token_missing",
|
|
55
|
+
"rule_message": "Developer proxy token missing or malformed — run conduct guard sync",
|
|
56
|
+
"input_summary": "session_start: proxy token not found in ~/.conduct/env",
|
|
57
|
+
}).encode()
|
|
58
|
+
req = urllib.request.Request(
|
|
59
|
+
f"{api_url}/guard/events",
|
|
60
|
+
data=payload,
|
|
61
|
+
headers={"Content-Type": "application/json"},
|
|
62
|
+
method="POST",
|
|
63
|
+
)
|
|
64
|
+
urllib.request.urlopen(req, timeout=3)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass # best-effort — never block the session
|
|
67
|
+
|
|
68
|
+
|
|
12
69
|
def main():
|
|
13
70
|
try:
|
|
14
71
|
sys.stdin.read()
|
|
15
72
|
except Exception:
|
|
16
73
|
pass
|
|
17
74
|
|
|
75
|
+
_check_proxy_token()
|
|
76
|
+
|
|
18
77
|
if not SNAPSHOT_PATH.exists():
|
|
19
78
|
sys.exit(0)
|
|
20
79
|
|
|
@@ -38,6 +38,7 @@ BUDGET_CACHE_TTL = 300 # 5 minutes
|
|
|
38
38
|
VERSION_CACHE_PATH = GUARD_DIR / "version_cache.json"
|
|
39
39
|
VERSION_CACHE_TTL = 60 # 1 minute — matches server poll window
|
|
40
40
|
WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
|
|
41
|
+
SIGNING_KEY_PATH = GUARD_DIR / "signing.key"
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
_DAEMON_URL = "http://127.0.0.1:7878"
|
|
@@ -51,6 +52,91 @@ def _daemon_alive() -> bool:
|
|
|
51
52
|
return False
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
def _verify_policy_signature(policy_dict: dict) -> bool:
|
|
56
|
+
"""Verify HMAC-SHA256 signature on a policy dict. Returns True on success.
|
|
57
|
+
|
|
58
|
+
If SIGNING_KEY_PATH does not exist (dev mode / workspace has no signing key),
|
|
59
|
+
always returns True for backwards compatibility.
|
|
60
|
+
|
|
61
|
+
Uses hmac.compare_digest to prevent timing attacks.
|
|
62
|
+
"""
|
|
63
|
+
import hashlib as _hashlib
|
|
64
|
+
import hmac as _hmac
|
|
65
|
+
|
|
66
|
+
# Path traversal guard: ensure SIGNING_KEY_PATH resolves within GUARD_DIR.
|
|
67
|
+
try:
|
|
68
|
+
resolved = SIGNING_KEY_PATH.resolve()
|
|
69
|
+
resolved.relative_to(GUARD_DIR.resolve())
|
|
70
|
+
except (ValueError, RuntimeError):
|
|
71
|
+
return True # path outside guard dir -- skip verification
|
|
72
|
+
|
|
73
|
+
if not SIGNING_KEY_PATH.exists():
|
|
74
|
+
return True # no local key -- dev mode, backwards compat
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
raw = SIGNING_KEY_PATH.read_text().strip()
|
|
78
|
+
key_bytes = bytes.fromhex(raw)
|
|
79
|
+
except Exception:
|
|
80
|
+
return True # unreadable or malformed key -- skip verification
|
|
81
|
+
|
|
82
|
+
expected_sig = policy_dict.get("signature")
|
|
83
|
+
if not expected_sig:
|
|
84
|
+
# Policy has no signature but we have a local key -- treat as invalid.
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
# Reconstruct the canonical body the server signed.
|
|
88
|
+
body_dict = {k: v for k, v in policy_dict.items() if k not in ("signature", "signed_at")}
|
|
89
|
+
canonical = json.dumps(body_dict, sort_keys=True, separators=(",", ":"))
|
|
90
|
+
computed_sig = _hmac.new(key_bytes, canonical.encode(), _hashlib.sha256).hexdigest()
|
|
91
|
+
|
|
92
|
+
return _hmac.compare_digest(expected_sig, computed_sig)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _post_signature_invalid_event(expected_sig, computed_sig, policy_version, hostname):
|
|
96
|
+
"""Fire-and-forget audit event for a failed signature check."""
|
|
97
|
+
try:
|
|
98
|
+
cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
|
|
99
|
+
except Exception:
|
|
100
|
+
return
|
|
101
|
+
workspace_id = cfg.get("workspace_id")
|
|
102
|
+
if not workspace_id:
|
|
103
|
+
return
|
|
104
|
+
import platform as _platform
|
|
105
|
+
_hostname = hostname or _platform.node()
|
|
106
|
+
payload = json.dumps({
|
|
107
|
+
"workspace_id": workspace_id,
|
|
108
|
+
"clerk_user_id": cfg.get("user_email"),
|
|
109
|
+
"user_email": cfg.get("user_email"),
|
|
110
|
+
"ai_tool": "hook",
|
|
111
|
+
"tool_call": "policy_signature_invalid",
|
|
112
|
+
"input_summary": json.dumps({
|
|
113
|
+
"expected_signature": expected_sig or "",
|
|
114
|
+
"computed_signature": computed_sig or "",
|
|
115
|
+
"policy_version": policy_version or "",
|
|
116
|
+
"hostname": _hostname,
|
|
117
|
+
})[:500],
|
|
118
|
+
"decision": "blocked",
|
|
119
|
+
"rule_id": "policy_signature_invalid",
|
|
120
|
+
"rule_message": "Policy signature verification failed",
|
|
121
|
+
"hostname": _hostname,
|
|
122
|
+
})
|
|
123
|
+
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
124
|
+
script = (
|
|
125
|
+
"import urllib.request\n"
|
|
126
|
+
"try:\n"
|
|
127
|
+
f" req = urllib.request.Request(\"{api_url}/guard/events\","
|
|
128
|
+
f" data={repr(payload.encode())}, headers={{\"Content-Type\": \"application/json\"}}, method=\"POST\")\n"
|
|
129
|
+
" urllib.request.urlopen(req, timeout=5)\n"
|
|
130
|
+
"except: pass\n"
|
|
131
|
+
)
|
|
132
|
+
subprocess.Popen(
|
|
133
|
+
[sys.executable, "-c", script],
|
|
134
|
+
stdout=subprocess.DEVNULL,
|
|
135
|
+
stderr=subprocess.DEVNULL,
|
|
136
|
+
start_new_session=True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
54
140
|
def _maybe_sync_policy():
|
|
55
141
|
"""Sync policy from daemon (instant) or remote API (once per minute). Never raises."""
|
|
56
142
|
try:
|
|
@@ -78,6 +164,19 @@ def _maybe_sync_policy():
|
|
|
78
164
|
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"} if api_key else {})
|
|
79
165
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
80
166
|
remote = json.loads(resp.read())
|
|
167
|
+
|
|
168
|
+
# Verify signature before writing to disk. If verification fails, keep
|
|
169
|
+
# the existing cached policy and emit an audit event.
|
|
170
|
+
import platform as _platform
|
|
171
|
+
if not _verify_policy_signature(remote):
|
|
172
|
+
_post_signature_invalid_event(
|
|
173
|
+
expected_sig=remote.get("signature"),
|
|
174
|
+
computed_sig=None,
|
|
175
|
+
policy_version=remote.get("version"),
|
|
176
|
+
hostname=_platform.node(),
|
|
177
|
+
)
|
|
178
|
+
return # do not overwrite the cached policy with a tampered one
|
|
179
|
+
|
|
81
180
|
remote_version = remote.get("version", "")
|
|
82
181
|
local_version = ""
|
|
83
182
|
if POLICY_PATH.exists():
|
|
@@ -176,6 +275,17 @@ except Exception:
|
|
|
176
275
|
except Exception:
|
|
177
276
|
return None, "allow", None, None
|
|
178
277
|
|
|
278
|
+
# Verify the cached policy's signature on every hook invocation.
|
|
279
|
+
if not _verify_policy_signature(policy):
|
|
280
|
+
import platform as _platform
|
|
281
|
+
_post_signature_invalid_event(
|
|
282
|
+
expected_sig=policy.get("signature"),
|
|
283
|
+
computed_sig=None,
|
|
284
|
+
policy_version=policy.get("version"),
|
|
285
|
+
hostname=_platform.node(),
|
|
286
|
+
)
|
|
287
|
+
return None, "allow", None, None
|
|
288
|
+
|
|
179
289
|
rules = policy.get("rules", [])
|
|
180
290
|
# For Bash, match against operator signature (argv[0] + subcommand + flags) — not raw JSON.
|
|
181
291
|
# Prevents false positives where dangerous patterns appear inside quoted argument values
|
|
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
|