snowflake-sandbox-python 0.2.1a1__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.
- snowflake/cli_sandbox/__init__.py +13 -0
- snowflake/cli_sandbox/_adapter.py +170 -0
- snowflake/cli_sandbox/_common.py +77 -0
- snowflake/cli_sandbox/_egress_flags.py +121 -0
- snowflake/cli_sandbox/_get_command.py +109 -0
- snowflake/cli_sandbox/_run_command.py +1091 -0
- snowflake/cli_sandbox/_shell_command.py +666 -0
- snowflake/cli_sandbox/_upload_plan.py +187 -0
- snowflake/cli_sandbox/commands.py +556 -0
- snowflake/cli_sandbox/plugin_spec.py +28 -0
- snowflake/cli_sandbox/py.typed +0 -0
- snowflake/sandbox/__init__.py +317 -0
- snowflake/sandbox/__main__.py +225 -0
- snowflake/sandbox/_ansi.py +206 -0
- snowflake/sandbox/_args.py +208 -0
- snowflake/sandbox/_assemble.py +256 -0
- snowflake/sandbox/_bundle.py +240 -0
- snowflake/sandbox/_connection_resolve.py +328 -0
- snowflake/sandbox/_deploy_spec.py +56 -0
- snowflake/sandbox/_diagnostics.py +501 -0
- snowflake/sandbox/_env.py +143 -0
- snowflake/sandbox/_files_mixin.py +280 -0
- snowflake/sandbox/_fs_ops.py +304 -0
- snowflake/sandbox/_globs.py +176 -0
- snowflake/sandbox/_hosts.py +110 -0
- snowflake/sandbox/_mcp_discovery.py +288 -0
- snowflake/sandbox/_mcp_status.py +183 -0
- snowflake/sandbox/_retry.py +94 -0
- snowflake/sandbox/_runtime/__init__.py +42 -0
- snowflake/sandbox/_runtime/_fs_helper.py +93 -0
- snowflake/sandbox/_runtime/_job_runner.py +111 -0
- snowflake/sandbox/_runtime/_protocol.py +53 -0
- snowflake/sandbox/_runtime/_shims.py +267 -0
- snowflake/sandbox/_sandbox_state.py +303 -0
- snowflake/sandbox/_session_registry.py +222 -0
- snowflake/sandbox/_sse.py +160 -0
- snowflake/sandbox/_stage.py +270 -0
- snowflake/sandbox/_sync_files_mixin.py +272 -0
- snowflake/sandbox/_sync_fs_ops.py +185 -0
- snowflake/sandbox/_sync_transport.py +737 -0
- snowflake/sandbox/_sync_watch.py +99 -0
- snowflake/sandbox/_transport.py +1366 -0
- snowflake/sandbox/_transport_errors.py +270 -0
- snowflake/sandbox/_upload_plan.py +497 -0
- snowflake/sandbox/_version.py +37 -0
- snowflake/sandbox/_watch.py +164 -0
- snowflake/sandbox/_wire.py +348 -0
- snowflake/sandbox/app.py +256 -0
- snowflake/sandbox/client.py +2356 -0
- snowflake/sandbox/config.py +1133 -0
- snowflake/sandbox/connect.py +288 -0
- snowflake/sandbox/deploy.py +499 -0
- snowflake/sandbox/egress.py +388 -0
- snowflake/sandbox/exceptions.py +253 -0
- snowflake/sandbox/exec_stream.py +264 -0
- snowflake/sandbox/files.py +547 -0
- snowflake/sandbox/function.py +567 -0
- snowflake/sandbox/image.py +46 -0
- snowflake/sandbox/jobs.py +649 -0
- snowflake/sandbox/lifecycle.py +67 -0
- snowflake/sandbox/log_stream.py +219 -0
- snowflake/sandbox/mcp.py +480 -0
- snowflake/sandbox/mount.py +161 -0
- snowflake/sandbox/py.typed +0 -0
- snowflake/sandbox/secret.py +244 -0
- snowflake/sandbox/session_app.py +244 -0
- snowflake/sandbox/shell.py +556 -0
- snowflake/sandbox/sync_client.py +2245 -0
- snowflake/sandbox/sync_exec_stream.py +238 -0
- snowflake/sandbox/sync_files.py +377 -0
- snowflake/sandbox/sync_log_stream.py +142 -0
- snowflake/sandbox/sync_shell.py +413 -0
- snowflake/sandbox/types.py +193 -0
- snowflake/sandbox/warm_session.py +700 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""Tell the user why a deploy will not work, or did not work.
|
|
2
|
+
|
|
3
|
+
Three stages, in the order a deploy meets them: `preflight_checks` validates a
|
|
4
|
+
`DeploySpec` client-side before anything is packaged, `diagnose_failure` maps a
|
|
5
|
+
known failure signature to its fix deterministically, and `explain_failure` is
|
|
6
|
+
the fallback for everything else — a best-effort ``snow cortex complete`` call.
|
|
7
|
+
`scaffold_module` (what ``snow sandbox init`` writes) lives here too because it
|
|
8
|
+
shares the inference machinery: ``_infer_egress_comment`` asks the same LLM which
|
|
9
|
+
hosts a project reaches.
|
|
10
|
+
|
|
11
|
+
So this module DOES make model calls. They are all best-effort and never raise —
|
|
12
|
+
a missing ``snow`` CLI degrades to a sentinel string — but nothing here is on the
|
|
13
|
+
deterministic deploy path, which is exactly why it is not in ``deploy``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from snowflake.sandbox._deploy_spec import DeploySpec
|
|
28
|
+
from snowflake.sandbox.deploy import DeployResult
|
|
29
|
+
|
|
30
|
+
from snowflake.sandbox._hosts import egress_group_for
|
|
31
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Problem:
|
|
36
|
+
"""A preflight finding. ``error`` aborts the deploy; ``warn`` is advisory."""
|
|
37
|
+
|
|
38
|
+
severity: str # "error" | "warn"
|
|
39
|
+
message: str
|
|
40
|
+
fix: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _stack_reachable() -> bool | None:
|
|
44
|
+
"""Best-effort reachability probe of the **configured** sandbox endpoint.
|
|
45
|
+
|
|
46
|
+
``None`` = not checked (which is the answer for a real Snowflake host).
|
|
47
|
+
|
|
48
|
+
Two things were wrong before. It probed a stale ``GS_HOST`` env var over
|
|
49
|
+
plain HTTP rather than the configured target, so preflight failed the deploy
|
|
50
|
+
naming a host that had nothing to do with it; and an unauthenticated GET of
|
|
51
|
+
one path is far too weak to justify refusing to deploy at all.
|
|
52
|
+
|
|
53
|
+
So the probe now runs only for a local stack (local dev mode), where
|
|
54
|
+
``http://host/healthz`` is real, fast, and genuinely diagnostic, and it reads
|
|
55
|
+
the address out of the resolved config. Against a managed host it is skipped:
|
|
56
|
+
a 401/404 there says nothing, and a 5s connect timeout on every deploy buys
|
|
57
|
+
nothing. Any HTTP response counts as reachable; only a connection-level
|
|
58
|
+
failure does not.
|
|
59
|
+
"""
|
|
60
|
+
from snowflake.sandbox.config import effective_config
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
cfg = effective_config()
|
|
64
|
+
if not cfg.cws_dev:
|
|
65
|
+
return None
|
|
66
|
+
base = cfg.base_url.rstrip("/")
|
|
67
|
+
except Exception:
|
|
68
|
+
return None
|
|
69
|
+
if not base:
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(f"{base}/healthz", timeout=5) as r:
|
|
73
|
+
return bool(r.status)
|
|
74
|
+
except urllib.error.HTTPError:
|
|
75
|
+
# The server answered, which is all this probe can honestly assert.
|
|
76
|
+
return True
|
|
77
|
+
except Exception:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def preflight_checks(spec: DeploySpec, bundle_root: Path | None = None) -> list[Problem]:
|
|
82
|
+
"""Cheap, client-side validation of a `DeploySpec`.
|
|
83
|
+
|
|
84
|
+
Catches the common misconfigs before we package and ship anything. The one
|
|
85
|
+
trap we *can't* see from here (the backend's credentials mode) is handled by
|
|
86
|
+
diagnose_failure."""
|
|
87
|
+
problems: list[Problem] = []
|
|
88
|
+
|
|
89
|
+
if not spec.entry:
|
|
90
|
+
problems.append(
|
|
91
|
+
Problem("error", "entry is required", 'add e.g. entry=["python3", "main.py"]')
|
|
92
|
+
)
|
|
93
|
+
elif (
|
|
94
|
+
bundle_root is not None and len(spec.entry) >= 2 and not str(spec.entry[0]).startswith("/")
|
|
95
|
+
):
|
|
96
|
+
# entry like ["python3","main.py"] / ["/bin/bash","setup.sh"] -> the
|
|
97
|
+
# script (last arg) should exist in the bundle.
|
|
98
|
+
script = bundle_root / str(spec.entry[-1])
|
|
99
|
+
if not script.exists():
|
|
100
|
+
problems.append(
|
|
101
|
+
Problem(
|
|
102
|
+
"error",
|
|
103
|
+
f"entry script not found in bundle: {spec.entry[-1]}",
|
|
104
|
+
f"create {spec.entry[-1]} in {bundle_root}",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if not spec.image:
|
|
109
|
+
problems.append(
|
|
110
|
+
Problem("error", "image is required", 'set image=Image.from_catalog("sandbox-base")')
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if not spec.code_stage:
|
|
114
|
+
problems.append(
|
|
115
|
+
Problem(
|
|
116
|
+
"warn",
|
|
117
|
+
"code_stage not set",
|
|
118
|
+
"set code_stage for presigned code delivery in a hosted deployment",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Egress + secrets: Snowflake's own rules (env var legality, collisions, per-app
|
|
123
|
+
# cap, host shape), so a bad declaration fails here rather than at StartApp.
|
|
124
|
+
try:
|
|
125
|
+
spec.egress_body()
|
|
126
|
+
except SandboxError as exc:
|
|
127
|
+
problems.append(Problem("error", str(exc)))
|
|
128
|
+
|
|
129
|
+
# A secret's `host` and Snowflake reachability are different controls: the first is
|
|
130
|
+
# enforced at the credential swap, the second by allow_internet / the group
|
|
131
|
+
# booleans. A secret scoped to a host the sandbox cannot reach on Snowflake is
|
|
132
|
+
# silently useless, so say so. (eai_hosts is the SPCS perimeter and does not
|
|
133
|
+
# affect Snowflake reachability, so it is deliberately not consulted here.)
|
|
134
|
+
# Only warn when egress was *explicitly* closed AND no EAI was granted:
|
|
135
|
+
# - allow_internet is tri-state: left unset the platform applies its permissive
|
|
136
|
+
# default, and actual reachability is then an Istio question we cannot answer
|
|
137
|
+
# from here — warning on it would be noise on the common path.
|
|
138
|
+
# - allow_github / allow_pypi are NOT credited here, and now never could be: both
|
|
139
|
+
# are inert, so neither makes a host reachable in any mode. The retired
|
|
140
|
+
# allowed_egress_hosts is likewise not consulted — hosts named there are ignored
|
|
141
|
+
# platform-side.
|
|
142
|
+
# - Under allow_internet=False the only grant left is an EAI, whose network rules
|
|
143
|
+
# live in Snowflake and cannot be resolved from here — so an EAI being present is
|
|
144
|
+
# taken as "the caller has said how" (loop-invariant, hence hoisted out below).
|
|
145
|
+
eg = spec.egress
|
|
146
|
+
if (
|
|
147
|
+
eg is not None
|
|
148
|
+
and eg.default_egress_allowed is False
|
|
149
|
+
and not eg.external_access_integrations
|
|
150
|
+
):
|
|
151
|
+
for s in spec.secrets:
|
|
152
|
+
for h in s.hosts:
|
|
153
|
+
hint = (
|
|
154
|
+
f"grant {h!r} with an External Access Integration and pass "
|
|
155
|
+
f'external_access_integrations=("MY_EAI",) — under '
|
|
156
|
+
f"allow_internet=False that is the only grant the platform applies"
|
|
157
|
+
)
|
|
158
|
+
problems.append(
|
|
159
|
+
Problem(
|
|
160
|
+
"warn",
|
|
161
|
+
f"secret {s.fqn!r} is scoped to host {h!r}, which the sandbox cannot "
|
|
162
|
+
f"reach with allow_internet=False — the credential could never be used",
|
|
163
|
+
hint,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
reachable = _stack_reachable()
|
|
168
|
+
if reachable is False:
|
|
169
|
+
# A warn, not an error: this is an unauthenticated probe of one path, so a
|
|
170
|
+
# negative is weak evidence. Failing the deploy on it meant a working
|
|
171
|
+
# target was refused (`deployed=False`, CLI exit 2) on the strength of a
|
|
172
|
+
# single unauthenticated GET.
|
|
173
|
+
try:
|
|
174
|
+
from snowflake.sandbox.config import effective_config
|
|
175
|
+
|
|
176
|
+
target = effective_config().base_url
|
|
177
|
+
except Exception: # pragma: no cover - only when nothing is configured
|
|
178
|
+
target = "the configured host"
|
|
179
|
+
problems.append(
|
|
180
|
+
Problem(
|
|
181
|
+
"warn",
|
|
182
|
+
f"sandbox backend did not answer at {target}",
|
|
183
|
+
"check the host/credentials, or start the local stack",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
return problems
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def diagnose_failure(exit_code: int | None, stdout: str, stderr: str) -> str:
|
|
190
|
+
"""Interpret a failed run. Returns a human fix string, or ''.
|
|
191
|
+
|
|
192
|
+
The signature trap: the entry runs but its script isn't there because the
|
|
193
|
+
code bundle never extracted into the container. Two platform-side causes share
|
|
194
|
+
this exact symptom (exit 127 from bash, or exit 2 'can't open file' from python):
|
|
195
|
+
1. backend not in real-credentials mode (the MITM proxy rewrites the S3
|
|
196
|
+
SigV4 presigned GET -> 403 -> empty bundle); or
|
|
197
|
+
2. the backend's seeded session token expired (stage-S3 egress allowlisting
|
|
198
|
+
lapses -> the presigned GET is blocked -> empty bundle)."""
|
|
199
|
+
if exit_code in (0, None):
|
|
200
|
+
return ""
|
|
201
|
+
blob = f"{stdout}\n{stderr}".lower()
|
|
202
|
+
bundle_missing = (
|
|
203
|
+
"no such file or directory" in blob or "can't open file" in blob or "cannot open" in blob
|
|
204
|
+
)
|
|
205
|
+
if bundle_missing:
|
|
206
|
+
return (
|
|
207
|
+
"the entry script wasn't found — the code bundle never extracted. On "
|
|
208
|
+
"the hosted backend check both: (1) the backend runs in real-credentials "
|
|
209
|
+
"mode (else the MITM proxy breaks the presigned S3 GET -> 403), and (2) "
|
|
210
|
+
"its seeded session token hasn't expired (restart the backend to refresh "
|
|
211
|
+
"it). Both yield an empty bundle with this exact symptom."
|
|
212
|
+
)
|
|
213
|
+
if "presigned code download failed" in blob or ("403" in blob and "s3" in blob):
|
|
214
|
+
return (
|
|
215
|
+
"presigned code download failed (S3 403) — the hosted backend must run "
|
|
216
|
+
"in real-credentials mode and have a non-expired session token."
|
|
217
|
+
)
|
|
218
|
+
return ""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def explain_failure(result: DeployResult, manifest_summary: str = "") -> str:
|
|
222
|
+
"""Best-effort LLM explanation of a deploy failure.
|
|
223
|
+
|
|
224
|
+
Only call this when ``result.exit_code != 0`` AND ``result.diagnosis == ""``.
|
|
225
|
+
Uses ``snow cortex complete`` (Snowflake CLI) for a one-shot prompt.
|
|
226
|
+
Returns a non-empty string in all cases — either an LLM explanation or a
|
|
227
|
+
graceful sentinel when no LLM is available or the call errors.
|
|
228
|
+
|
|
229
|
+
Never raises.
|
|
230
|
+
"""
|
|
231
|
+
_TAIL = 2000 # ~2 KB of context
|
|
232
|
+
|
|
233
|
+
def _tail(text: str) -> str:
|
|
234
|
+
return text[-_TAIL:] if len(text) > _TAIL else text
|
|
235
|
+
|
|
236
|
+
stderr_tail = _tail(result.stderr)
|
|
237
|
+
stdout_tail = _tail(result.stdout)
|
|
238
|
+
context_parts = [f"exit_code: {result.exit_code}"]
|
|
239
|
+
if manifest_summary:
|
|
240
|
+
context_parts.append(f"manifest: {manifest_summary}")
|
|
241
|
+
if stderr_tail:
|
|
242
|
+
context_parts.append(f"stderr (tail):\n{stderr_tail}")
|
|
243
|
+
if stdout_tail:
|
|
244
|
+
context_parts.append(f"stdout (tail):\n{stdout_tail}")
|
|
245
|
+
context_block = "\n".join(context_parts)
|
|
246
|
+
|
|
247
|
+
prompt = (
|
|
248
|
+
"A sandbox deploy failed. Given this context, explain the most likely "
|
|
249
|
+
"cause and the fix in 2-3 sentences.\n\n"
|
|
250
|
+
f"{context_block}"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
snow_bin = shutil.which("snow") or shutil.which("snowcli")
|
|
254
|
+
if snow_bin is None:
|
|
255
|
+
return (
|
|
256
|
+
"[explain] no local LLM available (snow / snowcli CLI not found); "
|
|
257
|
+
"see the raw failure output above."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
proc = subprocess.run(
|
|
262
|
+
[snow_bin, "cortex", "complete", prompt, "--silent"],
|
|
263
|
+
capture_output=True,
|
|
264
|
+
text=True,
|
|
265
|
+
timeout=30,
|
|
266
|
+
)
|
|
267
|
+
output = (proc.stdout or "").strip()
|
|
268
|
+
if output:
|
|
269
|
+
return output
|
|
270
|
+
# Non-zero exit or empty response → fall through to sentinel
|
|
271
|
+
err_snippet = (proc.stderr or "").strip()[:200]
|
|
272
|
+
return (
|
|
273
|
+
f"[explain] LLM call returned no output (exit {proc.returncode})"
|
|
274
|
+
+ (f": {err_snippet}" if err_snippet else "")
|
|
275
|
+
+ "; see the raw failure output above."
|
|
276
|
+
)
|
|
277
|
+
except subprocess.TimeoutExpired:
|
|
278
|
+
return (
|
|
279
|
+
"[explain] LLM call timed out (snow cortex complete); see the raw failure output above."
|
|
280
|
+
)
|
|
281
|
+
except Exception as exc: # noqa: BLE001
|
|
282
|
+
return (
|
|
283
|
+
f"[explain] LLM call failed ({type(exc).__name__}: {exc}); "
|
|
284
|
+
"see the raw failure output above."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def scaffold_module(
|
|
289
|
+
project_name: str = "my-agent",
|
|
290
|
+
infer_dir: str | Path | None = None,
|
|
291
|
+
source_dir: str | Path | None = None,
|
|
292
|
+
) -> str:
|
|
293
|
+
"""Return starter Python defining a deployable App — what ``init`` writes.
|
|
294
|
+
|
|
295
|
+
There is no manifest: the module *is* the contract.
|
|
296
|
+
|
|
297
|
+
The entry is detected from *source_dir* (default: the module's own directory
|
|
298
|
+
via *infer_dir*, else a ``main.py`` placeholder): ``setup.sh`` wins, else the
|
|
299
|
+
first of ``main.py`` / ``app.py`` / ``agent.py`` / ``__main__.py`` / ``run.py``.
|
|
300
|
+
|
|
301
|
+
``infer_dir``, when set, appends a commented ``Egress(...)`` suggestion
|
|
302
|
+
inferred from the project's imports (advisory only, never active config).
|
|
303
|
+
"""
|
|
304
|
+
probe = (
|
|
305
|
+
Path(source_dir)
|
|
306
|
+
if source_dir is not None
|
|
307
|
+
else (Path(infer_dir) if infer_dir is not None else None)
|
|
308
|
+
)
|
|
309
|
+
entry_repr = '["python3", "main.py"]'
|
|
310
|
+
req_note = ""
|
|
311
|
+
if probe is not None and probe.is_dir():
|
|
312
|
+
if (probe / "setup.sh").exists():
|
|
313
|
+
entry_repr = '["/bin/bash", "setup.sh"]'
|
|
314
|
+
else:
|
|
315
|
+
for cand in ("main.py", "app.py", "agent.py", "__main__.py", "run.py"):
|
|
316
|
+
if (probe / cand).exists():
|
|
317
|
+
entry_repr = f'["python3", "{cand}"]'
|
|
318
|
+
break
|
|
319
|
+
if (probe / "requirements.txt").exists():
|
|
320
|
+
req_note = (
|
|
321
|
+
" # requirements.txt detected — the base pip-installs it at startup,\n"
|
|
322
|
+
" # before entry runs.\n"
|
|
323
|
+
)
|
|
324
|
+
text = f'''"""A deployable sandbox agent.
|
|
325
|
+
|
|
326
|
+
Deploy it with: snow sandbox deploy {{__file__}}
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
from snowflake.sandbox import App, Bundle, Egress, Image, Secret
|
|
330
|
+
|
|
331
|
+
app = App("{project_name}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.function(
|
|
335
|
+
# The base image (a reference to a catalog base).
|
|
336
|
+
image=Image.from_catalog("sandbox-base"),
|
|
337
|
+
memory="4g",
|
|
338
|
+
# What the container runs. Drop `entry=` to run this function's body in the
|
|
339
|
+
# sandbox instead (the body becomes the remote payload).
|
|
340
|
+
{req_note} entry={entry_repr},
|
|
341
|
+
# Files to ship. Add include= globs to slice a monorepo.
|
|
342
|
+
bundle=Bundle.from_dir("."),
|
|
343
|
+
# Presigned code delivery (needed in a hosted deployment).
|
|
344
|
+
# code_stage="TEMP.MY_DB.SANDBOX_CODE",
|
|
345
|
+
# Static env vars.
|
|
346
|
+
env={{}},
|
|
347
|
+
# What the sandbox may reach. The default reaches Snowflake, the stages, and ~43
|
|
348
|
+
# package-manager hosts (incl. public PyPI). Anything else — GitHub included — needs
|
|
349
|
+
# an External Access Integration:
|
|
350
|
+
# Egress(external_access_integrations=("MY_EAI",))
|
|
351
|
+
egress=Egress(),
|
|
352
|
+
# Snowflake SECRETs to broker in. The sandbox gets a dummy; the egress proxy
|
|
353
|
+
# swaps in the real value on requests to `host`, so the credential never
|
|
354
|
+
# enters the sandbox. Create the SECRET first:
|
|
355
|
+
# CREATE SECRET <db>.<schema>.<name> TYPE = GENERIC_STRING SECRET_STRING = '<v>';
|
|
356
|
+
# `host` is a scope: a bare host also covers its subdomains. Use hosts=[...]
|
|
357
|
+
# when one listed host covers the rest. The host must also be reachable per
|
|
358
|
+
# `egress` above, or preflight warns.
|
|
359
|
+
secrets=[
|
|
360
|
+
# Secret.from_name("DB.SCHEMA.MY_TOKEN", env_var="MY_TOKEN",
|
|
361
|
+
# host="api.example.com"),
|
|
362
|
+
],
|
|
363
|
+
)
|
|
364
|
+
def agent() -> None:
|
|
365
|
+
"""Runs locally when called directly; deployed with .remote()."""
|
|
366
|
+
print("hello from the agent")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
import asyncio
|
|
371
|
+
|
|
372
|
+
print(asyncio.run(agent.remote()).stdout)
|
|
373
|
+
'''
|
|
374
|
+
if infer_dir is not None:
|
|
375
|
+
text += f"\n{_infer_egress_comment(Path(infer_dir))}\n"
|
|
376
|
+
return text
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _infer_advice(host_list_literal: str) -> str:
|
|
380
|
+
"""Render detected hosts as advice naming the option that enables them.
|
|
381
|
+
|
|
382
|
+
Hosts outside the two platform-backed groups are suggested as an **External Access
|
|
383
|
+
Integration**. They briefly got ``allowed_egress_hosts=[...]`` instead, but Snowflake
|
|
384
|
+
retired that list (hosts named there are ignored), so emitting it would hand the caller
|
|
385
|
+
a snippet that now raises — and before the SDK started refusing it, one that started a
|
|
386
|
+
sandbox which silently could not reach them.
|
|
387
|
+
"""
|
|
388
|
+
import ast as _ast
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
hosts = [str(h) for h in _ast.literal_eval(host_list_literal)]
|
|
392
|
+
except (ValueError, SyntaxError):
|
|
393
|
+
return f"# [infer] hosts the agent may need: {host_list_literal}"
|
|
394
|
+
|
|
395
|
+
groups = {g for g in (egress_group_for(h) for h in hosts) if g}
|
|
396
|
+
# github/dbt hosts count as ungrouped now: the flag that granted them is inert, so they
|
|
397
|
+
# need an EAI like any host outside the package-managers group — which means listing them
|
|
398
|
+
# in the CREATE NETWORK RULE printed below.
|
|
399
|
+
other = [h for h in hosts if egress_group_for(h) in (None, "github/dbt")]
|
|
400
|
+
|
|
401
|
+
lines = [f"# [infer] hosts detected: {', '.join(hosts) or '(none)'}"]
|
|
402
|
+
knobs = []
|
|
403
|
+
if "pypi" in groups:
|
|
404
|
+
# Public PyPI is in the always-on package-managers group — reachable by default,
|
|
405
|
+
# with no flag. allow_pypi is deprecated and inert, so it is not suggested here.
|
|
406
|
+
lines.append(
|
|
407
|
+
"# [infer] PyPI hosts are reachable by default (package-managers group); "
|
|
408
|
+
"no egress flag needed"
|
|
409
|
+
)
|
|
410
|
+
if other:
|
|
411
|
+
knobs.append('external_access_integrations=("MY_EAI",)')
|
|
412
|
+
lines.append(f"# [infer] not in a platform-backed group: {', '.join(other)}")
|
|
413
|
+
lines.append("# [infer] grant them with an EAI (a caller-supplied host list is retired):")
|
|
414
|
+
lines.append("# CREATE NETWORK RULE db.schema.my_rule MODE = EGRESS TYPE = HOST_PORT")
|
|
415
|
+
lines.append("# VALUE_LIST = (" + ", ".join(f"'{h}:443'" for h in other) + ");")
|
|
416
|
+
lines.append("# CREATE EXTERNAL ACCESS INTEGRATION my_eai")
|
|
417
|
+
lines.append("# ALLOWED_NETWORK_RULES = (db.schema.my_rule) ENABLED = TRUE;")
|
|
418
|
+
if knobs:
|
|
419
|
+
lines.append(f"# [infer] suggested: Egress({', '.join(knobs)})")
|
|
420
|
+
return "\n".join(lines)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _infer_egress_comment(src: Path) -> str:
|
|
424
|
+
"""Scan *.py in *src* for egress hints; ask the LLM which hosts it reaches.
|
|
425
|
+
|
|
426
|
+
Returns a COMMENT block — never active config. Always returns something
|
|
427
|
+
(degrades gracefully when no LLM is available).
|
|
428
|
+
"""
|
|
429
|
+
# ── Collect hints from source files ──────────────────────────────────────
|
|
430
|
+
import re
|
|
431
|
+
|
|
432
|
+
hints: list[str] = []
|
|
433
|
+
url_re = re.compile(r"https?://([a-zA-Z0-9.\-]+)")
|
|
434
|
+
import_re = re.compile(r"(?:import|from)\s+(requests|httpx|urllib|aiohttp)")
|
|
435
|
+
saas_re = re.compile(
|
|
436
|
+
r"\b(github|atlassian|jira|slack|confluence|grafana|stripe|twilio|sendgrid)\b", re.I
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
for py_file in sorted(src.glob("*.py")):
|
|
440
|
+
try:
|
|
441
|
+
text = py_file.read_text(errors="replace")
|
|
442
|
+
except OSError:
|
|
443
|
+
continue
|
|
444
|
+
for m in url_re.finditer(text):
|
|
445
|
+
hints.append(f"url:{m.group(1)}")
|
|
446
|
+
for m in import_re.finditer(text):
|
|
447
|
+
hints.append(f"import:{m.group(1)}")
|
|
448
|
+
for m in saas_re.finditer(text):
|
|
449
|
+
hints.append(f"ref:{m.group(1).lower()}")
|
|
450
|
+
|
|
451
|
+
# Deduplicate while preserving order.
|
|
452
|
+
seen: set[str] = set()
|
|
453
|
+
unique_hints: list[str] = []
|
|
454
|
+
for h in hints:
|
|
455
|
+
if h not in seen:
|
|
456
|
+
seen.add(h)
|
|
457
|
+
unique_hints.append(h)
|
|
458
|
+
|
|
459
|
+
if not unique_hints:
|
|
460
|
+
return "# [infer] no egress hints found in *.py files; review [egress] manually."
|
|
461
|
+
|
|
462
|
+
hints_str = ", ".join(unique_hints[:40]) # keep prompt short
|
|
463
|
+
|
|
464
|
+
prompt = (
|
|
465
|
+
"A Python agent project has these egress hints extracted from its source: "
|
|
466
|
+
f"{hints_str}. "
|
|
467
|
+
"Based on these hints, list the external hostnames it needs to reach. "
|
|
468
|
+
"Reply with ONLY a python list literal "
|
|
469
|
+
'like: ["domain1.com", "domain2.com"]. No explanation.'
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
snow_bin = shutil.which("snow") or shutil.which("snowcli")
|
|
473
|
+
if snow_bin is None:
|
|
474
|
+
return (
|
|
475
|
+
"# [infer] no local LLM available (snow / snowcli not found);\n"
|
|
476
|
+
"# review the Egress(...) settings manually based on your agent's imports."
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
proc = subprocess.run(
|
|
481
|
+
[snow_bin, "cortex", "complete", prompt, "--silent"],
|
|
482
|
+
capture_output=True,
|
|
483
|
+
text=True,
|
|
484
|
+
timeout=30,
|
|
485
|
+
)
|
|
486
|
+
output = (proc.stdout or "").strip()
|
|
487
|
+
if output:
|
|
488
|
+
return _infer_advice(output)
|
|
489
|
+
err_snippet = (proc.stderr or "").strip().replace("\n", " ").replace("\r", "")[:200]
|
|
490
|
+
return (
|
|
491
|
+
"# [infer] LLM returned no output"
|
|
492
|
+
+ (f" ({err_snippet})" if err_snippet else "")
|
|
493
|
+
+ "; review the Egress(...) settings manually based on your agent's imports."
|
|
494
|
+
)
|
|
495
|
+
except subprocess.TimeoutExpired:
|
|
496
|
+
return "# [infer] LLM call timed out; review the Egress(...) settings manually based on your agent's imports."
|
|
497
|
+
except Exception as exc: # noqa: BLE001
|
|
498
|
+
return (
|
|
499
|
+
f"# [infer] LLM call failed ({type(exc).__name__}: {exc}); "
|
|
500
|
+
"review the Egress(...) settings manually based on your agent's imports."
|
|
501
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Environment-variable validation for author-time surfaces.
|
|
2
|
+
|
|
3
|
+
The reserved-name policy for ``Sandbox.create(env=)``:
|
|
4
|
+
a value baked in can redirect the sandbox's Snowflake identity, defeat
|
|
5
|
+
the egress proxy, or hijack code execution, so both go through here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
14
|
+
|
|
15
|
+
__all__ = ["is_platform_env_key", "validate_env_mapping"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Mirrors Snowflake's server-side env var name rule.
|
|
19
|
+
_ENV_VAR_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
20
|
+
|
|
21
|
+
# Snowflake reserves this prefix for its own default (non-vanity) secret env vars.
|
|
22
|
+
_RESERVED_ENV_PREFIX = "CNG_SECRET_"
|
|
23
|
+
|
|
24
|
+
# Env-var names/prefixes an untrusted-code surface must not be able to set.
|
|
25
|
+
# create(env=) reaches the container's environment (merged in CreateContainer). A value
|
|
26
|
+
# baked here can redirect the sandbox's Snowflake identity (SNOWFLAKE_*), pre-occupy
|
|
27
|
+
# a brokered-secret slot (CNG_SECRET_*), defeat the egress proxy / MITM-CA trust
|
|
28
|
+
# (the proxy + TLS vars), or hijack code execution (PATH / LD_*). Reject them on the
|
|
29
|
+
# author-time surface so an env map can't quietly disable a security
|
|
30
|
+
# control. Names are compared case-insensitively (proxy vars are honoured in either
|
|
31
|
+
# case) and prefixes likewise.
|
|
32
|
+
_RESERVED_ENV_PREFIXES: tuple[str, ...] = ("SNOWFLAKE_", _RESERVED_ENV_PREFIX)
|
|
33
|
+
|
|
34
|
+
_RESERVED_ENV_NAMES: frozenset[str] = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"PATH",
|
|
37
|
+
"LD_PRELOAD",
|
|
38
|
+
"LD_LIBRARY_PATH",
|
|
39
|
+
"HTTP_PROXY",
|
|
40
|
+
"HTTPS_PROXY",
|
|
41
|
+
"ALL_PROXY",
|
|
42
|
+
"NO_PROXY",
|
|
43
|
+
"REQUESTS_CA_BUNDLE",
|
|
44
|
+
"SSL_CERT_FILE",
|
|
45
|
+
"SSL_CERT_DIR",
|
|
46
|
+
"CURL_CA_BUNDLE",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _reserved_env_reason(name: str) -> str | None:
|
|
52
|
+
"""Why *name* is reserved (a human phrase), or ``None`` if it is allowed.
|
|
53
|
+
|
|
54
|
+
Checks the reserved exact-name set (case-insensitive) and the reserved
|
|
55
|
+
prefixes (SNOWFLAKE_ / CNG_SECRET_, case-insensitive). Does NOT check the
|
|
56
|
+
identifier charset — callers that care validate that separately, since the
|
|
57
|
+
secret-env path and the plain-env path word it differently.
|
|
58
|
+
"""
|
|
59
|
+
upper = name.upper()
|
|
60
|
+
if upper in _RESERVED_ENV_NAMES:
|
|
61
|
+
return (
|
|
62
|
+
f"{name!r} is a reserved environment variable — setting it can disable "
|
|
63
|
+
"the egress proxy, the TLS trust store, or the executable search path"
|
|
64
|
+
)
|
|
65
|
+
for prefix in _RESERVED_ENV_PREFIXES:
|
|
66
|
+
if upper.startswith(prefix):
|
|
67
|
+
if prefix == _RESERVED_ENV_PREFIX:
|
|
68
|
+
return (
|
|
69
|
+
f"{name!r} starts with the reserved {_RESERVED_ENV_PREFIX!r} "
|
|
70
|
+
"prefix (Snowflake's brokered-secret namespace) — a baked value there "
|
|
71
|
+
"pre-occupies a secret slot"
|
|
72
|
+
)
|
|
73
|
+
return (
|
|
74
|
+
f"{name!r} starts with the reserved {prefix!r} prefix — a baked "
|
|
75
|
+
"value there can redirect the sandbox's Snowflake identity"
|
|
76
|
+
)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Server-reserved env prefixes that the PLATFORM itself injects, and which therefore
|
|
81
|
+
# MUST travel on the create body's `sandbox_env` LIST — never the user `env` MAP. The
|
|
82
|
+
# server rejects (400) a key under these on the `env` map but accepts the platform
|
|
83
|
+
# prefixes on `sandbox_env`:
|
|
84
|
+
# SNOWFLAKE_, SANDBOX_ -> platform-env prefixes: blocked on the `env` map, allowed on
|
|
85
|
+
# `sandbox_env`; the code-delivery keys SNOWFLAKE_CODE_URL / SNOWFLAKE_CODE_STAGE_PATH
|
|
86
|
+
# are recognized platform keys.
|
|
87
|
+
# CNG_SECRET_ -> Snowflake's brokered-secret injection namespace. Platform-owned: an
|
|
88
|
+
# SDK-minted key here must not collide on the user env map.
|
|
89
|
+
# A create with env={"SANDBOX_FOO"} or env={"SNOWFLAKE_FOO"} returns HTTP 400
|
|
90
|
+
# 'env key "…" is reserved'; env={"FOO"} is accepted.
|
|
91
|
+
# This is the list the reserved-env-guard scan test enforces against every public
|
|
92
|
+
# entry point's create/deploy body; keep it in lockstep with the server's own rules.
|
|
93
|
+
_PLATFORM_ENV_PREFIXES: tuple[str, ...] = ("SANDBOX_", "SNOWFLAKE_", "CNG_SECRET_")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_platform_env_key(name: str) -> bool:
|
|
97
|
+
"""True if *name* carries a server-reserved platform prefix.
|
|
98
|
+
|
|
99
|
+
See `_PLATFORM_ENV_PREFIXES`. Such a key must ride the `sandbox_env` LIST
|
|
100
|
+
rather than the user `env` map -- the server 400s a reserved-prefix key on
|
|
101
|
+
the `env` map.
|
|
102
|
+
"""
|
|
103
|
+
upper = name.upper()
|
|
104
|
+
return any(upper.startswith(prefix) for prefix in _PLATFORM_ENV_PREFIXES)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_env_mapping(
|
|
108
|
+
env: Mapping[str, object] | None,
|
|
109
|
+
*,
|
|
110
|
+
context: str,
|
|
111
|
+
allow: frozenset[str] = frozenset(),
|
|
112
|
+
) -> dict[str, str]:
|
|
113
|
+
"""Validate and normalise a user-supplied environment mapping.
|
|
114
|
+
|
|
115
|
+
Rejects non-identifier keys, the reserved names/prefixes (see
|
|
116
|
+
`_reserved_env_reason`), and non-string values (a silent ``str(123)`` /
|
|
117
|
+
``str(False)`` coercion is a footgun — ``env(DEBUG=False)`` would bake the
|
|
118
|
+
string ``"False"``, which is truthy). *allow* names keys that are exempt
|
|
119
|
+
from the reserved check because the SDK sets them itself (e.g.
|
|
120
|
+
``SNOWFLAKE_CODE_URL``, the presigned-download URL ``from_local`` injects).
|
|
121
|
+
Returns a plain ``dict[str, str]``. Raises `SandboxError`.
|
|
122
|
+
"""
|
|
123
|
+
if not env:
|
|
124
|
+
return {}
|
|
125
|
+
out: dict[str, str] = {}
|
|
126
|
+
for key, value in env.items():
|
|
127
|
+
if not isinstance(key, str) or not _ENV_VAR_RE.match(key):
|
|
128
|
+
raise SandboxError(
|
|
129
|
+
f"{context}: {key!r} is not a legal environment variable name "
|
|
130
|
+
"(must match ^[A-Za-z_][A-Za-z0-9_]*$)"
|
|
131
|
+
)
|
|
132
|
+
if key not in allow:
|
|
133
|
+
reason = _reserved_env_reason(key)
|
|
134
|
+
if reason is not None:
|
|
135
|
+
raise SandboxError(f"{context}: {reason}")
|
|
136
|
+
if not isinstance(value, str):
|
|
137
|
+
raise SandboxError(
|
|
138
|
+
f"{context}: value for {key!r} must be a string, not "
|
|
139
|
+
f"{type(value).__name__} (pass '{value}' explicitly if that is what "
|
|
140
|
+
"you meant — the SDK will not coerce it for you)"
|
|
141
|
+
)
|
|
142
|
+
out[key] = value
|
|
143
|
+
return out
|