sandbox-drift 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.
@@ -0,0 +1,3 @@
1
+ """sandbox-drift: harmless canary probes for AI coding agent sandboxes."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from sandbox_drift.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,62 @@
1
+ """Throw-away canary files: a work dir and a fake HOME in a temp folder.
2
+
3
+ The credential-looking paths exist only inside the fake HOME and hold nothing but
4
+ a random canary token. Real credential paths are never read, listed or stat-ed.
5
+ Folder names are assembled from fragments so no real path appears as a literal.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import secrets
11
+ import shutil
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ # (fragments joined at import time) relative paths under the fake HOME
16
+ CRED_PATHS: tuple[tuple[str, ...], ...] = (
17
+ (".a" + "ws", "cred" + "entials"),
18
+ (".s" + "sh", "id_" + "canary"),
19
+ (".con" + "fig", "g" + "h", "hosts.yml"),
20
+ (".ku" + "be", "con" + "fig"),
21
+ )
22
+
23
+
24
+ class Canary:
25
+ """Context manager owning a temp folder with ``work/`` and ``home/``."""
26
+
27
+ def __init__(self) -> None:
28
+ self.root: Path | None = None
29
+ self.token = "SANDBOX_DRIFT_CANARY_" + secrets.token_hex(8)
30
+
31
+ def __enter__(self) -> "Canary":
32
+ self.root = Path(tempfile.mkdtemp(prefix="sandbox-drift-"))
33
+ self.work_dir.mkdir()
34
+ self.fake_home.mkdir()
35
+ for parts in CRED_PATHS:
36
+ path = self.fake_home.joinpath(*parts)
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ path.write_text(self.token + "\n")
39
+ return self
40
+
41
+ def __exit__(self, *exc_info: object) -> None:
42
+ self.cleanup()
43
+
44
+ @property
45
+ def work_dir(self) -> Path:
46
+ assert self.root is not None
47
+ return self.root / "work"
48
+
49
+ @property
50
+ def fake_home(self) -> Path:
51
+ assert self.root is not None
52
+ return self.root / "home"
53
+
54
+ @property
55
+ def tmp_dir(self) -> Path:
56
+ assert self.root is not None
57
+ return self.root
58
+
59
+ def cleanup(self) -> None:
60
+ if self.root is not None:
61
+ shutil.rmtree(self.root, ignore_errors=True)
62
+ self.root = None
@@ -0,0 +1,69 @@
1
+ """Turn exceptions and HTTP codes into one of four observations.
2
+
3
+ This file is also prepended to the worker at run time, so it must stay
4
+ standard-library only and self-contained.
5
+
6
+ Observations: ``blocked`` (the sandbox said no), ``allowed`` (the packet or write
7
+ got through), ``error`` (we cannot tell), ``n/a`` (nothing to probe).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import errno
13
+
14
+ BLOCKED = "blocked"
15
+ ALLOWED = "allowed"
16
+ ERROR = "error"
17
+ NOT_APPLICABLE = "n/a"
18
+ OBSERVATIONS = (BLOCKED, ALLOWED, ERROR, NOT_APPLICABLE)
19
+
20
+ _DENIED_ERRNOS = (errno.EPERM, errno.EACCES)
21
+ # Errors that mean "the connection attempt left the sandbox but nobody answered".
22
+ _REACHED_ERRNOS = (errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ECONNREFUSED, errno.ETIMEDOUT)
23
+
24
+
25
+ def is_denied(exc: BaseException) -> bool:
26
+ """True for EPERM / EACCES style errors (the way sandboxes deny things)."""
27
+ return isinstance(exc, PermissionError) or getattr(exc, "errno", None) in _DENIED_ERRNOS
28
+
29
+
30
+ def classify_oserror(exc: BaseException) -> str:
31
+ """File-system style errors: denied -> blocked, anything else -> error."""
32
+ return BLOCKED if is_denied(exc) else ERROR
33
+
34
+
35
+ def classify_http(status: int) -> str:
36
+ """HTTP status from a filtering proxy: 403/407 -> blocked, 2xx -> allowed."""
37
+ if status in (403, 407):
38
+ return BLOCKED
39
+ if 200 <= status < 300:
40
+ return ALLOWED
41
+ return ERROR
42
+
43
+
44
+ def classify_connect(exc: BaseException | None) -> str:
45
+ """A direct connection to an unroutable address.
46
+
47
+ Success, a timeout, ENETUNREACH or ECONNREFUSED all mean the packet left the
48
+ sandbox (there is simply nobody at that address), so they count as ``allowed``.
49
+ """
50
+ if exc is None:
51
+ return ALLOWED
52
+ if is_denied(exc):
53
+ return BLOCKED
54
+ if isinstance(exc, TimeoutError) or getattr(exc, "errno", None) in _REACHED_ERRNOS:
55
+ return ALLOWED
56
+ return ERROR
57
+
58
+
59
+ def classify_unix_socket(exc: BaseException | None) -> str:
60
+ """A connect() to a Unix socket path (docker socket)."""
61
+ if exc is None:
62
+ return ALLOWED
63
+ if isinstance(exc, FileNotFoundError):
64
+ return NOT_APPLICABLE
65
+ if is_denied(exc):
66
+ return BLOCKED
67
+ if isinstance(exc, ConnectionRefusedError):
68
+ return ALLOWED
69
+ return ERROR
sandbox_drift/cli.py ADDED
@@ -0,0 +1,340 @@
1
+ """Command line interface: argparse sub-commands and exit codes (0 ok, 1 mismatch, 2 cannot run)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import re
9
+ import secrets
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from sandbox_drift import __version__, targets
14
+ from sandbox_drift.canary import Canary
15
+ from sandbox_drift.convert import claude_to_srt, load_json
16
+ from sandbox_drift.errors import ExecutorUnavailable, SandboxDriftError
17
+ from sandbox_drift.fingerprint import fingerprint_sbpl, fingerprint_settings
18
+ from sandbox_drift.i18n import SUPPORTED, resolve_lang, translator
19
+ from sandbox_drift.listener import CanaryListener
20
+ from sandbox_drift.policy import PRESETS, load_policy, parse_policy, policy_hash
21
+ from sandbox_drift.report import evaluate, exit_code, note_line, render_diff, render_run
22
+ from sandbox_drift.snapshot import (
23
+ diff_snapshots,
24
+ key_differences,
25
+ load_snapshot,
26
+ new_snapshot,
27
+ save_snapshot,
28
+ should_fail,
29
+ )
30
+
31
+ FORMATS = ("table", "md", "json")
32
+ _TR = translator("en") # replaced in main() once the language is known
33
+
34
+
35
+ # ------------------------------------------------------------------ argparse
36
+
37
+ class _Formatter(argparse.HelpFormatter):
38
+ def add_usage(self, usage, actions, groups, prefix=None): # noqa: ANN001
39
+ # argparse passes prefix="" when it builds a sub-command's prog; only replace the default
40
+ return super().add_usage(usage, actions, groups, _TR("arg.usage") + " " if prefix is None else prefix)
41
+
42
+
43
+ class _Parser(argparse.ArgumentParser):
44
+ """ArgumentParser whose fixed phrases (usage, options, errors, -h) are translated."""
45
+
46
+ def __init__(self, *args, **kwargs): # noqa: ANN002, ANN003
47
+ kwargs.setdefault("formatter_class", _Formatter)
48
+ kwargs["add_help"] = False
49
+ kwargs["allow_abbrev"] = False
50
+ if sys.version_info >= (3, 14):
51
+ kwargs["color"] = False
52
+ super().__init__(*args, **kwargs)
53
+ self._positionals.title = _TR("arg.positionals")
54
+ self._optionals.title = _TR("arg.options")
55
+ self.add_argument("-h", "--help", action="help", default=argparse.SUPPRESS, help=_TR("arg.help_help"))
56
+
57
+ def error(self, message: str): # noqa: ANN201
58
+ self.print_usage(sys.stderr)
59
+ self.exit(2, _TR("arg.error_prefix") + " " + _translate_argparse(message) + "\n")
60
+
61
+
62
+ _ARG_PATTERNS = (
63
+ (re.compile(r"the following arguments are required: (.+)"), "arg.required", ("names",)),
64
+ (re.compile(r"argument (.+?): invalid choice: (.+?) \(choose from (.+)\)"), "arg.invalid_choice",
65
+ ("name", "value", "choices")),
66
+ (re.compile(r"unrecognized arguments: (.+)"), "arg.unrecognized", ("names",)),
67
+ (re.compile(r"argument (.+?): expected one argument"), "arg.expected_one", ("name",)),
68
+ (re.compile(r"argument (.+?): invalid (?:float|int) value: (.+)"), "arg.invalid_number", ("name", "value")),
69
+ (re.compile(r"one of the arguments (.+) is required"), "arg.one_required", ("names",)),
70
+ (re.compile(r"argument (.+?): not allowed with argument (.+)"), "arg.not_allowed", ("name", "other")),
71
+ )
72
+
73
+
74
+ def _translate_argparse(message: str) -> str:
75
+ """Map argparse's fixed English sentences onto translated messages."""
76
+ for pattern, key, names in _ARG_PATTERNS:
77
+ found = pattern.fullmatch(message)
78
+ if found:
79
+ return _TR(key, **dict(zip(names, found.groups())))
80
+ return _TR("arg.generic", message=message)
81
+
82
+
83
+ def _add_common(parser: argparse.ArgumentParser) -> None:
84
+ parser.add_argument("--policy", metavar="FILE", help=_TR("opt.policy"))
85
+ parser.add_argument("--baseline", metavar="DIR", help=_TR("opt.baseline"))
86
+ parser.add_argument("--update-baseline", action="store_true", help=_TR("opt.update_baseline"))
87
+ parser.add_argument("--outside-dir", metavar="DIR", help=_TR("opt.outside_dir"))
88
+ parser.add_argument("--workdir", metavar="DIR", help=_TR("opt.workdir"))
89
+ parser.add_argument("--format", choices=FORMATS, default="table", help=_TR("opt.format"))
90
+ parser.add_argument("--out", metavar="FILE", help=_TR("opt.out"))
91
+ parser.add_argument("--strict", action="store_true", help=_TR("opt.strict"))
92
+ parser.add_argument("--exit-zero", action="store_true", help=_TR("opt.exit_zero"))
93
+ parser.add_argument("--timeout", type=float, default=2.0, metavar="SEC", help=_TR("opt.timeout"))
94
+ parser.add_argument("--total-timeout", type=float, default=60.0, metavar="SEC", help=_TR("opt.total_timeout"))
95
+ parser.add_argument("--agent-version", metavar="TEXT", help=_TR("opt.agent_version"))
96
+ parser.add_argument("--docker-socket", metavar="PATH", help=_TR("opt.docker_socket"))
97
+
98
+
99
+ def build_parser() -> argparse.ArgumentParser:
100
+ parser = _Parser(prog="sandbox-drift", description=_TR("help.description"), epilog=_TR("help.epilog"))
101
+ parser.add_argument("--lang", choices=SUPPORTED, help=_TR("opt.lang"))
102
+ parser.add_argument("--version", action="version", version=f"sandbox-drift {__version__}",
103
+ help=_TR("opt.version"))
104
+ sub = parser.add_subparsers(dest="command", metavar="<command>", parser_class=_Parser, required=True)
105
+
106
+ p_self = sub.add_parser("self", help=_TR("help.self"), description=_TR("help.self"))
107
+ _add_common(p_self)
108
+
109
+ p_run = sub.add_parser("run", help=_TR("help.run"), description=_TR("help.run"))
110
+ p_run.add_argument("--target", required=True, choices=("wrap", "srt", "replay"), help=_TR("opt.target"))
111
+ p_run.add_argument("--wrap", metavar="TEMPLATE", help=_TR("opt.wrap"))
112
+ p_run.add_argument("--settings", metavar="FILE", help=_TR("opt.settings"))
113
+ p_run.add_argument("--srt-bin", default="srt", metavar="PATH", help=_TR("opt.srt_bin"))
114
+ p_run.add_argument("--replay", metavar="FILE", help=_TR("opt.replay"))
115
+ p_run.add_argument("--python", metavar="PATH", help=_TR("opt.python"))
116
+ _add_common(p_run)
117
+
118
+ p_diff = sub.add_parser("diff", help=_TR("help.diff"), description=_TR("help.diff"))
119
+ p_diff.add_argument("old", metavar="OLD.json", help=_TR("opt.old"))
120
+ p_diff.add_argument("new", metavar="NEW.json", help=_TR("opt.new"))
121
+ p_diff.add_argument("--strict", action="store_true", help=_TR("opt.strict"))
122
+ p_diff.add_argument("--exit-zero", action="store_true", help=_TR("opt.exit_zero"))
123
+ p_diff.add_argument("--format", choices=FORMATS, default="table", help=_TR("opt.format"))
124
+
125
+ p_init = sub.add_parser("init", help=_TR("help.init"), description=_TR("help.init"))
126
+ p_init.add_argument("--preset", choices=tuple(PRESETS), default="self-basic", help=_TR("opt.preset"))
127
+ p_init.add_argument("--out", metavar="FILE", help=_TR("opt.init_out"))
128
+ p_init.add_argument("--force", action="store_true", help=_TR("opt.force"))
129
+
130
+ p_fp = sub.add_parser("fingerprint", help=_TR("help.fingerprint"), description=_TR("help.fingerprint"))
131
+ group = p_fp.add_mutually_exclusive_group(required=True)
132
+ group.add_argument("--settings", metavar="FILE", help=_TR("opt.fp_settings"))
133
+ group.add_argument("--sbpl", metavar="FILE", help=_TR("opt.fp_sbpl"))
134
+
135
+ p_conv = sub.add_parser("convert", help=_TR("help.convert"), description=_TR("help.convert"))
136
+ p_conv.add_argument("--from-claude", required=True, metavar="FILE", help=_TR("opt.from_claude"))
137
+ p_conv.add_argument("--fake-home", metavar="DIR", help=_TR("opt.fake_home"))
138
+ for child in (p_self, p_run, p_diff, p_init, p_fp, p_conv): # --lang also works after the command
139
+ child.add_argument("--lang", choices=SUPPORTED, default=argparse.SUPPRESS, help=_TR("opt.lang"))
140
+ return parser
141
+
142
+
143
+ def _prescan_lang(argv: list[str]) -> str | None:
144
+ """Find ``--lang`` before the real parser exists (the parser itself needs the language)."""
145
+ for i, arg in enumerate(argv):
146
+ if arg == "--lang" and i + 1 < len(argv):
147
+ return argv[i + 1]
148
+ if arg.startswith("--lang="):
149
+ return arg.split("=", 1)[1]
150
+ return None
151
+
152
+
153
+ # ------------------------------------------------------------------ helpers
154
+
155
+ def _fail(exc: SandboxDriftError) -> int:
156
+ message = _TR("err." + exc.code, **exc.params)
157
+ if isinstance(exc, ExecutorUnavailable):
158
+ message += " " + _TR("err.hint_self")
159
+ print(_TR("err.prefix") + " " + message, file=sys.stderr)
160
+ return 2
161
+
162
+
163
+ def _emit(text: str, out: str | None) -> None:
164
+ if out:
165
+ path = Path(out)
166
+ try:
167
+ path.parent.mkdir(parents=True, exist_ok=True)
168
+ path.write_text(text + "\n", encoding="utf-8")
169
+ except OSError as exc:
170
+ raise SandboxDriftError("output_unwritable", path=out, detail=exc.strerror or "") from exc
171
+ print(_TR("out.written", path=out))
172
+ else:
173
+ print(text)
174
+
175
+
176
+ def _use_color() -> bool:
177
+ return sys.stdout.isatty() and "NO_COLOR" not in os.environ
178
+
179
+
180
+ def _need(value: object, option: str, target: str) -> None:
181
+ if not value:
182
+ raise SandboxDriftError("missing_option", option=option, target=target)
183
+
184
+
185
+ def _collect(args: argparse.Namespace, target: str, policy: dict[str, str]) -> dict:
186
+ """Run the chosen target and return a snapshot document."""
187
+ agent = args.agent_version or os.environ.get("SANDBOX_DRIFT_AGENT_VERSION") or "unknown"
188
+ phash = policy_hash(policy)
189
+
190
+ if target == "replay":
191
+ _need(args.replay, "--replay", target)
192
+ saved = targets.load_replay(args.replay)
193
+ doc = new_snapshot(saved.get("target", "replay"), saved["probes"],
194
+ agent_version=saved.get("agent_version", agent),
195
+ engine_version=saved.get("engine_version", "unknown"), policy_hash=phash,
196
+ settings_fingerprint=saved.get("settings_fingerprint", "none"), note=saved.get("note"),
197
+ note_code=saved.get("note_code"))
198
+ for field in ("os", "arch"):
199
+ doc[field] = saved.get(field, doc[field])
200
+ return doc
201
+
202
+ outside = args.outside_dir or str(Path.home())
203
+ with CanaryListener() as listener:
204
+ if target == "self":
205
+ setup = targets.ProbeSetup(workdir=args.workdir or os.getcwd(), outside_dir=outside,
206
+ canary_token="SANDBOX_DRIFT_CANARY_" + secrets.token_hex(8),
207
+ listener_port=listener.port, timeout=args.timeout,
208
+ docker_socket=args.docker_socket)
209
+ probes = targets.run_self(setup)
210
+ return new_snapshot("self", probes, agent_version=agent, policy_hash=phash)
211
+
212
+ with Canary() as canary:
213
+ setup = targets.ProbeSetup(workdir=args.workdir or str(canary.work_dir), outside_dir=outside,
214
+ canary_token=canary.token, listener_port=listener.port,
215
+ fake_home=str(canary.fake_home), timeout=args.timeout,
216
+ docker_socket=args.docker_socket)
217
+ python = args.python or targets.default_python()
218
+ if target == "wrap":
219
+ _need(args.wrap, "--wrap", target)
220
+ probes = targets.run_wrap(args.wrap, setup, canary.tmp_dir, python, args.total_timeout)
221
+ return new_snapshot("wrap", probes, agent_version=agent, policy_hash=phash)
222
+ _need(args.settings, "--settings", target)
223
+ settings = load_json(args.settings)
224
+ srt_settings, warnings = claude_to_srt(settings, setup.fake_home)
225
+ for key, code in warnings:
226
+ print(_TR("warn." + code, key=key), file=sys.stderr)
227
+ allow_write = srt_settings["filesystem"].setdefault("allowWrite", [])
228
+ if setup.workdir not in allow_write:
229
+ allow_write.append(setup.workdir) # the positive control needs a writable work dir
230
+ probes = targets.run_srt(args.srt_bin, srt_settings, setup, canary.tmp_dir, python, args.total_timeout)
231
+ return new_snapshot("srt", probes, agent_version=agent,
232
+ engine_version=targets.engine_version(args.srt_bin), policy_hash=phash,
233
+ settings_fingerprint=fingerprint_settings(srt_settings))
234
+
235
+
236
+ def _run_command(args: argparse.Namespace, target: str) -> int:
237
+ if args.policy:
238
+ policy = load_policy(args.policy)
239
+ else:
240
+ policy = parse_policy(PRESETS["claude-srt" if target == "srt" else "self-basic"], "<preset>")
241
+ doc = _collect(args, target, policy)
242
+
243
+ changes, key_diffs, baseline_note = [], [], ""
244
+ if args.baseline:
245
+ path = Path(args.baseline) / f"baseline-{doc['target']}.json"
246
+ if path.exists():
247
+ old = load_snapshot(path)
248
+ changes = diff_snapshots(old, doc)
249
+ key_diffs = key_differences(old, doc)
250
+ baseline_note = _TR("baseline.compared", path=str(path))
251
+ if args.update_baseline:
252
+ save_snapshot(path, doc)
253
+ baseline_note += " " + _TR("baseline.updated")
254
+ else:
255
+ save_snapshot(path, doc)
256
+ baseline_note = _TR("baseline.created", path=str(path))
257
+ rows = evaluate(doc["probes"], policy, changes)
258
+ notes = [_TR("diff.key_changed", field=f, old=a, new=b) for f, a, b in key_diffs]
259
+ if target == "replay":
260
+ notes.append(_TR("report.replay_mock"))
261
+ if note_line(doc, _TR):
262
+ notes.append(note_line(doc, _TR)) # our example notes are translated, a user's own note is shown raw
263
+ text = render_run(doc, rows, args.format, _TR, color=_use_color() and not args.out, notes=notes,
264
+ baseline_note=baseline_note)
265
+ _emit(text, args.out)
266
+ code = exit_code(rows, changes, args.strict)
267
+ return 0 if args.exit_zero else code
268
+
269
+
270
+ def _diff_command(args: argparse.Namespace) -> int:
271
+ old, new = load_snapshot(args.old), load_snapshot(args.new)
272
+ changes = diff_snapshots(old, new)
273
+ text = render_diff(old, new, changes, key_differences(old, new), args.format, _TR, color=_use_color(),
274
+ strict=args.strict)
275
+ print(text)
276
+ failed = should_fail(changes, args.strict)
277
+ return 1 if failed and not args.exit_zero else 0
278
+
279
+
280
+ def _init_command(args: argparse.Namespace) -> int:
281
+ text = PRESETS[args.preset]
282
+ if not args.out:
283
+ print(text, end="")
284
+ return 0
285
+ path = Path(args.out)
286
+ if path.exists() and not args.force:
287
+ raise SandboxDriftError("init_exists", path=str(path))
288
+ try:
289
+ path.parent.mkdir(parents=True, exist_ok=True)
290
+ path.write_text(text, encoding="utf-8")
291
+ except OSError as exc:
292
+ raise SandboxDriftError("output_unwritable", path=str(path), detail=exc.strerror or "") from exc
293
+ print(_TR("out.written", path=str(path)))
294
+ return 0
295
+
296
+
297
+ def _read_text(path: str) -> str:
298
+ try:
299
+ return Path(path).read_text(encoding="utf-8")
300
+ except FileNotFoundError as exc:
301
+ raise SandboxDriftError("settings_not_found", path=path) from exc
302
+ except OSError as exc:
303
+ raise SandboxDriftError("file_unreadable", path=path, detail=exc.strerror or "") from exc
304
+
305
+
306
+ def _fingerprint_command(args: argparse.Namespace) -> int:
307
+ if args.sbpl:
308
+ print(fingerprint_sbpl(_read_text(args.sbpl)))
309
+ else:
310
+ print(fingerprint_settings(load_json(args.settings)))
311
+ return 0
312
+
313
+
314
+ def _convert_command(args: argparse.Namespace) -> int:
315
+ srt_settings, warnings = claude_to_srt(load_json(args.from_claude), args.fake_home)
316
+ for key, code in warnings:
317
+ print(_TR("warn." + code, key=key), file=sys.stderr)
318
+ print(json.dumps(srt_settings, indent=2))
319
+ return 0
320
+
321
+
322
+ def main(argv: list[str] | None = None) -> int:
323
+ global _TR
324
+ argv = list(sys.argv[1:] if argv is None else argv)
325
+ _TR = translator(resolve_lang(_prescan_lang(argv)))
326
+ args = build_parser().parse_args(argv)
327
+ try:
328
+ if args.command == "self":
329
+ return _run_command(args, "self")
330
+ if args.command == "run":
331
+ return _run_command(args, args.target)
332
+ if args.command == "diff":
333
+ return _diff_command(args)
334
+ if args.command == "init":
335
+ return _init_command(args)
336
+ if args.command == "fingerprint":
337
+ return _fingerprint_command(args)
338
+ return _convert_command(args)
339
+ except SandboxDriftError as exc:
340
+ return _fail(exc)
@@ -0,0 +1,77 @@
1
+ """agent CLI ``sandbox`` settings block -> sandbox-runtime (srt) settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from sandbox_drift.errors import SandboxDriftError
9
+
10
+ NETWORK_KEYS = ("allowedDomains", "deniedDomains", "allowUnixSockets", "allowAllUnixSockets",
11
+ "allowLocalBinding", "httpProxyPort", "socksProxyPort")
12
+ FILESYSTEM_KEYS = ("denyRead", "allowRead", "allowWrite", "denyWrite")
13
+ PATH_KEYS = ("denyRead", "allowRead", "allowWrite", "denyWrite", "allowUnixSockets")
14
+
15
+
16
+ def load_json(path: str | Path) -> dict:
17
+ file = Path(path)
18
+ try:
19
+ data = json.loads(file.read_text(encoding="utf-8"))
20
+ except FileNotFoundError as exc:
21
+ raise SandboxDriftError("settings_not_found", path=str(file)) from exc
22
+ except OSError as exc:
23
+ raise SandboxDriftError("file_unreadable", path=str(file), detail=exc.strerror or "") from exc
24
+ except json.JSONDecodeError as exc:
25
+ raise SandboxDriftError("settings_invalid_json", path=str(file), detail=str(exc)) from exc
26
+ if not isinstance(data, dict):
27
+ raise SandboxDriftError("settings_not_object", path=str(file))
28
+ return data
29
+
30
+
31
+ def expand_path(value: str, fake_home: str | None) -> str:
32
+ """``~`` becomes the fake HOME; a leading ``//`` means an absolute path (agent CLI convention)."""
33
+ if fake_home and (value == "~" or value.startswith("~/")):
34
+ return fake_home + value[1:]
35
+ if value.startswith("//"):
36
+ return value[1:]
37
+ return value
38
+
39
+
40
+ def _pick(section: object, allowed: tuple[str, ...], where: str, warnings: list[tuple[str, str]],
41
+ fake_home: str | None) -> dict:
42
+ result: dict = {}
43
+ if not isinstance(section, dict):
44
+ return result
45
+ for key, value in section.items():
46
+ if key not in allowed:
47
+ warnings.append((where + "." + key, "unknown_key"))
48
+ continue
49
+ if key in PATH_KEYS and isinstance(value, list):
50
+ value = [expand_path(item, fake_home) if isinstance(item, str) else item for item in value]
51
+ result[key] = value
52
+ return result
53
+
54
+
55
+ def claude_to_srt(settings: dict, fake_home: str | None = None) -> tuple[dict, list[tuple[str, str]]]:
56
+ """Return ``(srt_settings, warnings)``; warnings are ``(dotted key, code)`` pairs.
57
+
58
+ Accepts an agent CLI settings file (``sandbox`` block) or an srt settings file
59
+ (top-level ``network`` / ``filesystem``).
60
+ """
61
+ warnings: list[tuple[str, str]] = []
62
+ if isinstance(settings.get("sandbox"), dict):
63
+ block = settings["sandbox"]
64
+ prefix = "sandbox."
65
+ for key in block:
66
+ if key not in ("network", "filesystem"):
67
+ warnings.append((prefix + key, "not_translated"))
68
+ elif "network" in settings or "filesystem" in settings:
69
+ block = settings
70
+ prefix = ""
71
+ else:
72
+ raise SandboxDriftError("settings_no_sandbox")
73
+ srt = {
74
+ "network": _pick(block.get("network"), NETWORK_KEYS, prefix + "network", warnings, fake_home),
75
+ "filesystem": _pick(block.get("filesystem"), FILESYSTEM_KEYS, prefix + "filesystem", warnings, fake_home),
76
+ }
77
+ return srt, warnings
@@ -0,0 +1,16 @@
1
+ """Errors carry a stable code plus parameters so the CLI can translate them."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SandboxDriftError(Exception):
7
+ """Base error: ``code`` selects the message ``err.<code>``, ``params`` fill it in."""
8
+
9
+ def __init__(self, code: str, **params: object) -> None:
10
+ self.code = code
11
+ self.params = params
12
+ super().__init__(f"{code}: {params}")
13
+
14
+
15
+ class ExecutorUnavailable(SandboxDriftError):
16
+ """The runner could not run the worker at all (exit 2, never reported as 'blocked')."""
@@ -0,0 +1,43 @@
1
+ """Stable fingerprints so 'did the sandbox configuration change?' has a yes/no answer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+
9
+ _LOG_TAG = re.compile(r'\(with message "[^"]*"\)')
10
+ _PROXY_PORT = re.compile(r"(localhost|127\.0\.0\.1|\[::1\]):\d+")
11
+ _TEMP_PATH = re.compile(r"/(?:private/)?(?:var/folders|tmp)/[^\s\"')]*")
12
+ _SPACES = re.compile(r"[ \t]+")
13
+
14
+
15
+ def _digest(text: str) -> str:
16
+ return "sha256:" + hashlib.sha256(text.encode()).hexdigest()
17
+
18
+
19
+ def fingerprint_settings(data: object) -> str:
20
+ """Hash of a settings object; key order and whitespace do not matter."""
21
+ return _digest(json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
22
+
23
+
24
+ def normalize_sbpl(text: str) -> str:
25
+ """Remove the parts of a Seatbelt profile that change on every run.
26
+
27
+ Random log tags, the proxy port and temp paths differ between runs even when
28
+ the rules are identical, so they are replaced with placeholders.
29
+ """
30
+ lines = []
31
+ for raw in text.splitlines():
32
+ line = raw.strip()
33
+ if not line or line.startswith(";"):
34
+ continue
35
+ line = _LOG_TAG.sub('(with message "<TAG>")', line)
36
+ line = _PROXY_PORT.sub(r"\1:<PORT>", line)
37
+ line = _TEMP_PATH.sub("<TMP>", line)
38
+ lines.append(_SPACES.sub(" ", line))
39
+ return "\n".join(lines)
40
+
41
+
42
+ def fingerprint_sbpl(text: str) -> str:
43
+ return _digest(normalize_sbpl(text))