interlockd 0.1.0__py3-none-any.whl

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 (84) hide show
  1. interlockd/__init__.py +3 -0
  2. interlockd/__main__.py +7 -0
  3. interlockd/aibom.py +136 -0
  4. interlockd/api.py +307 -0
  5. interlockd/auth.py +38 -0
  6. interlockd/calibration.py +260 -0
  7. interlockd/cli.py +1920 -0
  8. interlockd/compose.py +84 -0
  9. interlockd/config.py +80 -0
  10. interlockd/corpus.py +505 -0
  11. interlockd/data/packs/_corpus.yaml +7 -0
  12. interlockd/data/packs/egress.jsonl +38 -0
  13. interlockd/data/packs/fileops.jsonl +20 -0
  14. interlockd/data/packs/git.jsonl +51 -0
  15. interlockd/data/packs/packages.jsonl +24 -0
  16. interlockd/data/packs/persistence.jsonl +31 -0
  17. interlockd/data/packs/secrets.jsonl +22 -0
  18. interlockd/data/packs/shell.jsonl +75 -0
  19. interlockd/det/__init__.py +53 -0
  20. interlockd/det/describe.py +66 -0
  21. interlockd/det/engine.py +153 -0
  22. interlockd/det/flow.py +106 -0
  23. interlockd/det/mandate.py +108 -0
  24. interlockd/det/mcp.py +70 -0
  25. interlockd/det/memory.py +50 -0
  26. interlockd/det/packs.py +531 -0
  27. interlockd/det/shellparse.py +438 -0
  28. interlockd/egress.py +124 -0
  29. interlockd/gateway.py +350 -0
  30. interlockd/ids.py +25 -0
  31. interlockd/insights.py +316 -0
  32. interlockd/integrations/__init__.py +2 -0
  33. interlockd/integrations/client.py +85 -0
  34. interlockd/integrations/langchain.py +59 -0
  35. interlockd/integrations/openai_agents.py +72 -0
  36. interlockd/integrations/strands.py +69 -0
  37. interlockd/judges/__init__.py +107 -0
  38. interlockd/judges/guardrails.py +94 -0
  39. interlockd/judges/local_probe.py +192 -0
  40. interlockd/judges/projector.py +82 -0
  41. interlockd/judges/strands_decider.py +160 -0
  42. interlockd/ledger.py +62 -0
  43. interlockd/mcp_http.py +216 -0
  44. interlockd/mcp_proxy.py +315 -0
  45. interlockd/mcp_server.py +211 -0
  46. interlockd/models.py +98 -0
  47. interlockd/normalize/__init__.py +1 -0
  48. interlockd/normalize/claude_code.py +89 -0
  49. interlockd/normalize/cursor.py +59 -0
  50. interlockd/normalize/gemini_cli.py +55 -0
  51. interlockd/normalize/kiro.py +65 -0
  52. interlockd/normalize/more.py +98 -0
  53. interlockd/ogr.py +542 -0
  54. interlockd/onboard.py +300 -0
  55. interlockd/otel.py +139 -0
  56. interlockd/paths.py +63 -0
  57. interlockd/pipeline.py +370 -0
  58. interlockd/policy/__init__.py +220 -0
  59. interlockd/policy_lint.py +76 -0
  60. interlockd/render.py +142 -0
  61. interlockd/sandbox.py +87 -0
  62. interlockd/scan.py +405 -0
  63. interlockd/schema/verdict.schema.json +68 -0
  64. interlockd/selftest.py +101 -0
  65. interlockd/service.py +247 -0
  66. interlockd/shims/claude-code/interlock-hook.sh +113 -0
  67. interlockd/shims/kiro/interlock-hook.mjs +101 -0
  68. interlockd/shims/kiro/interlock-hook.sh +82 -0
  69. interlockd/shims/opencode/interlock.js +60 -0
  70. interlockd/store/__init__.py +22 -0
  71. interlockd/store/drainer.py +74 -0
  72. interlockd/store/dynamodb.py +144 -0
  73. interlockd/store/memory.py +28 -0
  74. interlockd/store/spool.py +159 -0
  75. interlockd/testkit.py +401 -0
  76. interlockd/tiers.py +95 -0
  77. interlockd/transcript/__init__.py +1 -0
  78. interlockd/transcript/claude_code.py +73 -0
  79. interlockd/transcript/kiro.py +102 -0
  80. interlockd-0.1.0.dist-info/METADATA +125 -0
  81. interlockd-0.1.0.dist-info/RECORD +84 -0
  82. interlockd-0.1.0.dist-info/WHEEL +4 -0
  83. interlockd-0.1.0.dist-info/entry_points.txt +2 -0
  84. interlockd-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,531 @@
1
+ """The deterministic packs. Each `check_*` inspects a parsed command (or a file operation) and adds findings.
2
+
3
+ Categories are OGR taxonomy ids (phase1-research.md §4). Rule ids are stable strings referenced by policy
4
+ overrides and by test cases.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import os
11
+ import re
12
+ from urllib.parse import urlparse
13
+
14
+ from ..paths import is_protected, is_sensitive, is_under, normalize_path
15
+ from ..policy import Policy
16
+ from . import DetResult, rule
17
+ from .shellparse import Parsed, _words_without_data_args
18
+
19
+ # ------------------------------------------------------------------------------------------------- rules
20
+ R_PIPE_TO_SHELL = rule("shell.pipe_to_shell", "security.malicious_command", "critical", "block")
21
+ R_DECODE_EXEC = rule("shell.decode_and_execute", "security.malicious_command", "critical", "block")
22
+ R_EVAL = rule("shell.eval", "security.malicious_command", "high", "ask")
23
+ R_RM_RF_ROOTISH = rule("shell.rm_rf_dangerous_target", "security.malicious_command", "critical", "block")
24
+ R_RM_RF = rule("shell.rm_rf", "security.malicious_command", "high", "model")
25
+ R_RM_OUTSIDE = rule("shell.rm_outside_workspace", "security.malicious_command", "high", "ask")
26
+ R_DD_MKFS = rule("shell.disk_destroy", "security.malicious_command", "critical", "block")
27
+ R_CHMOD_WORLD = rule("shell.chmod_world_writable", "security.privilege_escalation", "high", "ask")
28
+ R_SUDO = rule("shell.sudo", "security.privilege_escalation", "high", "ask")
29
+ R_FORK_BOMB = rule("shell.fork_bomb", "security.resource_exhaustion", "critical", "block")
30
+ R_HISTORY_CLEAR = rule("shell.clear_history_or_logs", "security.persistence", "high", "ask")
31
+
32
+ R_GIT_FORCE_PROTECTED = rule("git.force_push_protected_branch", "security.malicious_command", "critical", "block")
33
+ R_GIT_FORCE_OWN = rule("git.force_push_own_branch", "security.malicious_command", "medium", "model")
34
+ R_GIT_PUSH_PROTECTED = rule("git.push_protected_branch", "security.malicious_command", "medium", "model")
35
+ R_GIT_PUSH_UNKNOWN_REMOTE = rule("git.push_unknown_remote", "security.data_exfiltration", "high", "ask")
36
+ R_GIT_REMOTE_ADD = rule("git.remote_add_unknown", "security.data_exfiltration", "medium", "ask")
37
+ R_GIT_DELETE_REMOTE_BRANCH = rule("git.delete_remote_branch", "security.malicious_command", "high", "ask")
38
+ R_GIT_RESET_HARD = rule("git.reset_hard", "security.malicious_command", "high", "ask")
39
+ R_GIT_CLEAN = rule("git.clean_force", "security.malicious_command", "high", "ask")
40
+ R_GIT_HISTORY = rule("git.history_rewrite", "security.malicious_command", "medium", "model")
41
+ R_GIT_SKIP_HOOKS = rule("git.skip_hooks", "security.malicious_command", "medium", "ask")
42
+ R_GIT_CREDENTIAL = rule("git.credential_config", "security.secret_leak", "high", "ask")
43
+
44
+ R_EGRESS_UNKNOWN = rule("egress.unknown_host", "security.data_exfiltration", "medium", "model")
45
+ R_EGRESS_UPLOAD_FILE = rule("egress.upload_file_body", "security.data_exfiltration", "high", "ask")
46
+ R_EGRESS_UPLOAD_SENSITIVE = rule("egress.upload_sensitive_file", "security.data_exfiltration", "critical", "block")
47
+ R_EGRESS_RAW_IP = rule("egress.raw_ip_or_port", "security.ssrf", "medium", "ask")
48
+ R_EGRESS_METADATA = rule("egress.cloud_metadata_endpoint", "security.ssrf", "critical", "block")
49
+ R_EGRESS_SHARE = rule("egress.public_sharing_service", "security.data_exfiltration", "high", "ask")
50
+ R_EGRESS_REVERSE_SHELL = rule("egress.reverse_shell", "security.malicious_command", "critical", "block")
51
+ R_EGRESS_DNS_TUNNEL = rule("egress.dns_exfil_pattern", "security.data_exfiltration", "high", "ask")
52
+
53
+ R_PERSIST_RC = rule("persistence.shell_rc", "security.persistence", "high", "ask")
54
+ R_PERSIST_CRON = rule("persistence.cron_or_scheduler", "security.persistence", "high", "ask")
55
+ R_PERSIST_SSH_KEYS = rule("persistence.authorized_keys", "security.persistence", "critical", "block")
56
+ R_PERSIST_GIT_HOOK = rule("persistence.git_hook", "security.persistence", "high", "ask")
57
+ R_PERSIST_AGENT_CONFIG = rule("persistence.agent_config_write", "security.persistence", "critical", "block")
58
+ R_PERSIST_CI = rule("persistence.ci_workflow_write", "security.persistence", "medium", "ask")
59
+ R_PERSIST_SUDOERS = rule("persistence.sudoers_or_system", "security.privilege_escalation", "critical", "block")
60
+ R_PERSIST_TRANSCRIPT = rule("persistence.transcript_write", "security.memory_poisoning", "high", "ask")
61
+
62
+ R_SECRET_IN_ARGS = rule("secrets.literal_in_arguments", "security.secret_leak", "high", "ask")
63
+ R_SECRET_READ = rule("secrets.read_sensitive_path", "security.secret_leak", "medium", "model")
64
+ R_SECRET_DUMP_ENV = rule("secrets.dump_environment", "security.secret_leak", "medium", "model")
65
+ R_SECRET_DUMP_AND_EGRESS = rule("secrets.dump_then_egress", "security.data_exfiltration", "critical", "block")
66
+ R_SECRET_IN_RESULT = rule("secrets.literal_in_result", "security.secret_leak", "high", "flag")
67
+
68
+ R_PKG_UNDECLARED = rule("packages.install_undeclared", "security.supply_chain", "medium", "model")
69
+ R_PKG_URL = rule("packages.install_from_url_or_vcs", "security.supply_chain", "high", "ask")
70
+ R_PKG_INDEX = rule("packages.alternate_index", "security.supply_chain", "high", "ask")
71
+ R_PKG_GLOBAL = rule("packages.global_or_system_install", "security.supply_chain", "medium", "ask")
72
+ R_PKG_RUN_SCRIPT = rule("packages.run_remote_script", "security.supply_chain", "high", "ask")
73
+
74
+ # ------------------------------------------------------------------------------------------------- helpers
75
+ PATH_ARG = "tool_call.arguments.command"
76
+ SHARE_HOSTS = ("gist.github.com", "pastebin.com", "paste.ee", "transfer.sh", "file.io", "0x0.st", "ix.io", "termbin.com",
77
+ "ngrok", "requestbin", "webhook.site", "pipedream.net", "hookbin", "burpcollaborator", "interact.sh", "oast")
78
+ METADATA_HOSTS = ("169.254.169.254", "metadata.google.internal", "100.100.100.200", "fd00:ec2::254")
79
+ RC_FILES = (".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".zlogin", ".config/fish/config.fish",
80
+ ".bash_login", ".kshrc")
81
+ AGENT_CONFIG_MARKERS = (".kiro/", ".claude/", ".codex/", ".interlock/", "settings.json", "hooks.json", "mcp.json",
82
+ "CLAUDE.md", "AGENTS.md", ".cursorrules", ".github/copilot-instructions.md")
83
+ SECRET_PATTERNS = [
84
+ ("aws_access_key", re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b")),
85
+ ("aws_secret_key", re.compile(r"(?i)aws_secret_access_key\s*[=:]\s*['\"]?[A-Za-z0-9/+=]{40}")),
86
+ ("github_token", re.compile(r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{60,}\b")),
87
+ ("openai_key", re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b")),
88
+ ("slack_token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
89
+ ("private_key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----")),
90
+ ("generic_assignment", re.compile(r"(?i)\b([a-z0-9_]*(?:api[_-]?key|secret|token|password|passwd)[a-z0-9_]*)\s*[=:]\s*['\"]?(?=[^\s'\"]*\d)(?=[^\s'\"]*[A-Za-z])[A-Za-z0-9/_+=.!@#$%^&*-]{12,}")),
91
+ ("bearer", re.compile(r"(?i)authorization:\s*bearer\s+[A-Za-z0-9._~+/=-]{20,}")),
92
+ ]
93
+ ENV_DUMPERS = {"env", "printenv", "set", "export", "declare"}
94
+ PACKAGE_INSTALLERS = {
95
+ "pip": ("install",), "pip3": ("install",), "uv": ("pip", "add"), "pipx": ("install", "run"), "poetry": ("add",),
96
+ "npm": ("install", "i", "add", "exec", "x"), "npx": (), "pnpm": ("add", "install", "dlx"), "yarn": ("add", "dlx"),
97
+ "bun": ("add", "install", "x"), "cargo": ("install",), "go": ("install", "get"), "gem": ("install",),
98
+ "brew": ("install", "tap"), "apt": ("install",), "apt-get": ("install",), "yum": ("install",), "dnf": ("install",),
99
+ "apk": ("add",), "conda": ("install",), "mamba": ("install",),
100
+ }
101
+ GLOBAL_FLAGS = {"-g", "--global", "--system", "--user", "--break-system-packages"}
102
+ MANIFESTS = ("package.json", "pyproject.toml", "requirements.txt", "requirements-dev.txt", "requirements.in", "Cargo.toml",
103
+ "go.mod", "Gemfile", "Pipfile", "environment.yml", "Brewfile")
104
+
105
+
106
+ def _norm_remote(s: str) -> str:
107
+ s = s.strip()
108
+ s = re.sub(r"^(https?|ssh|git)://", "", s)
109
+ s = re.sub(r"^[^@/]+@", "", s) # git@github.com:org/repo
110
+ s = s.replace(":", "/", 1) if re.match(r"^[^/]+:[^/]", s) else s
111
+ s = re.sub(r"\.git$", "", s)
112
+ return s.rstrip("/")
113
+
114
+
115
+ def _remote_allowed(remote: str, allowed: list[str]) -> bool:
116
+ r = _norm_remote(remote)
117
+ return any(fnmatch.fnmatch(r, pat) or fnmatch.fnmatch(r + "/", pat) for pat in allowed)
118
+
119
+
120
+ LOCAL_SUFFIXES = (".tgz", ".tar", ".gz", ".zip", ".whl", ".json", ".txt", ".log", ".sh", ".py", ".js", ".ts", ".md", ".yml", ".yaml",
121
+ ".toml", ".csv", ".sqlite", ".db", ".pem", ".key", ".bin", ".pdf", ".png", ".jpg", ".html", ".xml", ".env")
122
+
123
+
124
+ def _host_of(word: str) -> str | None:
125
+ w = word.strip("'\"")
126
+ if "://" in w:
127
+ try:
128
+ return (urlparse(w).hostname or "").lower() or None
129
+ except ValueError:
130
+ return None
131
+ if w.lower().endswith(LOCAL_SUFFIXES) or "/" in w.split(".")[0]:
132
+ return None
133
+ m = re.match(r"^([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\.[A-Za-z]{2,}|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?(?:/|$)", w)
134
+ return m.group(1).lower() if m else None
135
+
136
+
137
+ def _host_allowed(host: str, allowed: list[str]) -> bool:
138
+ h = host.lower()
139
+ return any(h == a or h.endswith("." + a) for a in allowed)
140
+
141
+
142
+ def _is_ip(host: str) -> bool:
143
+ return bool(re.match(r"^(?:\d{1,3}\.){3}\d{1,3}$", host)) or ":" in host
144
+
145
+
146
+ def _branch_protected(branch: str, patterns: list[str]) -> bool:
147
+ b = branch.split(":")[-1].removeprefix("refs/heads/")
148
+ return any(fnmatch.fnmatch(b, p) for p in patterns)
149
+
150
+
151
+ def _targets(words: list[str]) -> list[str]:
152
+ return [w for w in words[1:] if not w.startswith("-")]
153
+
154
+
155
+ def _looks_like_sensitive_file(word: str, cwd: str | None, policy: Policy) -> bool:
156
+ w = word.lstrip("@<").strip("'\"")
157
+ if not w or w.startswith("-"):
158
+ return False
159
+ return is_sensitive(normalize_path(w, cwd), policy.trust.sensitive_paths)
160
+
161
+
162
+ # ------------------------------------------------------------------------------------------------- shell pack
163
+ def check_shell(parsed: Parsed, res: DetResult, policy: Policy, cwd: str | None) -> None:
164
+ roots = policy.trust.workspace_roots or ([cwd] if cwd else [])
165
+ for seg in parsed.piped_to_shell:
166
+ res.add(policy, R_PIPE_TO_SHELL, path=PATH_ARG, subject=parsed.raw[:200])
167
+ for seg in parsed.decode_exec:
168
+ res.add(policy, R_DECODE_EXEC, path=PATH_ARG, subject=parsed.raw[:200])
169
+ if re.search(r":\(\)\s*\{\s*:\|:&\s*\};\s*:", parsed.code):
170
+ res.add(policy, R_FORK_BOMB, path=PATH_ARG, subject=":(){ :|:& };:")
171
+ for seg in parsed.all_segments():
172
+ w = seg.words
173
+ if not w:
174
+ continue
175
+ cmd = os.path.basename(w[0])
176
+ if cmd == "sudo":
177
+ res.add(policy, R_SUDO, path=PATH_ARG, subject=seg.raw[:120])
178
+ w = w[1:]
179
+ cmd = os.path.basename(w[0]) if w else ""
180
+ if cmd == "eval":
181
+ res.add(policy, R_EVAL, path=PATH_ARG, subject=seg.raw[:120])
182
+ if cmd == "rm":
183
+ flags = "".join(x.lstrip("-") for x in w[1:] if x.startswith("-") and not x.startswith("--"))
184
+ recursive = "r" in flags or "R" in flags or "--recursive" in w
185
+ force = "f" in flags or "--force" in w
186
+ targets = _targets(w)
187
+ system_dirs = ("/usr", "/etc", "/var", "/home", "/Users", "/bin", "/sbin", "/lib", "/opt", "/System", "/Library",
188
+ "/boot", "/root", "/Applications")
189
+ dangerous = any(t in ("/", "/*", "~", "~/", "$HOME", "$HOME/", ".", "*", "..") or t.rstrip("/") in system_dirs
190
+ or any(t.startswith(d + "/") and t.count("/") <= 2 for d in system_dirs) for t in targets)
191
+ if recursive and dangerous:
192
+ res.add(policy, R_RM_RF_ROOTISH, path=PATH_ARG, subject=seg.raw[:120])
193
+ elif recursive and force:
194
+ outside = [t for t in targets if not is_under(normalize_path(t, cwd), roots)] if roots else []
195
+ if outside:
196
+ res.add(policy, R_RM_OUTSIDE, path=PATH_ARG, subject=seg.raw[:120])
197
+ else:
198
+ res.add(policy, R_RM_RF, path=PATH_ARG, subject=seg.raw[:120])
199
+ res.action_class = "destructive"
200
+ disk_tool = cmd in ("dd", "mkfs", "shred", "wipefs", "diskutil") or cmd.startswith("mkfs.")
201
+ if disk_tool and (cmd != "dd" or any(t.startswith("of=/dev/") for t in w[1:])):
202
+ res.add(policy, R_DD_MKFS, path=PATH_ARG, subject=seg.raw[:120])
203
+ if cmd == "chmod" and any(re.fullmatch(r"-?R?\s*0?[67]77", x) or x == "a+rwx" for x in w[1:]):
204
+ res.add(policy, R_CHMOD_WORLD, path=PATH_ARG, subject=seg.raw[:120])
205
+ if (cmd == "history" and "-c" in w) or (cmd in ("rm", "truncate", "shred") and any(
206
+ re.search(r"(\.bash_history|\.zsh_history|/var/log/|\.lesshst)", t) for t in w[1:])):
207
+ res.add(policy, R_HISTORY_CLEAR, path=PATH_ARG, subject=seg.raw[:120])
208
+
209
+
210
+ # ------------------------------------------------------------------------------------------------- git pack
211
+ def check_git(parsed: Parsed, res: DetResult, policy: Policy) -> None:
212
+ for seg in parsed.all_segments():
213
+ w = _words_without_data_args(seg.words)
214
+ if not w or os.path.basename(w[0]) != "git":
215
+ continue
216
+ # skip global options like -C path
217
+ i = 1
218
+ while i < len(w) and w[i].startswith("-"):
219
+ i += 2 if w[i] in ("-C", "-c", "--git-dir", "--work-tree") else 1
220
+ if i >= len(w):
221
+ continue
222
+ sub, rest = w[i], w[i + 1:]
223
+ res.normalized.setdefault("git", []).append({"subcommand": sub, "args": rest})
224
+ if sub == "push":
225
+ force = any(f in rest for f in ("--force", "-f", "--force-with-lease")) or any(
226
+ f.startswith("--force-with-lease") for f in rest) or any(r.startswith("+") for r in rest)
227
+ delete = "--delete" in rest or "-d" in rest or any(r.startswith(":") and len(r) > 1 for r in rest)
228
+ no_verify = "--no-verify" in rest
229
+ positional = [r for r in rest if not r.startswith("-") and not r.startswith("+")]
230
+ remote = positional[0] if positional else "origin"
231
+ refs = [r.lstrip("+") for r in rest if not r.startswith("-")][1:] or []
232
+ branch = refs[0] if refs else "<current>"
233
+ res.action_class = "vcs_history_or_remote"
234
+ res.normalized["git_push"] = {"remote": remote, "branch": branch, "force": force, "delete": delete}
235
+ if delete:
236
+ res.add(policy, R_GIT_DELETE_REMOTE_BRANCH, path=PATH_ARG, subject=seg.raw[:120])
237
+ if force and any(_branch_protected(b, policy.trust.protected_branches) for b in refs):
238
+ res.add(policy, R_GIT_FORCE_PROTECTED, path=PATH_ARG, subject=seg.raw[:120])
239
+ elif force:
240
+ res.add(policy, R_GIT_FORCE_OWN, path=PATH_ARG, subject=seg.raw[:120])
241
+ elif any(_branch_protected(b, policy.trust.protected_branches) for b in refs):
242
+ res.add(policy, R_GIT_PUSH_PROTECTED, path=PATH_ARG, subject=seg.raw[:120])
243
+ if no_verify:
244
+ res.add(policy, R_GIT_SKIP_HOOKS, path=PATH_ARG, subject=seg.raw[:120])
245
+ looks_like_url = "/" in remote or ":" in remote or "." in remote
246
+ if looks_like_url and policy.trust.allowed_git_remotes and not _remote_allowed(remote, policy.trust.allowed_git_remotes):
247
+ res.add(policy, R_GIT_PUSH_UNKNOWN_REMOTE, path=PATH_ARG, subject=remote)
248
+ elif sub == "remote" and rest[:1] == ["add"] and len(rest) >= 3:
249
+ url = rest[2]
250
+ res.normalized["git_remote_add"] = {"name": rest[1], "url": url}
251
+ if policy.trust.allowed_git_remotes and not _remote_allowed(url, policy.trust.allowed_git_remotes):
252
+ res.add(policy, R_GIT_REMOTE_ADD, path=PATH_ARG, subject=url)
253
+ elif sub == "reset" and "--hard" in rest:
254
+ res.action_class = "destructive"
255
+ res.add(policy, R_GIT_RESET_HARD, path=PATH_ARG, subject=seg.raw[:120])
256
+ elif sub == "clean" and any(re.fullmatch(r"-[a-zA-Z]*f[a-zA-Z]*", f) or f == "--force" for f in rest):
257
+ res.action_class = "destructive"
258
+ res.add(policy, R_GIT_CLEAN, path=PATH_ARG, subject=seg.raw[:120])
259
+ elif sub in ("filter-branch", "filter-repo", "replace") or (sub == "rebase" and ("-i" in rest or "--interactive" in rest or "--root" in rest)) \
260
+ or (sub == "commit" and "--amend" in rest) or (sub == "branch" and ("-D" in rest or "--delete" in rest and "--force" in rest)):
261
+ res.action_class = "vcs_history_or_remote"
262
+ res.add(policy, R_GIT_HISTORY, path=PATH_ARG, subject=seg.raw[:120])
263
+ elif sub == "commit" and "--no-verify" in rest:
264
+ res.add(policy, R_GIT_SKIP_HOOKS, path=PATH_ARG, subject=seg.raw[:120])
265
+ elif sub == "config" and any("credential" in r or "askpass" in r.lower() or "sshcommand" in r.lower() for r in rest):
266
+ res.add(policy, R_GIT_CREDENTIAL, path=PATH_ARG, subject=seg.raw[:120])
267
+
268
+
269
+ # ------------------------------------------------------------------------------------------------- egress pack
270
+ def check_egress(parsed: Parsed, res: DetResult, policy: Policy, cwd: str | None) -> None:
271
+ hosts: list[str] = []
272
+ for seg in parsed.all_segments():
273
+ w = seg.words
274
+ if not w:
275
+ continue
276
+ cmd = os.path.basename(w[0])
277
+ if cmd in ("curl", "wget", "http", "https", "fetch", "aria2c", "scp", "rsync", "ssh", "sftp", "nc", "ncat", "netcat",
278
+ "socat", "telnet", "openssl", "ftp", "git", "pip", "pip3", "npm", "gh", "aws", "gcloud", "az", "nslookup", "dig"):
279
+ res.action_class = res.action_class if res.action_class != "other" else "network_egress"
280
+ if cmd in ("curl", "wget", "http", "https", "fetch", "aria2c"):
281
+ upload_file = None
282
+ for i, x in enumerate(w[1:], 1):
283
+ if x in ("-d", "--data", "--data-binary", "--data-raw", "--data-urlencode", "-F", "--form", "-T", "--upload-file",
284
+ "--post-file", "--body-file") and i + 1 < len(w):
285
+ v = w[i + 1]
286
+ if v.startswith("@") or x in ("-T", "--upload-file", "--post-file", "--body-file") or (x in ("-F", "--form") and "@" in v):
287
+ upload_file = v.split("@", 1)[-1] if "@" in v else v
288
+ elif x.startswith("--data") and "=@" in x or (x.startswith("-d@")):
289
+ upload_file = x.split("@", 1)[-1]
290
+ urls = [x for x in w[1:] if _host_of(x)]
291
+ for u in urls:
292
+ h = _host_of(u)
293
+ if h:
294
+ hosts.append(h)
295
+ if h in METADATA_HOSTS or h.startswith("169.254."):
296
+ res.add(policy, R_EGRESS_METADATA, path=PATH_ARG, subject=h)
297
+ elif any(s in h for s in SHARE_HOSTS):
298
+ res.add(policy, R_EGRESS_SHARE, path=PATH_ARG, subject=h)
299
+ elif _is_ip(h) and not _host_allowed(h, policy.trust.allowed_hosts):
300
+ res.add(policy, R_EGRESS_RAW_IP, path=PATH_ARG, subject=h)
301
+ elif not _host_allowed(h, policy.trust.allowed_hosts):
302
+ res.add(policy, R_EGRESS_UNKNOWN, path=PATH_ARG, subject=h)
303
+ if upload_file is not None:
304
+ sensitive = _looks_like_sensitive_file(upload_file, cwd, policy) or upload_file.strip("'\"") in ("-",)
305
+ if sensitive:
306
+ res.add(policy, R_EGRESS_UPLOAD_SENSITIVE, path=PATH_ARG, subject=upload_file)
307
+ elif not urls or any(not _host_allowed(_host_of(u) or "", policy.trust.allowed_hosts) for u in urls):
308
+ res.add(policy, R_EGRESS_UPLOAD_FILE, path=PATH_ARG, subject=upload_file)
309
+ elif cmd in ("scp", "rsync", "sftp"):
310
+ for x in w[1:]:
311
+ if ":" in x and not x.startswith("-") and not os.path.exists(x.split(":")[0]):
312
+ h = x.split(":")[0].split("@")[-1]
313
+ hosts.append(h)
314
+ if not _host_allowed(h, policy.trust.allowed_hosts):
315
+ res.add(policy, R_EGRESS_UNKNOWN, path=PATH_ARG, subject=h)
316
+ if any(_looks_like_sensitive_file(x, cwd, policy) for x in w[1:] if not x.startswith("-")):
317
+ res.add(policy, R_EGRESS_UPLOAD_SENSITIVE, path=PATH_ARG, subject=seg.raw[:120])
318
+ elif cmd in ("nc", "ncat", "netcat", "socat", "telnet"):
319
+ if any(f in w for f in ("-e", "--exec", "-c")) or re.search(r"/dev/tcp/|exec \d+<>|0>&1|>&\s*/dev/tcp", parsed.code) \
320
+ or any("exec:" in x.lower() for x in w):
321
+ res.add(policy, R_EGRESS_REVERSE_SHELL, path=PATH_ARG, subject=seg.raw[:120])
322
+ else:
323
+ target = [x for x in w[1:] if not x.startswith("-")]
324
+ if target and not _host_allowed(target[0], policy.trust.allowed_hosts):
325
+ res.add(policy, R_EGRESS_RAW_IP if _is_ip(target[0]) else R_EGRESS_UNKNOWN, path=PATH_ARG, subject=target[0])
326
+ elif cmd in ("gh",) and w[1:3] == ["gist", "create"] or (cmd == "gh" and "gist" in w and "create" in w):
327
+ res.add(policy, R_EGRESS_SHARE, path=PATH_ARG, subject=seg.raw[:120])
328
+ elif cmd in ("nslookup", "dig", "host") and re.search(r"\$\(|`|base64|xxd", seg.raw):
329
+ res.add(policy, R_EGRESS_DNS_TUNNEL, path=PATH_ARG, subject=seg.raw[:120])
330
+ if re.search(r"/dev/tcp/[\w.-]+/\d+", parsed.code):
331
+ res.add(policy, R_EGRESS_REVERSE_SHELL, path=PATH_ARG, subject=parsed.raw[:120])
332
+ if hosts:
333
+ res.normalized["egress_hosts"] = sorted(set(hosts))
334
+
335
+
336
+ # ------------------------------------------------------------------------------------------------- persistence pack
337
+ def _persistence_path_check(path: str, res: DetResult, policy: Policy, roots: list[str], where: str) -> None:
338
+ p = path
339
+ base = os.path.basename(p)
340
+ rel_home = p.replace(os.path.expanduser("~"), "~", 1) if p.startswith(os.path.expanduser("~")) else p
341
+ if base in RC_FILES or any(rel_home.endswith(r) for r in RC_FILES) or "/etc/profile" in p or "/etc/bash" in p or "/etc/zsh" in p:
342
+ res.add(policy, R_PERSIST_RC, path=where, subject=p)
343
+ if "authorized_keys" in p or (rel_home.startswith("~/.ssh/") and base in ("config", "rc") and where != "read"):
344
+ res.add(policy, R_PERSIST_SSH_KEYS, path=where, subject=p)
345
+ if any(k in p for k in ("/crontab", "/cron.d/", "/etc/cron", "LaunchAgents", "LaunchDaemons", "/systemd/", ".config/autostart", "/etc/rc.local", "/etc/init.d")):
346
+ res.add(policy, R_PERSIST_CRON, path=where, subject=p)
347
+ if "/.git/hooks/" in p or p.endswith("/.git/config") or "/.git/config" in p:
348
+ res.add(policy, R_PERSIST_GIT_HOOK, path=where, subject=p)
349
+ home = os.path.expanduser("~")
350
+ agent_home_dirs = (f"{home}/.kiro/", f"{home}/.claude/", f"{home}/.codex/", f"{home}/.interlock/", f"{home}/.cursor/", f"{home}/.gemini/")
351
+ # runtime artifacts (logs, pids, the spool, session transcripts' caches) are not configuration; writing them is normal
352
+ runtime_artifact = p.endswith((".log", ".pid", ".jsonl", ".drained", ".lock", ".tmp", "nohup.out")) or "/spool/" in p \
353
+ or "/logs/" in p or "/sessions/" in p or "/session-index/" in p or "/tool-outputs/" in p
354
+ protected_agent = not runtime_artifact and (
355
+ (is_protected(p, roots, policy.trust.protected_paths) and any(m in p for m in AGENT_CONFIG_MARKERS))
356
+ or p.startswith(agent_home_dirs) or "/.kiro/" in p or "/.claude/" in p or "/.codex/" in p or "/.interlock/" in p)
357
+ if protected_agent:
358
+ res.add(policy, R_PERSIST_AGENT_CONFIG, path=where, subject=p)
359
+ elif ("/sessions/" in p or "/.claude/projects/" in p) and p.endswith((".jsonl", ".json")) and (
360
+ "/.kiro/" in p or "/.claude/" in p or "/.codex/" in p):
361
+ res.add(policy, R_PERSIST_TRANSCRIPT, path=where, subject=p)
362
+ elif ".github/workflows" in p:
363
+ res.add(policy, R_PERSIST_CI, path=where, subject=p)
364
+ if p.startswith(("/etc/sudoers", "/etc/passwd", "/etc/shadow", "/etc/pam.d", "/Library/LaunchDaemons", "/etc/hosts")):
365
+ res.add(policy, R_PERSIST_SUDOERS, path=where, subject=p)
366
+ if not protected_agent: # instruction files read back into every future session
367
+ from .memory import check_instruction_write
368
+
369
+ check_instruction_write(p, None, res, policy, where)
370
+
371
+
372
+ def check_persistence_shell(parsed: Parsed, res: DetResult, policy: Policy, cwd: str | None) -> None:
373
+ roots = policy.trust.workspace_roots or ([cwd] if cwd else [])
374
+ raw = parsed.code
375
+ # redirections and tee targets
376
+ for m in re.finditer(r"(?:>>?|\btee\s+(?:-a\s+)?)\s*(['\"]?)([^\s'\"|;&()]+)\1", raw):
377
+ _persistence_path_check(normalize_path(m.group(2), cwd), res, policy, roots, PATH_ARG)
378
+ for seg in parsed.all_segments():
379
+ w = seg.words
380
+ if not w:
381
+ continue
382
+ if w[0] == "sudo" and len(w) > 1:
383
+ w = w[1:]
384
+ cmd = os.path.basename(w[0])
385
+ if cmd == "crontab" and ("-" in w or "-e" in w or any(not x.startswith("-") for x in w[1:])):
386
+ res.add(policy, R_PERSIST_CRON, path=PATH_ARG, subject=seg.raw[:120])
387
+ if cmd in ("launchctl", "systemctl") and any(x in w for x in ("load", "bootstrap", "enable", "start", "--now")):
388
+ res.add(policy, R_PERSIST_CRON, path=PATH_ARG, subject=seg.raw[:120])
389
+ if cmd in ("ssh-add", "ssh-copy-id"):
390
+ res.add(policy, R_PERSIST_SSH_KEYS, path=PATH_ARG, subject=seg.raw[:120])
391
+ if cmd in ("cp", "mv", "install", "ln", "touch", "chmod", "defaults") or (cmd == "sed" and any(x.startswith("-i") for x in w[1:])):
392
+ for x in w[1:]:
393
+ if not x.startswith("-") and ("/" in x or x.startswith(".")) and not x.startswith("s/"):
394
+ _persistence_path_check(normalize_path(x, cwd), res, policy, roots, PATH_ARG)
395
+ if cmd == "git" and "config" in w and "--global" in w and any("core.hookspath" in x.lower() or "alias." in x.lower() for x in w):
396
+ res.add(policy, R_PERSIST_GIT_HOOK, path=PATH_ARG, subject=seg.raw[:120])
397
+
398
+
399
+ def check_persistence_file_op(path: str, res: DetResult, policy: Policy, cwd: str | None, where: str) -> None:
400
+ roots = policy.trust.workspace_roots or ([cwd] if cwd else [])
401
+ _persistence_path_check(path, res, policy, roots, where)
402
+
403
+
404
+ # ------------------------------------------------------------------------------------------------- secrets pack
405
+ PLACEHOLDER = re.compile(r"(?i)(your[_-]|example|placeholder|changeme|xxxx|<[^>]+>|\$\{?[A-Z_]+\}?$|dummy|sample)")
406
+
407
+
408
+ def scan_secrets(text: str, where: str, res: DetResult, policy: Policy, rule_id: str) -> list[str]:
409
+ kinds: list[str] = []
410
+ for kind, pat in SECRET_PATTERNS:
411
+ m = pat.search(text)
412
+ if m and (kind in ("private_key", "aws_access_key", "github_token", "slack_token") or not PLACEHOLDER.search(m.group(0))):
413
+ kinds.append(kind)
414
+ res.add(policy, rule_id, path=where, start=m.start(), end=m.end(), subject=f"{kind}: {m.group(0)[:6]}…")
415
+ return kinds
416
+
417
+
418
+ def check_secrets_shell(parsed: Parsed, res: DetResult, policy: Policy, cwd: str | None) -> None:
419
+ scan_secrets(parsed.code, PATH_ARG, res, policy, R_SECRET_IN_ARGS)
420
+ for body in parsed.data_heredocs: # file contents written via heredoc: secrets matter, commands inside do not
421
+ scan_secrets(body, PATH_ARG, res, policy, R_SECRET_IN_ARGS)
422
+ reads_sensitive = False
423
+ dumps_env = False
424
+ for seg in parsed.all_segments():
425
+ w = seg.words
426
+ if not w:
427
+ continue
428
+ if w[0] == "sudo" and len(w) > 1:
429
+ w = w[1:]
430
+ cmd = os.path.basename(w[0])
431
+ if cmd in ("cat", "less", "more", "head", "tail", "bat", "cp", "base64", "xxd", "strings", "grep", "rg", "awk", "sed", "source", "."):
432
+ for x in w[1:]:
433
+ if not x.startswith("-") and _looks_like_sensitive_file(x, cwd, policy):
434
+ reads_sensitive = True
435
+ res.add(policy, R_SECRET_READ, path=PATH_ARG, subject=x)
436
+ res.action_class = "credential_access"
437
+ if cmd in ENV_DUMPERS and len(w) == 1 or (cmd in ("env", "printenv") and len(w) <= 2) or (cmd == "env" and re.search(r"\benv\s*[>|)]", parsed.code)) or \
438
+ (cmd in ("grep", "rg") and any(re.search(r"(?i)token|secret|key|passw", x) for x in w[1:]) and ("env" in parsed.code or "printenv" in parsed.code)):
439
+ dumps_env = True
440
+ res.add(policy, R_SECRET_DUMP_ENV, path=PATH_ARG, subject=seg.raw[:120])
441
+ res.action_class = "credential_access"
442
+ if cmd in ("security", "keychain-dump", "lazagne", "mimikatz") or (cmd == "security" and "find-generic-password" in w):
443
+ dumps_env = True
444
+ res.add(policy, R_SECRET_DUMP_ENV, path=PATH_ARG, subject=seg.raw[:120])
445
+ res.action_class = "credential_access"
446
+ egress = bool(res.normalized.get("egress_hosts")) or any(
447
+ c in parsed.commands() for c in ("curl", "wget", "nc", "ncat", "scp", "ssh", "rsync", "nslookup", "dig", "host", "socat"))
448
+ if (reads_sensitive or dumps_env) and egress:
449
+ res.add(policy, R_SECRET_DUMP_AND_EGRESS, path=PATH_ARG, subject=parsed.raw[:160])
450
+
451
+
452
+ # ------------------------------------------------------------------------------------------------- packages pack
453
+ def _declared_packages(cwd: str | None, roots: list[str]) -> set[str] | None:
454
+ """Best-effort read of manifests at the workspace root. None = no manifest found (cannot judge)."""
455
+ found: set[str] = set()
456
+ seen_manifest = False
457
+ for root in [cwd, *roots]:
458
+ if not root:
459
+ continue
460
+ for name in MANIFESTS:
461
+ p = os.path.join(root, name)
462
+ if not os.path.isfile(p):
463
+ continue
464
+ seen_manifest = True
465
+ try:
466
+ with open(p, encoding="utf-8", errors="ignore") as fh:
467
+ text = fh.read()
468
+ except OSError:
469
+ continue
470
+ if name == "package.json":
471
+ found.update(re.findall(r'"(@?[A-Za-z0-9_./-]+)"\s*:\s*"[^"]*"', text))
472
+ elif name in ("pyproject.toml", "Pipfile", "Cargo.toml"):
473
+ found.update(m.lower() for m in re.findall(r'^\s*"?([A-Za-z0-9_.-]+)"?\s*(?:=|>=|<=|~=|==)', text, re.MULTILINE))
474
+ found.update(m.lower() for m in re.findall(r'"([A-Za-z0-9_.-]+)(?:\[[^\]]*\])?\s*[><=~!]', text))
475
+ elif name.startswith("requirements"):
476
+ found.update(m.lower() for m in re.findall(r"^\s*([A-Za-z0-9_.-]+)", text, re.MULTILINE))
477
+ elif name == "go.mod":
478
+ found.update(re.findall(r"^\s*([\w./-]+)\s+v", text, re.MULTILINE))
479
+ elif name == "Gemfile":
480
+ found.update(re.findall(r"gem\s+['\"]([^'\"]+)['\"]", text))
481
+ return found if seen_manifest else None
482
+
483
+
484
+ def check_packages(parsed: Parsed, res: DetResult, policy: Policy, cwd: str | None) -> None:
485
+ roots = policy.trust.workspace_roots or ([cwd] if cwd else [])
486
+ declared: set[str] | None | bool = False # lazy
487
+ for seg in parsed.all_segments():
488
+ w = seg.words
489
+ if not w:
490
+ continue
491
+ if w[0] == "sudo" and len(w) > 1:
492
+ w = w[1:]
493
+ cmd = os.path.basename(w[0])
494
+ if cmd not in PACKAGE_INSTALLERS:
495
+ continue
496
+ subs = PACKAGE_INSTALLERS[cmd]
497
+ if subs and not (len(w) > 1 and w[1] in subs) and not (cmd == "uv" and w[1:3] == ["pip", "install"]):
498
+ continue
499
+ res.action_class = "install_or_configure"
500
+ args = w[2:] if subs else w[1:]
501
+ if cmd == "uv" and w[1:3] == ["pip", "install"]:
502
+ args = w[3:]
503
+ if cmd in ("npx", "pnpm", "yarn", "bun") and (not subs or (len(w) > 1 and w[1] in ("dlx", "x", "exec"))):
504
+ res.add(policy, R_PKG_RUN_SCRIPT, path=PATH_ARG, subject=seg.raw[:120])
505
+ if any(f in w for f in GLOBAL_FLAGS) or cmd in ("apt", "apt-get", "yum", "dnf", "apk", "brew"):
506
+ res.add(policy, R_PKG_GLOBAL, path=PATH_ARG, subject=seg.raw[:120])
507
+ index_values: set[str] = set()
508
+ for i, a in enumerate(args):
509
+ if a in ("--index-url", "-i", "--extra-index-url", "--registry", "--find-links", "-f") and i + 1 < len(args):
510
+ idx = args[i + 1]
511
+ index_values.add(idx)
512
+ if not any(idx.rstrip("/").startswith(x.rstrip("/")) for x in policy.trust.allowed_package_indexes):
513
+ res.add(policy, R_PKG_INDEX, path=PATH_ARG, subject=idx)
514
+ for a in args:
515
+ if a in index_values:
516
+ continue
517
+ if re.match(r"^(git\+|https?://|ssh://|git@|github:|file:)", a) or a.endswith((".git", ".whl", ".tar.gz", ".tgz")):
518
+ res.add(policy, R_PKG_URL, path=PATH_ARG, subject=a)
519
+ names = [re.split(r"[=<>!~@\[]", a, 1)[0] for a in args if a and not a.startswith("-") and a not in index_values
520
+ and not re.match(r"^(git\+|https?://|ssh://|git@|file:)", a) and a not in ("-r", "--requirement")]
521
+ if names and policy.trust.declared_packages_only and cmd in ("pip", "pip3", "uv", "npm", "pnpm", "yarn", "bun", "poetry", "cargo", "gem"):
522
+ if "-r" in args or "--requirement" in args or any(a.endswith((".txt", ".lock")) for a in args):
523
+ continue # installing from the manifest itself
524
+ if declared is False:
525
+ declared = _declared_packages(cwd, roots)
526
+ if declared is None:
527
+ continue # no manifest to compare against: cannot judge, do not guess
528
+ undeclared = [n for n in names if n.lower() not in {d.lower() for d in declared} and n not in (".", "-e", "--editable")]
529
+ if undeclared:
530
+ res.add(policy, R_PKG_UNDECLARED, path=PATH_ARG, subject=", ".join(undeclared)[:120])
531
+ res.normalized["undeclared_packages"] = undeclared