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.
- sandbox_drift/__init__.py +3 -0
- sandbox_drift/__main__.py +4 -0
- sandbox_drift/canary.py +62 -0
- sandbox_drift/classify.py +69 -0
- sandbox_drift/cli.py +340 -0
- sandbox_drift/convert.py +77 -0
- sandbox_drift/errors.py +16 -0
- sandbox_drift/fingerprint.py +43 -0
- sandbox_drift/i18n.py +61 -0
- sandbox_drift/listener.py +47 -0
- sandbox_drift/locales/en.json +132 -0
- sandbox_drift/locales/ko.json +132 -0
- sandbox_drift/policy.py +79 -0
- sandbox_drift/report.py +260 -0
- sandbox_drift/snapshot.py +115 -0
- sandbox_drift/targets.py +162 -0
- sandbox_drift/worker.py +254 -0
- sandbox_drift-0.1.0.dist-info/METADATA +367 -0
- sandbox_drift-0.1.0.dist-info/RECORD +22 -0
- sandbox_drift-0.1.0.dist-info/WHEEL +4 -0
- sandbox_drift-0.1.0.dist-info/entry_points.txt +2 -0
- sandbox_drift-0.1.0.dist-info/licenses/LICENSE +21 -0
sandbox_drift/canary.py
ADDED
|
@@ -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)
|
sandbox_drift/convert.py
ADDED
|
@@ -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
|
sandbox_drift/errors.py
ADDED
|
@@ -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))
|