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.
Files changed (80) hide show
  1. snowflake/cli_sandbox/__init__.py +13 -0
  2. snowflake/cli_sandbox/_adapter.py +170 -0
  3. snowflake/cli_sandbox/_common.py +77 -0
  4. snowflake/cli_sandbox/_egress_flags.py +121 -0
  5. snowflake/cli_sandbox/_get_command.py +109 -0
  6. snowflake/cli_sandbox/_run_command.py +1091 -0
  7. snowflake/cli_sandbox/_shell_command.py +666 -0
  8. snowflake/cli_sandbox/_upload_plan.py +187 -0
  9. snowflake/cli_sandbox/commands.py +556 -0
  10. snowflake/cli_sandbox/plugin_spec.py +28 -0
  11. snowflake/cli_sandbox/py.typed +0 -0
  12. snowflake/sandbox/__init__.py +317 -0
  13. snowflake/sandbox/__main__.py +225 -0
  14. snowflake/sandbox/_ansi.py +206 -0
  15. snowflake/sandbox/_args.py +208 -0
  16. snowflake/sandbox/_assemble.py +256 -0
  17. snowflake/sandbox/_bundle.py +240 -0
  18. snowflake/sandbox/_connection_resolve.py +328 -0
  19. snowflake/sandbox/_deploy_spec.py +56 -0
  20. snowflake/sandbox/_diagnostics.py +501 -0
  21. snowflake/sandbox/_env.py +143 -0
  22. snowflake/sandbox/_files_mixin.py +280 -0
  23. snowflake/sandbox/_fs_ops.py +304 -0
  24. snowflake/sandbox/_globs.py +176 -0
  25. snowflake/sandbox/_hosts.py +110 -0
  26. snowflake/sandbox/_mcp_discovery.py +288 -0
  27. snowflake/sandbox/_mcp_status.py +183 -0
  28. snowflake/sandbox/_retry.py +94 -0
  29. snowflake/sandbox/_runtime/__init__.py +42 -0
  30. snowflake/sandbox/_runtime/_fs_helper.py +93 -0
  31. snowflake/sandbox/_runtime/_job_runner.py +111 -0
  32. snowflake/sandbox/_runtime/_protocol.py +53 -0
  33. snowflake/sandbox/_runtime/_shims.py +267 -0
  34. snowflake/sandbox/_sandbox_state.py +303 -0
  35. snowflake/sandbox/_session_registry.py +222 -0
  36. snowflake/sandbox/_sse.py +160 -0
  37. snowflake/sandbox/_stage.py +270 -0
  38. snowflake/sandbox/_sync_files_mixin.py +272 -0
  39. snowflake/sandbox/_sync_fs_ops.py +185 -0
  40. snowflake/sandbox/_sync_transport.py +737 -0
  41. snowflake/sandbox/_sync_watch.py +99 -0
  42. snowflake/sandbox/_transport.py +1366 -0
  43. snowflake/sandbox/_transport_errors.py +270 -0
  44. snowflake/sandbox/_upload_plan.py +497 -0
  45. snowflake/sandbox/_version.py +37 -0
  46. snowflake/sandbox/_watch.py +164 -0
  47. snowflake/sandbox/_wire.py +348 -0
  48. snowflake/sandbox/app.py +256 -0
  49. snowflake/sandbox/client.py +2356 -0
  50. snowflake/sandbox/config.py +1133 -0
  51. snowflake/sandbox/connect.py +288 -0
  52. snowflake/sandbox/deploy.py +499 -0
  53. snowflake/sandbox/egress.py +388 -0
  54. snowflake/sandbox/exceptions.py +253 -0
  55. snowflake/sandbox/exec_stream.py +264 -0
  56. snowflake/sandbox/files.py +547 -0
  57. snowflake/sandbox/function.py +567 -0
  58. snowflake/sandbox/image.py +46 -0
  59. snowflake/sandbox/jobs.py +649 -0
  60. snowflake/sandbox/lifecycle.py +67 -0
  61. snowflake/sandbox/log_stream.py +219 -0
  62. snowflake/sandbox/mcp.py +480 -0
  63. snowflake/sandbox/mount.py +161 -0
  64. snowflake/sandbox/py.typed +0 -0
  65. snowflake/sandbox/secret.py +244 -0
  66. snowflake/sandbox/session_app.py +244 -0
  67. snowflake/sandbox/shell.py +556 -0
  68. snowflake/sandbox/sync_client.py +2245 -0
  69. snowflake/sandbox/sync_exec_stream.py +238 -0
  70. snowflake/sandbox/sync_files.py +377 -0
  71. snowflake/sandbox/sync_log_stream.py +142 -0
  72. snowflake/sandbox/sync_shell.py +413 -0
  73. snowflake/sandbox/types.py +193 -0
  74. snowflake/sandbox/warm_session.py +700 -0
  75. snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
  76. snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
  77. snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
  78. snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
  79. snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
  80. 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