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.
- interlockd/__init__.py +3 -0
- interlockd/__main__.py +7 -0
- interlockd/aibom.py +136 -0
- interlockd/api.py +307 -0
- interlockd/auth.py +38 -0
- interlockd/calibration.py +260 -0
- interlockd/cli.py +1920 -0
- interlockd/compose.py +84 -0
- interlockd/config.py +80 -0
- interlockd/corpus.py +505 -0
- interlockd/data/packs/_corpus.yaml +7 -0
- interlockd/data/packs/egress.jsonl +38 -0
- interlockd/data/packs/fileops.jsonl +20 -0
- interlockd/data/packs/git.jsonl +51 -0
- interlockd/data/packs/packages.jsonl +24 -0
- interlockd/data/packs/persistence.jsonl +31 -0
- interlockd/data/packs/secrets.jsonl +22 -0
- interlockd/data/packs/shell.jsonl +75 -0
- interlockd/det/__init__.py +53 -0
- interlockd/det/describe.py +66 -0
- interlockd/det/engine.py +153 -0
- interlockd/det/flow.py +106 -0
- interlockd/det/mandate.py +108 -0
- interlockd/det/mcp.py +70 -0
- interlockd/det/memory.py +50 -0
- interlockd/det/packs.py +531 -0
- interlockd/det/shellparse.py +438 -0
- interlockd/egress.py +124 -0
- interlockd/gateway.py +350 -0
- interlockd/ids.py +25 -0
- interlockd/insights.py +316 -0
- interlockd/integrations/__init__.py +2 -0
- interlockd/integrations/client.py +85 -0
- interlockd/integrations/langchain.py +59 -0
- interlockd/integrations/openai_agents.py +72 -0
- interlockd/integrations/strands.py +69 -0
- interlockd/judges/__init__.py +107 -0
- interlockd/judges/guardrails.py +94 -0
- interlockd/judges/local_probe.py +192 -0
- interlockd/judges/projector.py +82 -0
- interlockd/judges/strands_decider.py +160 -0
- interlockd/ledger.py +62 -0
- interlockd/mcp_http.py +216 -0
- interlockd/mcp_proxy.py +315 -0
- interlockd/mcp_server.py +211 -0
- interlockd/models.py +98 -0
- interlockd/normalize/__init__.py +1 -0
- interlockd/normalize/claude_code.py +89 -0
- interlockd/normalize/cursor.py +59 -0
- interlockd/normalize/gemini_cli.py +55 -0
- interlockd/normalize/kiro.py +65 -0
- interlockd/normalize/more.py +98 -0
- interlockd/ogr.py +542 -0
- interlockd/onboard.py +300 -0
- interlockd/otel.py +139 -0
- interlockd/paths.py +63 -0
- interlockd/pipeline.py +370 -0
- interlockd/policy/__init__.py +220 -0
- interlockd/policy_lint.py +76 -0
- interlockd/render.py +142 -0
- interlockd/sandbox.py +87 -0
- interlockd/scan.py +405 -0
- interlockd/schema/verdict.schema.json +68 -0
- interlockd/selftest.py +101 -0
- interlockd/service.py +247 -0
- interlockd/shims/claude-code/interlock-hook.sh +113 -0
- interlockd/shims/kiro/interlock-hook.mjs +101 -0
- interlockd/shims/kiro/interlock-hook.sh +82 -0
- interlockd/shims/opencode/interlock.js +60 -0
- interlockd/store/__init__.py +22 -0
- interlockd/store/drainer.py +74 -0
- interlockd/store/dynamodb.py +144 -0
- interlockd/store/memory.py +28 -0
- interlockd/store/spool.py +159 -0
- interlockd/testkit.py +401 -0
- interlockd/tiers.py +95 -0
- interlockd/transcript/__init__.py +1 -0
- interlockd/transcript/claude_code.py +73 -0
- interlockd/transcript/kiro.py +102 -0
- interlockd-0.1.0.dist-info/METADATA +125 -0
- interlockd-0.1.0.dist-info/RECORD +84 -0
- interlockd-0.1.0.dist-info/WHEEL +4 -0
- interlockd-0.1.0.dist-info/entry_points.txt +2 -0
- interlockd-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Shell command decomposition: segments, words, pipes, heredocs, interpreter one-liners, decode-and-exec.
|
|
2
|
+
|
|
3
|
+
This is a pragmatic tokenizer, not a full POSIX grammar. It must never raise: any input yields a list of
|
|
4
|
+
segments, each a list of words, and the raw text is always available to the packs as a fallback.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import binascii
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shlex
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
CONTROL = re.compile(r"(\|\||&&|;|\||\n)")
|
|
17
|
+
INTERPRETERS = {
|
|
18
|
+
"bash": ("-c",), "sh": ("-c",), "zsh": ("-c",), "dash": ("-c",), "ksh": ("-c",),
|
|
19
|
+
"python": ("-c",), "python3": ("-c",), "python2": ("-c",), "node": ("-e", "--eval", "-p"), "ruby": ("-e",),
|
|
20
|
+
"perl": ("-e", "-E"), "php": ("-r",), "osascript": ("-e",),
|
|
21
|
+
}
|
|
22
|
+
SHELLS = {"sh", "bash", "zsh", "dash", "ksh", "fish"}
|
|
23
|
+
B64 = re.compile(r"^[A-Za-z0-9+/=_-]{16,}$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Segment:
|
|
28
|
+
raw: str
|
|
29
|
+
words: list[str]
|
|
30
|
+
heredoc: str | None = None
|
|
31
|
+
inner: list[Segment] = field(default_factory=list) # programs passed to interpreters, decoded payloads
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def cmd(self) -> str:
|
|
35
|
+
return self.words[0] if self.words else ""
|
|
36
|
+
|
|
37
|
+
def flat(self) -> list[Segment]:
|
|
38
|
+
out = [self]
|
|
39
|
+
for s in self.inner:
|
|
40
|
+
out.extend(s.flat())
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Parsed:
|
|
46
|
+
raw: str
|
|
47
|
+
segments: list[Segment]
|
|
48
|
+
piped_to_shell: list[Segment] = field(default_factory=list) # "curl … | sh" style
|
|
49
|
+
decode_exec: list[Segment] = field(default_factory=list) # "echo … | base64 -d | sh"
|
|
50
|
+
data_heredocs: list[str] = field(default_factory=list) # heredoc bodies written to files, not executed
|
|
51
|
+
code: str = "" # the command text with data heredocs removed
|
|
52
|
+
|
|
53
|
+
def all_segments(self) -> list[Segment]:
|
|
54
|
+
out: list[Segment] = []
|
|
55
|
+
for s in self.segments:
|
|
56
|
+
out.extend(s.flat())
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
def commands(self) -> list[str]:
|
|
60
|
+
return [s.cmd for s in self.all_segments() if s.cmd]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _strip_env_prefix(words: list[str]) -> list[str]:
|
|
64
|
+
i = 0
|
|
65
|
+
while i < len(words) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", words[i]):
|
|
66
|
+
i += 1
|
|
67
|
+
if i < len(words) and words[i] in ("env", "sudo", "doas", "command", "exec", "nohup", "time", "nice", "xargs"):
|
|
68
|
+
# keep sudo visible to the privilege pack; a bare `env` (no following command) is a dump, keep it too
|
|
69
|
+
if words[i] == "sudo" or (words[i] == "env" and (i + 1 >= len(words) or words[i + 1].startswith((">", "|")))):
|
|
70
|
+
return words[i:]
|
|
71
|
+
return _strip_env_prefix(words[i + 1:]) if i + 1 < len(words) else words[i:]
|
|
72
|
+
return words[i:]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
HEREDOC_OPEN = re.compile(r"<<-?\s*(['\"]?)(\w+)\1")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def split_heredocs(text: str) -> tuple[str, list[tuple[str, str]]]:
|
|
79
|
+
"""Return (text with every heredoc body removed, [(consumer_line, body), ...]) in order of appearance.
|
|
80
|
+
|
|
81
|
+
The consumer line is the command line carrying the `<<TAG` operator; the body runs from the next line to
|
|
82
|
+
the matching terminator. An unterminated heredoc runs to the end of the text.
|
|
83
|
+
"""
|
|
84
|
+
out_lines: list[str] = []
|
|
85
|
+
docs: list[tuple[str, str]] = []
|
|
86
|
+
lines = text.split("\n")
|
|
87
|
+
i = 0
|
|
88
|
+
while i < len(lines):
|
|
89
|
+
line = lines[i]
|
|
90
|
+
m = HEREDOC_OPEN.search(line)
|
|
91
|
+
if not m:
|
|
92
|
+
out_lines.append(line)
|
|
93
|
+
i += 1
|
|
94
|
+
continue
|
|
95
|
+
tag = m.group(2)
|
|
96
|
+
body: list[str] = []
|
|
97
|
+
j = i + 1
|
|
98
|
+
while j < len(lines) and lines[j].strip() != tag:
|
|
99
|
+
body.append(lines[j])
|
|
100
|
+
j += 1
|
|
101
|
+
docs.append((line, "\n".join(body)))
|
|
102
|
+
out_lines.append(line)
|
|
103
|
+
i = j + 1
|
|
104
|
+
return "\n".join(out_lines), docs
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _split_heredoc(text: str) -> tuple[str, str | None]:
|
|
108
|
+
"""Compatibility shim: (code text, first heredoc body or None)."""
|
|
109
|
+
code, docs = split_heredocs(text)
|
|
110
|
+
return code, (docs[0][1] if docs else None)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _words(raw: str) -> list[str]:
|
|
114
|
+
try:
|
|
115
|
+
return shlex.split(raw, posix=True)
|
|
116
|
+
except ValueError:
|
|
117
|
+
# unbalanced quotes (a heredoc fragment, a truncated message): fall back to whitespace words
|
|
118
|
+
return raw.split()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _balanced_substitutions(chunk: str) -> list[str]:
|
|
122
|
+
"""Contents of $( … ), ( … ) and ` … ` with balanced nesting. Single quotes make everything literal;
|
|
123
|
+
inside double quotes $( ) and backticks still substitute (as in a real shell)."""
|
|
124
|
+
out: list[str] = []
|
|
125
|
+
i, in_single, in_double = 0, False, False
|
|
126
|
+
while i < len(chunk):
|
|
127
|
+
ch = chunk[i]
|
|
128
|
+
if in_single:
|
|
129
|
+
if ch == "'":
|
|
130
|
+
in_single = False
|
|
131
|
+
i += 1
|
|
132
|
+
continue
|
|
133
|
+
if ch == "\\" and in_double and i + 1 < len(chunk):
|
|
134
|
+
i += 2
|
|
135
|
+
continue
|
|
136
|
+
if ch == "'" and not in_double:
|
|
137
|
+
in_single = True
|
|
138
|
+
i += 1
|
|
139
|
+
continue
|
|
140
|
+
if ch == '"':
|
|
141
|
+
in_double = not in_double
|
|
142
|
+
i += 1
|
|
143
|
+
continue
|
|
144
|
+
if chunk.startswith("$(", i) or (ch == "(" and not in_double and (i == 0 or chunk[i - 1] in " \t;&|")):
|
|
145
|
+
start = i + 2 if chunk.startswith("$(", i) else i + 1
|
|
146
|
+
depth, j = 1, start
|
|
147
|
+
while j < len(chunk) and depth:
|
|
148
|
+
if chunk[j] == "(":
|
|
149
|
+
depth += 1
|
|
150
|
+
elif chunk[j] == ")":
|
|
151
|
+
depth -= 1
|
|
152
|
+
j += 1
|
|
153
|
+
if depth == 0:
|
|
154
|
+
out.append(chunk[start:j - 1])
|
|
155
|
+
i = j
|
|
156
|
+
continue
|
|
157
|
+
if ch == "`":
|
|
158
|
+
j = chunk.find("`", i + 1)
|
|
159
|
+
if j > i:
|
|
160
|
+
out.append(chunk[i + 1:j])
|
|
161
|
+
i = j + 1
|
|
162
|
+
continue
|
|
163
|
+
i += 1
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _segments(text: str, depth: int = 0) -> list[Segment]:
|
|
168
|
+
if depth > 5:
|
|
169
|
+
return []
|
|
170
|
+
code, docs = split_heredocs(text)
|
|
171
|
+
heredoc = docs[0][1] if docs else None
|
|
172
|
+
segs: list[Segment] = []
|
|
173
|
+
for chunk in _split_top_level(code, ("||", "&&", ";", "\n")):
|
|
174
|
+
# a pipeline is several commands; each piece is its own segment
|
|
175
|
+
for piece in _pipeline_pieces_raw(chunk):
|
|
176
|
+
words = _strip_env_prefix(_words(piece))
|
|
177
|
+
# strip a stray leading "(" from a subshell piece so the command name is clean
|
|
178
|
+
if words and words[0].startswith("(") and len(words[0]) > 1:
|
|
179
|
+
words[0] = words[0][1:]
|
|
180
|
+
seg = Segment(raw=piece, words=words, heredoc=heredoc if not segs else None)
|
|
181
|
+
for inner in _balanced_substitutions(piece):
|
|
182
|
+
if inner.strip():
|
|
183
|
+
seg.inner.extend(_segments(inner, depth + 1))
|
|
184
|
+
if words and os.path.basename(words[0]) in INTERPRETERS:
|
|
185
|
+
flags = INTERPRETERS[os.path.basename(words[0])]
|
|
186
|
+
for i, w in enumerate(words[1:], 1):
|
|
187
|
+
if w in flags and i + 1 < len(words):
|
|
188
|
+
program = words[i + 1]
|
|
189
|
+
if os.path.basename(words[0]) in SHELLS:
|
|
190
|
+
seg.inner.extend(_segments(program, depth + 1))
|
|
191
|
+
else:
|
|
192
|
+
for inner in exec_literals(program):
|
|
193
|
+
seg.inner.extend(_segments(inner, depth + 1))
|
|
194
|
+
break
|
|
195
|
+
segs.append(seg)
|
|
196
|
+
for consumer, body in docs:
|
|
197
|
+
kind = _heredoc_kind(consumer)
|
|
198
|
+
if kind == "shell":
|
|
199
|
+
segs.extend(_segments(body, depth + 1))
|
|
200
|
+
elif kind == "interpreter":
|
|
201
|
+
for inner in exec_literals(body):
|
|
202
|
+
segs.extend(_segments(inner, depth + 1))
|
|
203
|
+
return segs
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _pipeline_pieces_raw(text: str) -> list[str]:
|
|
207
|
+
pieces = _split_top_level(text.replace("||", "\x00"), ("|",))
|
|
208
|
+
return [p.replace("\x00", "||") for p in pieces]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
FETCHERS = {"curl", "wget", "fetch", "http", "https", "aria2c"}
|
|
212
|
+
DECODERS = {"base64", "openssl", "xxd", "gzip", "gunzip", "zcat", "python3", "python", "perl", "rev", "tr", "uudecode"}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _pipeline_pieces(text: str) -> list[list[str]]:
|
|
216
|
+
"""Split one chain-free command on single '|' (outside quotes/parens) into word lists."""
|
|
217
|
+
pieces = _split_top_level(text.replace("||", "\x00"), ("|",))
|
|
218
|
+
return [_strip_env_prefix(_words(p.replace("\x00", "||"))) for p in pieces]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
DATA_ARG_FLAGS = {"git": ("-m", "--message", "-F", "--file"), "gh": ("--body", "--title", "-b", "-t", "--notes", "-m"),
|
|
222
|
+
"hg": ("-m",), "svn": ("-m",), "sed": (), "grep": (), "rg": (), "awk": (), "echo": (), "printf": ()}
|
|
223
|
+
DATA_ONLY_COMMANDS = {"echo", "printf", "grep", "rg", "awk", "sed", "test", "[", "expr"}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _words_without_data_args(words: list[str]) -> list[str]:
|
|
227
|
+
"""Drop message bodies (git commit -m "…"), echo/printf text, and pattern arguments from a word list."""
|
|
228
|
+
if not words:
|
|
229
|
+
return words
|
|
230
|
+
cmd = os.path.basename(words[0])
|
|
231
|
+
if cmd in DATA_ONLY_COMMANDS:
|
|
232
|
+
return words[:1]
|
|
233
|
+
flags = DATA_ARG_FLAGS.get(cmd)
|
|
234
|
+
if not flags:
|
|
235
|
+
return words
|
|
236
|
+
out, skip = [], False
|
|
237
|
+
for w in words:
|
|
238
|
+
if skip:
|
|
239
|
+
skip = False
|
|
240
|
+
continue
|
|
241
|
+
if w in flags:
|
|
242
|
+
out.append(w)
|
|
243
|
+
skip = True
|
|
244
|
+
continue
|
|
245
|
+
if any(w.startswith(f + "=") for f in flags if f.startswith("--")) or (cmd == "git" and len(w) > 2 and w.startswith("-m") and not w.startswith("--")):
|
|
246
|
+
out.append(w.split("=", 1)[0])
|
|
247
|
+
continue
|
|
248
|
+
out.append(w)
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _is_shell(words: list[str]) -> bool:
|
|
253
|
+
if not words:
|
|
254
|
+
return False
|
|
255
|
+
w = words
|
|
256
|
+
if w[0] == "sudo" and len(w) > 1:
|
|
257
|
+
w = w[1:]
|
|
258
|
+
return os.path.basename(w[0]) in SHELLS
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _analyze_pipelines(chunk: str, parsed: Parsed) -> None:
|
|
262
|
+
pieces = _pipeline_pieces(chunk)
|
|
263
|
+
if len(pieces) < 2 or not _is_shell(pieces[-1]):
|
|
264
|
+
return
|
|
265
|
+
first = pieces[0]
|
|
266
|
+
# the fetcher is usually word 0, but cron lines ("0 3 * * * curl …") and env prefixes put it later
|
|
267
|
+
fetch_at = next((i for i, w in enumerate(first) if os.path.basename(w) in FETCHERS), None)
|
|
268
|
+
if fetch_at is not None:
|
|
269
|
+
parsed.piped_to_shell.append(Segment(raw=chunk, words=first[fetch_at:]))
|
|
270
|
+
decoders = [p for p in pieces[1:-1] if p and os.path.basename(p[0]) in DECODERS]
|
|
271
|
+
if decoders:
|
|
272
|
+
parsed.decode_exec.append(Segment(raw=chunk, words=decoders[0]))
|
|
273
|
+
for w in first:
|
|
274
|
+
if B64.match(w):
|
|
275
|
+
try:
|
|
276
|
+
decoded = base64.b64decode(w + "=" * (-len(w) % 4)).decode("utf-8", "ignore")
|
|
277
|
+
except (ValueError, binascii.Error):
|
|
278
|
+
continue
|
|
279
|
+
if decoded.strip():
|
|
280
|
+
parsed.segments.extend(_segments(decoded))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _split_top_level(text: str, seps: tuple[str, ...]) -> list[str]:
|
|
284
|
+
"""Split on separators that are outside quotes and parentheses."""
|
|
285
|
+
out, buf, i, depth, quote = [], [], 0, 0, None
|
|
286
|
+
while i < len(text):
|
|
287
|
+
ch = text[i]
|
|
288
|
+
if quote:
|
|
289
|
+
buf.append(ch)
|
|
290
|
+
if ch == "\\" and i + 1 < len(text) and quote == '"':
|
|
291
|
+
buf.append(text[i + 1]); i += 2; continue
|
|
292
|
+
if ch == quote:
|
|
293
|
+
quote = None
|
|
294
|
+
i += 1; continue
|
|
295
|
+
if ch in ("'", '"', "`"):
|
|
296
|
+
quote = ch; buf.append(ch); i += 1; continue
|
|
297
|
+
if ch == "(":
|
|
298
|
+
depth += 1
|
|
299
|
+
elif ch == ")":
|
|
300
|
+
depth = max(0, depth - 1)
|
|
301
|
+
if depth == 0:
|
|
302
|
+
matched = next((s for s in seps if text.startswith(s, i)), None)
|
|
303
|
+
if matched:
|
|
304
|
+
out.append("".join(buf)); buf = []; i += len(matched); continue
|
|
305
|
+
buf.append(ch); i += 1
|
|
306
|
+
out.append("".join(buf))
|
|
307
|
+
return [c.strip() for c in out if c.strip()]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
EXEC_CALL = re.compile(
|
|
311
|
+
r"(?:os\.system|os\.popen|subprocess\.(?:run|call|check_output|check_call|Popen)|commands\.getoutput|"
|
|
312
|
+
r"execSync|exec|spawn|spawnSync|execFile|system|popen|shell_exec|passthru|backticks|Runtime\.getRuntime\(\)\.exec|"
|
|
313
|
+
r"os\.exec\w*|eval)\s*\(\s*(?:\[\s*)?(?:f?r?['\"])((?:[^'\"\\]|\\.)*)['\"]"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def exec_literals(program: str) -> list[str]:
|
|
318
|
+
"""String literals passed to exec-like calls inside an interpreter program (os.system('…'), execSync("…")).
|
|
319
|
+
|
|
320
|
+
Other string literals are data: a Python file that *mentions* `rm -rf /` in a docstring or a test fixture
|
|
321
|
+
is not a command to run it. This is what keeps editing security code from tripping the gate.
|
|
322
|
+
"""
|
|
323
|
+
out = []
|
|
324
|
+
for m in EXEC_CALL.finditer(program):
|
|
325
|
+
lit = m.group(1).strip()
|
|
326
|
+
if lit:
|
|
327
|
+
out.append(lit)
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _consumer_words(consumer_line: str) -> list[str]:
|
|
332
|
+
head = consumer_line.split("\n", 1)[0]
|
|
333
|
+
idx = head.find("<<")
|
|
334
|
+
if idx < 0:
|
|
335
|
+
return []
|
|
336
|
+
after = re.sub(r"^<<-?\s*['\"]?\w+['\"]?", "", head[idx:]).strip()
|
|
337
|
+
if after.startswith("|"): # `cat <<EOF | bash`: the text after the operator names the real consumer
|
|
338
|
+
tail_pieces = _pipeline_pieces_raw(after.lstrip("|").strip())
|
|
339
|
+
words = _strip_env_prefix(_words(tail_pieces[-1])) if tail_pieces else []
|
|
340
|
+
else:
|
|
341
|
+
chunks = _split_top_level(head[:idx], ("||", "&&", ";", "\n"))
|
|
342
|
+
pieces = _pipeline_pieces_raw(chunks[-1]) if chunks else []
|
|
343
|
+
words = _strip_env_prefix(_words(pieces[-1])) if pieces else []
|
|
344
|
+
if words and words[0] == "sudo" and len(words) > 1:
|
|
345
|
+
words = words[1:]
|
|
346
|
+
return words
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _heredoc_kind(consumer_line: str) -> str:
|
|
350
|
+
"""'shell' when a shell consumes the heredoc, 'interpreter' for python/node/etc., else 'data' (a file write)."""
|
|
351
|
+
words = _consumer_words(consumer_line)
|
|
352
|
+
if not words:
|
|
353
|
+
return "data"
|
|
354
|
+
cmd = os.path.basename(words[0])
|
|
355
|
+
if cmd in SHELLS:
|
|
356
|
+
return "shell"
|
|
357
|
+
if cmd in INTERPRETERS:
|
|
358
|
+
return "interpreter"
|
|
359
|
+
return "data"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _heredoc_is_code(consumer_line: str) -> bool:
|
|
363
|
+
return _heredoc_kind(consumer_line) != "data"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _chain_chunks(text: str) -> list[str]:
|
|
367
|
+
code, docs = split_heredocs(text)
|
|
368
|
+
chunks = _split_top_level(code, ("||", "&&", ";", "\n"))
|
|
369
|
+
for consumer, body in docs:
|
|
370
|
+
kind = _heredoc_kind(consumer)
|
|
371
|
+
if kind == "shell":
|
|
372
|
+
chunks.extend(_chain_chunks(body))
|
|
373
|
+
elif kind == "interpreter":
|
|
374
|
+
for inner in exec_literals(body):
|
|
375
|
+
chunks.extend(_chain_chunks(inner))
|
|
376
|
+
return chunks
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _scan_text_for_pipelines(text: str, parsed: Parsed, depth: int = 0) -> None:
|
|
380
|
+
if depth > 4 or not text.strip():
|
|
381
|
+
return
|
|
382
|
+
for chunk in _chain_chunks(text):
|
|
383
|
+
_analyze_pipelines(chunk, parsed)
|
|
384
|
+
# each pipeline piece may itself carry a quoted command (echo '… | bash' inside a subshell)
|
|
385
|
+
for piece in _pipeline_pieces_raw(chunk):
|
|
386
|
+
pw = _strip_env_prefix(_words(piece))
|
|
387
|
+
if pw and os.path.basename(pw[0]) in INTERPRETERS and os.path.basename(pw[0]) not in SHELLS:
|
|
388
|
+
continue # interpreter programs are handled through exec_literals above
|
|
389
|
+
# echo/printf text is data, unless this chunk persists it (crontab, tee, >> redirect) or evals it
|
|
390
|
+
if pw and os.path.basename(pw[0]) in DATA_ONLY_COMMANDS and not re.search(r"\bcrontab\b|\btee\b|>>?\s*\S|\beval\b", chunk):
|
|
391
|
+
continue
|
|
392
|
+
if pw and os.path.basename(pw[0]) not in DATA_ONLY_COMMANDS and os.path.basename(pw[0]) in DATA_ARG_FLAGS \
|
|
393
|
+
and len(_words_without_data_args(pw)) < len(pw):
|
|
394
|
+
continue # git commit -m "…" / gh pr --body "…": the message is data; the git pack reads git's own argv
|
|
395
|
+
for q in re.findall(r"'([^']*)'|\"((?:[^\"\\\\]|\\\\.)*)\"", piece):
|
|
396
|
+
for inner in q:
|
|
397
|
+
if inner and ("|" in inner or ";" in inner or "&&" in inner) and inner != text:
|
|
398
|
+
_scan_text_for_pipelines(inner.lstrip("!"), parsed, depth + 1)
|
|
399
|
+
# subshells / substitutions / backticks
|
|
400
|
+
for inner in _balanced_substitutions(chunk):
|
|
401
|
+
if inner.strip():
|
|
402
|
+
_scan_text_for_pipelines(inner, parsed, depth + 1)
|
|
403
|
+
# interpreter one-liners and any quoted string that itself looks like a command
|
|
404
|
+
words = _strip_env_prefix(_words(chunk))
|
|
405
|
+
if words and os.path.basename(words[0]) in INTERPRETERS:
|
|
406
|
+
flags = INTERPRETERS[os.path.basename(words[0])]
|
|
407
|
+
for i, w in enumerate(words[1:], 1):
|
|
408
|
+
if w in flags and i + 1 < len(words):
|
|
409
|
+
program = words[i + 1]
|
|
410
|
+
if os.path.basename(words[0]) in SHELLS:
|
|
411
|
+
_scan_text_for_pipelines(program, parsed, depth + 1)
|
|
412
|
+
else:
|
|
413
|
+
for inner in exec_literals(program):
|
|
414
|
+
_scan_text_for_pipelines(inner, parsed, depth + 1)
|
|
415
|
+
break
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def parse(command: str) -> Parsed:
|
|
420
|
+
text = command if isinstance(command, str) else str(command)
|
|
421
|
+
code, docs = split_heredocs(text)
|
|
422
|
+
parsed = Parsed(raw=text, segments=_segments(text))
|
|
423
|
+
code_docs: list[str] = []
|
|
424
|
+
for consumer, body in docs:
|
|
425
|
+
kind = _heredoc_kind(consumer)
|
|
426
|
+
if kind == "shell":
|
|
427
|
+
code_docs.append(body)
|
|
428
|
+
elif kind == "interpreter":
|
|
429
|
+
code_docs.extend(exec_literals(body))
|
|
430
|
+
else:
|
|
431
|
+
parsed.data_heredocs.append(body)
|
|
432
|
+
parsed.code = code + ("\n" + "\n".join(code_docs) if code_docs else "")
|
|
433
|
+
_scan_text_for_pipelines(code, parsed)
|
|
434
|
+
for doc in code_docs:
|
|
435
|
+
_scan_text_for_pipelines(doc, parsed)
|
|
436
|
+
return parsed
|
|
437
|
+
|
|
438
|
+
|
interlockd/egress.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""`interlock egress-proxy`: an HTTP(S) forward proxy that decides every outbound destination host.
|
|
2
|
+
|
|
3
|
+
interlock egress-proxy --listen 127.0.0.1:7392 [--strict]
|
|
4
|
+
interlock sandbox --egress http://127.0.0.1:7392 -- my-agent # OS: localhost only; the proxy is the way out
|
|
5
|
+
|
|
6
|
+
CONNECT tunnels (HTTPS) and absolute-URI requests (plain HTTP) are both supported. HTTPS is tunnelled, never
|
|
7
|
+
decrypted: the proxy sees the destination host and port, not the content. Each new host in a session is decided
|
|
8
|
+
by the daemon as a `net_connect` tool call (the egress rules apply: metadata endpoints, raw IPs, sharing services,
|
|
9
|
+
unknown hosts) and recorded; `--strict` additionally refuses any host outside `trust.allowed_hosts`. Decisions are
|
|
10
|
+
cached per host for the proxy's lifetime.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import select
|
|
16
|
+
import socket
|
|
17
|
+
import socketserver
|
|
18
|
+
import threading
|
|
19
|
+
from fnmatch import fnmatchcase
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
from .integrations.client import InterlockClient
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Decider:
|
|
26
|
+
def __init__(self, client: InterlockClient, *, strict: bool = False, allowed_hosts: list[str] | None = None,
|
|
27
|
+
session: str = "egress-proxy"):
|
|
28
|
+
self.client, self.strict, self.allowed = client, strict, allowed_hosts or []
|
|
29
|
+
self.session = session
|
|
30
|
+
self.cache: dict[str, tuple[bool, str]] = {}
|
|
31
|
+
self.lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def _allowlisted(self, host: str) -> bool:
|
|
34
|
+
return any(host == h or fnmatchcase(host, h) or host.endswith("." + h.lstrip("*.")) for h in self.allowed)
|
|
35
|
+
|
|
36
|
+
def allow(self, host: str, port: int) -> tuple[bool, str]:
|
|
37
|
+
key = f"{host}:{port}"
|
|
38
|
+
with self.lock:
|
|
39
|
+
if key in self.cache:
|
|
40
|
+
return self.cache[key]
|
|
41
|
+
scheme = "https" if port == 443 else "http"
|
|
42
|
+
v = self.client.decide("pre_action", "net_connect", {"url": f"{scheme}://{host}:{port}/", "host": host, "port": port},
|
|
43
|
+
session=self.session)
|
|
44
|
+
if v is None:
|
|
45
|
+
ok, why = (not self.client.fails_closed()), "daemon unreachable"
|
|
46
|
+
elif self.client.stops(v):
|
|
47
|
+
ok, why = False, v.get("reason") or "refused by policy"
|
|
48
|
+
elif self.strict and not self._allowlisted(host):
|
|
49
|
+
ok, why = False, f"{host} is not in trust.allowed_hosts (strict)"
|
|
50
|
+
else:
|
|
51
|
+
ok, why = True, ""
|
|
52
|
+
with self.lock:
|
|
53
|
+
self.cache[key] = (ok, why)
|
|
54
|
+
return ok, why
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _relay(a: socket.socket, b: socket.socket) -> None:
|
|
58
|
+
socks = [a, b]
|
|
59
|
+
try:
|
|
60
|
+
while True:
|
|
61
|
+
r, _, _ = select.select(socks, [], [], 60)
|
|
62
|
+
if not r:
|
|
63
|
+
return
|
|
64
|
+
for s in r:
|
|
65
|
+
data = s.recv(65536)
|
|
66
|
+
if not data:
|
|
67
|
+
return
|
|
68
|
+
(b if s is a else a).sendall(data)
|
|
69
|
+
except OSError:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def make_server(listen: str, decider: Decider) -> socketserver.ThreadingTCPServer:
|
|
74
|
+
host, _, port = listen.rpartition(":")
|
|
75
|
+
|
|
76
|
+
class Handler(socketserver.BaseRequestHandler):
|
|
77
|
+
def handle(self):
|
|
78
|
+
c = self.request
|
|
79
|
+
head = b""
|
|
80
|
+
while b"\r\n\r\n" not in head and len(head) < 65536:
|
|
81
|
+
chunk = c.recv(4096)
|
|
82
|
+
if not chunk:
|
|
83
|
+
return
|
|
84
|
+
head += chunk
|
|
85
|
+
first = head.split(b"\r\n", 1)[0].decode("latin-1")
|
|
86
|
+
try:
|
|
87
|
+
method, target, _ = first.split(" ", 2)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return
|
|
90
|
+
if method.upper() == "CONNECT":
|
|
91
|
+
h, _, p = target.rpartition(":")
|
|
92
|
+
dest_host, dest_port, forward = h.strip("[]"), int(p or 443), b""
|
|
93
|
+
else:
|
|
94
|
+
u = urlsplit(target)
|
|
95
|
+
if not u.hostname:
|
|
96
|
+
c.sendall(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
|
|
97
|
+
return
|
|
98
|
+
dest_host, dest_port = u.hostname, u.port or 80
|
|
99
|
+
path = (u.path or "/") + (f"?{u.query}" if u.query else "")
|
|
100
|
+
forward = head.replace(target.encode("latin-1"), path.encode("latin-1"), 1)
|
|
101
|
+
ok, why = decider.allow(dest_host, dest_port)
|
|
102
|
+
if not ok:
|
|
103
|
+
body = f"[interlock] connection to {dest_host}:{dest_port} refused: {why}\n".encode()
|
|
104
|
+
c.sendall(b"HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: "
|
|
105
|
+
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
up = socket.create_connection((dest_host, dest_port), timeout=15)
|
|
109
|
+
except OSError as e:
|
|
110
|
+
msg = f"upstream unreachable: {type(e).__name__}\n".encode()
|
|
111
|
+
c.sendall(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: " + str(len(msg)).encode() + b"\r\n\r\n" + msg)
|
|
112
|
+
return
|
|
113
|
+
with up:
|
|
114
|
+
if forward:
|
|
115
|
+
up.sendall(forward)
|
|
116
|
+
else:
|
|
117
|
+
c.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
118
|
+
_relay(c, up)
|
|
119
|
+
|
|
120
|
+
class Server(socketserver.ThreadingTCPServer):
|
|
121
|
+
allow_reuse_address = True
|
|
122
|
+
daemon_threads = True
|
|
123
|
+
|
|
124
|
+
return Server((host or "127.0.0.1", int(port)), Handler)
|