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,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)