@sanlabs/sanbox-cli 0.0.14 → 0.0.15

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.
package/README.md CHANGED
@@ -15,7 +15,13 @@ installed_cli_version="$(sanbox --version)"
15
15
  test "$installed_cli_version" = "$latest_cli_version"
16
16
  ```
17
17
 
18
- Always use the latest published CLI. CLI 0.0.14 adds repeatable `--allowed-model` options when
18
+ Always use the latest published CLI. CLI 0.0.15 adds Secret Proxy management, template grants,
19
+ and run-scope inspection. Use `--path` or `--all-paths` to configure request scope and repeatable
20
+ `--secret-proxy` options to select proxies during template creation. Grants check HTTPS access;
21
+ `--add-network-access` explicitly adds missing host rules on port 443. Proxy details include
22
+ run-ID attachment, timestamps, and granted template IDs. If setup fails after template creation,
23
+ the CLI reports the existing template ID and recovery commands. See [Secret Proxies](#secret-proxies).
24
+ CLI 0.0.14 adds repeatable `--allowed-model` options when
19
25
  creating OpenCode Computer templates and `sanbox run --model` to select a configured same-provider
20
26
  model at startup. The selected model is saved on the run and reused on Resume; SDK per-prompt
21
27
  switching does not change that saved startup default.
@@ -388,3 +394,51 @@ Batch fan-out is client-side. Keep the CLI process alive until all tasks are sub
388
394
  Use `--json` for request/response commands and `--jsonl` for streams. Envelopes have `schema_version`, `ok`, `command`, `context`, `data` or `error`, and `next_actions`.
389
395
 
390
396
  Exit codes are `0` for command success, `1` for local/API failure, `2` for readiness or waited remote failure, and `130` for a detached watcher.
397
+
398
+ ## Secret Proxies
399
+
400
+ Create a proxy from a JSON configuration and read its credential from stdin. For example, save this as `proxy.json`:
401
+
402
+ ```json
403
+ {
404
+ "name": "customer-api",
405
+ "host": "api.example.com",
406
+ "authentication": { "type": "bearer" },
407
+ "attach_run_id": true
408
+ }
409
+ ```
410
+
411
+ ```sh
412
+ sanbox secret-proxies create --file proxy.json --secret-stdin \
413
+ --path /users --path /projects < token-file
414
+ sanbox secret-proxies get customer-api
415
+ sanbox secret-proxies update customer-api --path /users --if-revision 1
416
+ ```
417
+
418
+ Repeat `--path` to select paths and their subpaths, or use `--all-paths` for the entire API. `/users` includes `/users/42`, but excludes `/users-admin`. `/users/` includes descendants but excludes `/users`. Paths do not support wildcards.
419
+
420
+ These options replace all request rules and allow every supported HTTP method: GET, HEAD, POST, PUT, PATCH, DELETE, and OPTIONS. To retain method restrictions, supply `request_rules` in the JSON file without either path option. Creation requires an explicit path option or rules in the file. Updates accept `--file`, path options, or both, and require the current `--if-revision`. Names and hosts are immutable.
421
+
422
+ Grant a proxy to an existing template:
423
+
424
+ ```sh
425
+ sanbox templates secret-proxies grant TEMPLATE_ID customer-api
426
+ sanbox templates secret-proxies grant TEMPLATE_ID customer-api --add-network-access
427
+ ```
428
+
429
+ Grants check whether the host is allowed on HTTPS port 443. Missing or partial access produces a warning and does not prevent the grant. IP/CIDR rules are reported as DNS-dependent when hostname access cannot be confirmed. `--add-network-access` explicitly adds missing host rules on port 443, preserving existing custom rules and descriptions. Managed provider and harness rules remain server-owned.
430
+
431
+ Select proxies while creating a template with repeatable `--secret-proxy`:
432
+
433
+ ```sh
434
+ sanbox templates create --name "Customer support" \
435
+ --model-provider openai --model MODEL_ID \
436
+ --secret-proxy customer-api --secret-proxy billing-api \
437
+ --add-network-access
438
+ ```
439
+
440
+ Proxy names and IDs are resolved before creation. Network changes and grants are separate operations after the template is created. If setup fails, the CLI exits with an error, reports the existing template ID, and provides grant commands to finish setup. Successful grants and network changes remain saved. Use those recovery commands instead of creating another template.
441
+
442
+ `secret-proxies get` shows path scope, run-ID attachment, Created/Updated timestamps, and granted template IDs. With `--json`, grant results include `data.network_access`; template creation includes `data.secret_proxies`. Each network check includes its status, whether a rule was added, and any warning. Incomplete setup reports granted/pending proxy IDs under `error.details` and recovery commands under `next_actions`.
443
+
444
+ There is no enable or test step. Grants and configuration edits apply on the next start or resume. Inspect run scope with `sanbox runs secret-proxies RUN_ID`. Credentials are never accepted in command-line arguments or JSON configuration. See [Secret Proxies](../docs/secret-proxies.md) for runtime behavior, rotation, and deletion.
package/dist/api.js CHANGED
@@ -165,6 +165,30 @@ export class SanboxClient {
165
165
  async disconnectAnthropicEnvironment(environmentId) {
166
166
  return this.request(await this.orgPath(`/anthropic-environments/${encodeURIComponent(environmentId)}`), { method: "DELETE" });
167
167
  }
168
+ async listSecretProxies() {
169
+ return this.request(await this.orgPath("/secret-proxies"));
170
+ }
171
+ async getSecretProxy(id) {
172
+ return this.request(await this.orgPath(`/secret-proxies/${encodeURIComponent(id)}`));
173
+ }
174
+ async createSecretProxy(body) {
175
+ return this.request(await this.orgPath("/secret-proxies"), { method: "POST", body: JSON.stringify(body) });
176
+ }
177
+ async updateSecretProxy(id, body) {
178
+ return this.request(await this.orgPath(`/secret-proxies/${encodeURIComponent(id)}`), { method: "PATCH", body: JSON.stringify(body) });
179
+ }
180
+ async deleteSecretProxy(id) {
181
+ return this.request(await this.orgPath(`/secret-proxies/${encodeURIComponent(id)}`), { method: "DELETE" });
182
+ }
183
+ async getTemplateSecretProxies(template) {
184
+ return this.request(await this.orgPath(`/templates/${encodeURIComponent(template)}/secret-proxies`));
185
+ }
186
+ async grantTemplateSecretProxy(template, id, grant = true) {
187
+ return this.request(await this.orgPath(`/templates/${encodeURIComponent(template)}/secret-proxies/${encodeURIComponent(id)}`), { method: grant ? "PUT" : "DELETE" });
188
+ }
189
+ async getRunSecretProxies(run) {
190
+ return this.request(await this.orgPath(`/runs/${encodeURIComponent(run)}/secret-proxies`));
191
+ }
168
192
  async listTemplates() {
169
193
  return this.request(await this.orgPath("/templates"));
170
194
  }
@@ -177,6 +201,9 @@ export class SanboxClient {
177
201
  async deleteTemplate(templateId) {
178
202
  return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}`), { method: "DELETE" });
179
203
  }
204
+ async updateTemplateNetworkPolicy(templateId, networkPolicy) {
205
+ return this.request(await this.orgPath(`/templates/${encodeURIComponent(templateId)}/network-policy`), { method: "PATCH", body: JSON.stringify({ network_policy: networkPolicy }) });
206
+ }
180
207
  async createTemplate(body) {
181
208
  return this.request(await this.orgPath("/templates"), {
182
209
  method: "POST",
package/dist/args.js CHANGED
@@ -1,4 +1,6 @@
1
1
  const multiFlags = new Set([
2
+ "path",
3
+ "secret-proxy",
2
4
  "input",
3
5
  "include",
4
6
  "artifact",
@@ -8,6 +10,9 @@ const multiFlags = new Set([
8
10
  "browser-domain"
9
11
  ]);
10
12
  export const booleanFlags = new Set([
13
+ "all-paths",
14
+ "add-network-access",
15
+ "secret-stdin",
11
16
  "help",
12
17
  "version",
13
18
  "json",
package/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { proxyCommand, formatProxyResult, secretProxiesHelp } from "./secretProxies.js";
3
+ import { formatNetworkAccess, grantTemplateProxies, resolveSecretProxies } from "./templateSecretProxies.js";
2
4
  import fs from "node:fs/promises";
3
5
  import path from "node:path";
4
6
  import { formatActivityJsonl, formatActivityLine, parseActivityView, shouldRenderEvent } from "./activity.js";
@@ -30,6 +32,7 @@ Commands:
30
32
  sanbox auth check [--json]
31
33
  sanbox context [--json]
32
34
  sanbox doctor [--json]
35
+ sanbox secret-proxies --help
33
36
  sanbox model-providers list [--json]
34
37
  sanbox model-providers get <provider-id> [--json]
35
38
  sanbox model-providers models <provider-id> [--json]
@@ -40,7 +43,7 @@ Commands:
40
43
  sanbox templates list [--json]
41
44
  sanbox templates get <template-id> [--json]
42
45
  sanbox templates validate <template-id> [--json]
43
- sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--allowed-model <model-id>] [--channel email|telegram] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
46
+ sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--allowed-model <model-id>] [--channel email|telegram] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--secret-proxy <name-or-id>] [--add-network-access] [--json]
44
47
  sanbox templates delete <template-id> --force [--json]
45
48
  sanbox run "task" --template <template-id> [--model <model-id>] [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
46
49
  sanbox run --task "..." --template <template-id> [--model <model-id>] [--email-address <address>] [--telegram-bot-token <token>] [--telegram-allowed-user <id>] [--input <path>] [--wait | --watch] [--json | --jsonl]
@@ -170,10 +173,13 @@ Usage:
170
173
  sanbox templates list [--json]
171
174
  sanbox templates get <template-id> [--json]
172
175
  sanbox templates validate <template-id> [--json]
173
- sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--allowed-model <model-id>] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--json]
176
+ sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes|browser-use] [--mode task|computer] [--allowed-model <model-id>] [--browser-domain <hostname>] [--llm-budget-usd <amount>] [--secret-proxy <name-or-id>] [--add-network-access] [--json]
174
177
  sanbox templates delete <template-id> --force [--json]
175
178
 
176
179
  Template creation requires an exact provider id and that provider's exact model id.
180
+ Use repeatable --secret-proxy <name-or-id> to grant saved proxies to the new template.
181
+ Grants check HTTPS access. Add --add-network-access to explicitly allow their hosts on port 443.
182
+ If setup fails after creation, use the reported template id and grant commands to finish setup.
177
183
  LiteLLM budgets are optional USD amounts and apply separately to each run.
178
184
  OpenCode defaults to --mode task. Use --mode computer for a retained private OpenCode server;
179
185
  per-run LiteLLM budgets are not available for that retained mode yet.
@@ -468,6 +474,14 @@ const positionalText = (command, startIndex) => command.slice(startIndex).join("
468
474
  const runTask = (command, flags) => flagString(flags, "task") || positionalText(command, 1);
469
475
  const commonFlags = ["api-url", "json", "help"];
470
476
  const flagSets = {
477
+ "secret-proxies.list": commonFlags,
478
+ "secret-proxies.get": commonFlags,
479
+ "secret-proxies.create": [...commonFlags, "file", "secret-stdin", "path", "all-paths"],
480
+ "secret-proxies.update": [...commonFlags, "file", "if-revision", "path", "all-paths"],
481
+ "secret-proxies.rotate": [...commonFlags, "secret-stdin"],
482
+ "secret-proxies.delete": [...commonFlags, "yes"],
483
+ "templates.secret-proxies": [...commonFlags, "add-network-access"],
484
+ "runs.secret-proxies": commonFlags,
471
485
  "auth.check": commonFlags,
472
486
  context: [...commonFlags, "template"],
473
487
  doctor: [...commonFlags, "template"],
@@ -484,6 +498,8 @@ const flagSets = {
484
498
  "templates.delete": [...commonFlags, "force"],
485
499
  "templates.create": [
486
500
  ...commonFlags,
501
+ "secret-proxy",
502
+ "add-network-access",
487
503
  "name",
488
504
  "model-provider",
489
505
  "model",
@@ -549,7 +565,8 @@ const commandKey = (command) => {
549
565
  return "runs.supabase";
550
566
  if (command[0] === "opencode" && command[1] === "connections")
551
567
  return "opencode.connections";
552
- if (command[0] === "model-providers" ||
568
+ if (command[0] === "secret-proxies" ||
569
+ command[0] === "model-providers" ||
553
570
  command[0] === "anthropic-environments" ||
554
571
  command[0] === "templates" ||
555
572
  command[0] === "runs" ||
@@ -561,6 +578,8 @@ const commandKey = (command) => {
561
578
  return command[0];
562
579
  };
563
580
  const requiredValueFlags = new Set([
581
+ "path", "secret-proxy",
582
+ "file", "if-revision",
564
583
  "api-url", "template", "task", "input", "include", "external-run-id", "email-address",
565
584
  "supabase-user-id", "return-url",
566
585
  "telegram-bot-token", "telegram-allowed-user", "channel",
@@ -607,7 +626,7 @@ const validateFlags = (command, flags) => {
607
626
  return;
608
627
  }
609
628
  const knownRoots = new Set([
610
- "auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
629
+ "secret-proxies", "auth", "context", "doctor", "model-providers", "anthropic-environments", "templates",
611
630
  "run", "batch", "runs", "opencode", "ssh", "ssh-proxy", "login", "logout", "init", "version"
612
631
  ]);
613
632
  const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
@@ -622,6 +641,9 @@ const validatePositionals = (command, flags) => {
622
641
  if (hasFlag(flags, "help"))
623
642
  return;
624
643
  const maximums = {
644
+ "secret-proxies.list": 2, "secret-proxies.get": 3, "secret-proxies.create": 2,
645
+ "secret-proxies.update": 3, "secret-proxies.rotate": 3, "secret-proxies.delete": 3,
646
+ "templates.secret-proxies": 5, "runs.secret-proxies": 3,
625
647
  "auth.check": 2,
626
648
  context: 1,
627
649
  doctor: 1,
@@ -1273,6 +1295,13 @@ const commandTemplates = async (command, flags) => {
1273
1295
  if (harness !== "hermes" && channels.length > 0) {
1274
1296
  throw new CliError("hermes_channels_not_supported", "--channel requires --harness hermes.");
1275
1297
  }
1298
+ const proxyNames = flagList(flags, "secret-proxy");
1299
+ const addNetworkAccess = hasFlag(flags, "add-network-access");
1300
+ if (addNetworkAccess && !proxyNames.length) {
1301
+ throw new CliError("secret_proxy_required", "--add-network-access requires at least one --secret-proxy.");
1302
+ }
1303
+ // Resolve every selection before creating anything, including name/id aliases.
1304
+ const selectedProxies = await resolveSecretProxies(client, proxyNames);
1276
1305
  let payload;
1277
1306
  try {
1278
1307
  payload = await client.createTemplate({
@@ -1305,8 +1334,21 @@ const commandTemplates = async (command, flags) => {
1305
1334
  catch (error) {
1306
1335
  throw templateCreationError(error, client, modelProvider, model);
1307
1336
  }
1337
+ let proxySetup;
1338
+ if (selectedProxies.length) {
1339
+ try {
1340
+ const { template, ...setup } = await grantTemplateProxies(client, payload.template.id, selectedProxies, addNetworkAccess);
1341
+ payload.template = template;
1342
+ proxySetup = setup;
1343
+ }
1344
+ catch (error) {
1345
+ if (error instanceof CliError)
1346
+ throw new CliError(error.code, `Template ${payload.template.id} was created. Do not rerun templates create.\n${error.message}`, { ...error.options, details: { ...error.options.details, template_created: true } });
1347
+ throw error;
1348
+ }
1349
+ }
1308
1350
  if (hasFlag(flags, "json")) {
1309
- printSuccess("templates.create", payload, jsonContext(client), [
1351
+ printSuccess("templates.create", { ...payload, ...(proxySetup ? { secret_proxies: proxySetup } : {}) }, jsonContext(client), [
1310
1352
  commandAction(["sanbox", "templates", "validate", payload.template.id, "--json"], "Validate the new template before running it."),
1311
1353
  commandAction([
1312
1354
  "sanbox",
@@ -1328,6 +1370,8 @@ const commandTemplates = async (command, flags) => {
1328
1370
  }
1329
1371
  process.stdout.write(`created ${payload.template.id} provider=${payload.template.provider_id || modelProvider} model=${payload.template.model_id || model}` +
1330
1372
  `${payload.template.llm_budget_usd ? ` budget=$${payload.template.llm_budget_usd}/run` : ""}\n`);
1373
+ if (proxySetup)
1374
+ process.stdout.write(`Granted proxies: ${selectedProxies.map(proxy => proxy.name).join(", ")}\n${formatNetworkAccess(proxySetup.network_access)}`);
1331
1375
  return;
1332
1376
  }
1333
1377
  if (action === "delete") {
@@ -1846,6 +1890,8 @@ const helpFor = (command) => {
1846
1890
  return runsSupabaseHelp;
1847
1891
  if (command[0] === "doctor")
1848
1892
  return doctorHelp;
1893
+ if (command[0] === "secret-proxies" || command[1] === "secret-proxies")
1894
+ return secretProxiesHelp;
1849
1895
  if (command[0] === "model-providers")
1850
1896
  return modelProvidersHelp;
1851
1897
  if (command[0] === "anthropic-environments")
@@ -1920,6 +1966,15 @@ const main = async (command, flags) => {
1920
1966
  return commandContext(flags);
1921
1967
  if (command[0] === "doctor")
1922
1968
  return commandDoctor(flags);
1969
+ if (command[0] === "secret-proxies" || command[1] === "secret-proxies") {
1970
+ const client = makeClient(flags);
1971
+ const payload = await proxyCommand(client, command, flags);
1972
+ if (hasFlag(flags, "json"))
1973
+ printSuccess(commandKey(command) || "secret-proxies", payload, jsonContext(client));
1974
+ else
1975
+ process.stdout.write(formatProxyResult(payload));
1976
+ return;
1977
+ }
1923
1978
  if (command[0] === "model-providers")
1924
1979
  return commandModelProviders(command, flags);
1925
1980
  if (command[0] === "anthropic-environments") {
@@ -0,0 +1,168 @@
1
+ import fs from "node:fs/promises";
2
+ import { posix } from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ import { isoUtcTimestamp } from "./output.js";
5
+ import { formatNetworkAccess, grantTemplateProxies, resolveSecretProxies } from "./templateSecretProxies.js";
6
+ export const secretProxiesHelp = `Secret Proxies
7
+ sanbox secret-proxies list
8
+ sanbox secret-proxies get <name-or-id>
9
+ sanbox secret-proxies create --file proxy.json --secret-stdin [--path /users | --all-paths]
10
+ sanbox secret-proxies update <name-or-id> --if-revision <revision> [--file proxy.json] [--path /users | --all-paths]
11
+ sanbox secret-proxies rotate <name-or-id> --secret-stdin
12
+ sanbox secret-proxies delete <name-or-id> --yes
13
+ sanbox templates secret-proxies list <template>
14
+ sanbox templates secret-proxies grant <template> <name-or-id> [--add-network-access]
15
+ sanbox templates secret-proxies revoke <template> <name-or-id>
16
+ sanbox runs secret-proxies <run>
17
+
18
+ Use --json for structured output. Credentials are read only from stdin.
19
+ Repeat --path for multiple paths. Each includes its subpaths; path wildcards are not supported.
20
+ --path and --all-paths replace request_rules with all supported HTTP methods.
21
+ Without either option, request_rules in the JSON file retain their method restrictions.
22
+ Updates require --file or a path option, and always require --if-revision.
23
+ Names and hosts cannot be updated. Create a new proxy to change either.
24
+ Saving makes a proxy available to granted templates on their next start/resume.
25
+ Call the original HTTPS URL. Network rules must allow its host; matching requests receive credentials automatically.
26
+ Grants check HTTPS access. --add-network-access explicitly adds missing host rules on port 443.
27
+ `;
28
+ export const proxyMethods = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
29
+ export function proxyPathRules(flags) {
30
+ if (flags.path !== undefined && flags["all-paths"]) {
31
+ throw new CliError("conflicting_paths", "Use either --path or --all-paths, not both.");
32
+ }
33
+ if (flags["all-paths"])
34
+ return [{ path_prefix: "/", methods: [...proxyMethods] }];
35
+ if (flags.path === undefined)
36
+ return undefined;
37
+ const paths = Array.isArray(flags.path) ? flags.path : [flags.path];
38
+ if (!paths.length || paths.length > 32)
39
+ throw new CliError("invalid_paths", "Provide between 1 and 32 paths.");
40
+ for (const path of paths) {
41
+ if (typeof path !== "string" || !path.startsWith("/") || /[*%\\?#\s\x00-\x20\x7f]/.test(path) ||
42
+ path.includes("//") || posix.normalize(path) !== path) {
43
+ throw new CliError("invalid_path", "Use an absolute path such as /users, without wildcards, encoding, queries, whitespace, or dot segments.");
44
+ }
45
+ }
46
+ return [...new Set(paths)].map(path_prefix => ({ path_prefix, methods: [...proxyMethods] }));
47
+ }
48
+ export async function proxyCommand(client, command, flags) {
49
+ const required = (value) => {
50
+ if (!value)
51
+ throw new CliError("argument_required", secretProxiesHelp);
52
+ return value;
53
+ };
54
+ const resolve = async (value) => {
55
+ return (await resolveSecretProxies(client, [required(value)]))[0];
56
+ };
57
+ const readCredential = async () => {
58
+ if (!flags["secret-stdin"] || process.stdin.isTTY)
59
+ throw new CliError("secret_stdin_required", "Pipe the credential into stdin and pass --secret-stdin.");
60
+ let value = "";
61
+ for await (const chunk of process.stdin) {
62
+ value += chunk.toString();
63
+ if (value.length > 8194)
64
+ throw new CliError("credential_too_long", "Credential exceeds 8192 characters.");
65
+ }
66
+ value = value.replace(/\r?\n$/, "");
67
+ if (!value)
68
+ throw new CliError("credential_required", "Credential is empty.");
69
+ return value;
70
+ };
71
+ const readConfig = async () => {
72
+ if (typeof flags.file !== "string")
73
+ throw new CliError("file_required", "Pass --file with a JSON configuration path.");
74
+ const stat = await fs.stat(flags.file);
75
+ if (stat.size > 64_000)
76
+ throw new CliError("file_too_large", "Configuration exceeds 64 KB.");
77
+ let config;
78
+ try {
79
+ config = JSON.parse(await fs.readFile(flags.file, "utf8"));
80
+ }
81
+ catch {
82
+ throw new CliError("invalid_proxy_json", "Configuration must contain valid JSON.");
83
+ }
84
+ if (!config ||
85
+ typeof config !== "object" ||
86
+ Array.isArray(config) ||
87
+ "credential" in config)
88
+ throw new CliError("invalid_proxy_config", "Use a configuration object without credentials. Pass credentials through stdin.");
89
+ return config;
90
+ };
91
+ if (command[0] === "runs")
92
+ return client.getRunSecretProxies(required(command[2]));
93
+ if (command[0] === "templates") {
94
+ const action = command[2], template = required(command[3]);
95
+ if (flags["add-network-access"] && action !== "grant") {
96
+ throw new CliError("unsupported_flag", "--add-network-access is only supported for grants and template creation.");
97
+ }
98
+ if (action === "list")
99
+ return client.getTemplateSecretProxies(template);
100
+ const proxy = await resolve(command[4]);
101
+ if (!["grant", "revoke"].includes(action || ""))
102
+ throw new CliError("invalid_action", secretProxiesHelp);
103
+ if (action === "revoke")
104
+ return client.grantTemplateSecretProxy(template, proxy.id, false);
105
+ const { template: _template, ...setup } = await grantTemplateProxies(client, template, [proxy], Boolean(flags["add-network-access"]));
106
+ return { granted: true, ...setup };
107
+ }
108
+ const action = command[1];
109
+ if (action === "list")
110
+ return client.listSecretProxies();
111
+ const rules = proxyPathRules(flags);
112
+ if (action === "create") {
113
+ const config = await readConfig();
114
+ if (!rules && config.request_rules === undefined) {
115
+ throw new CliError("paths_required", "Choose --path or --all-paths, or provide request_rules in the configuration file.");
116
+ }
117
+ return client.createSecretProxy({
118
+ ...config,
119
+ ...(rules ? { request_rules: rules } : {}),
120
+ credential: await readCredential(),
121
+ });
122
+ }
123
+ const proxy = await resolve(command[2]);
124
+ if (action === "get")
125
+ return client.getSecretProxy(proxy.id);
126
+ if (action === "delete") {
127
+ if (!flags.yes)
128
+ throw new CliError("confirmation_required", "Pass --yes to delete the proxy and revoke its active access.");
129
+ return client.deleteSecretProxy(proxy.id);
130
+ }
131
+ if (action === "rotate")
132
+ return client.updateSecretProxy(proxy.id, {
133
+ revision: proxy.revision,
134
+ credential: await readCredential(),
135
+ });
136
+ if (action === "update") {
137
+ const revision = Number(flags["if-revision"]);
138
+ if (!Number.isInteger(revision) || revision < 1)
139
+ throw new CliError("revision_required", "Pass --if-revision with the revision you edited.");
140
+ return client.updateSecretProxy(proxy.id, {
141
+ ...(flags.file !== undefined || !rules ? await readConfig() : {}),
142
+ ...(rules ? { request_rules: rules } : {}),
143
+ revision,
144
+ });
145
+ }
146
+ throw new CliError("invalid_action", secretProxiesHelp);
147
+ }
148
+ export function formatProxyResult(payload) {
149
+ if ("proxies" in payload)
150
+ return payload.proxies.length
151
+ ? payload.proxies.map(proxy => `${proxy.id}\t${proxy.name}\t${proxy.host}\trevision=${proxy.revision}\tattach_run_id=${proxy.attach_run_id}${proxy.updated_at ? `\tupdated=${isoUtcTimestamp(proxy.updated_at)}` : ""}${proxy.granted === undefined ? "" : `\tgranted=${proxy.granted}`}${proxy.removed ? "\tremoved" : ""}`).join("\n") + "\n"
152
+ : "No secret proxies.\n";
153
+ if ("proxy" in payload) {
154
+ const proxy = payload.proxy;
155
+ const templateIds = payload.template_ids ?? proxy.template_ids;
156
+ const rules = proxy.request_rules.map(rule => {
157
+ const allMethods = proxyMethods.every(method => rule.methods.includes(method));
158
+ const scope = rule.path_prefix === "/" ? "All paths" : `${rule.path_prefix} and subpaths`;
159
+ return ` ${scope} (${allMethods ? "all supported methods" : rule.methods.join(", ")})`;
160
+ }).join("\n");
161
+ return `${proxy.id} ${proxy.name}\nHost: ${proxy.host}\nRevision: ${proxy.revision}\nAuthentication: ${proxy.authentication.type} (${proxy.authentication.header})\nAttach Sanbox run ID: ${proxy.attach_run_id ? "Yes (X-Sanbox-Run-ID)" : "No"}\nCreated: ${proxy.created_at ? isoUtcTimestamp(proxy.created_at) : "Unavailable"}\nUpdated: ${proxy.updated_at ? isoUtcTimestamp(proxy.updated_at) : "Unavailable"}\nPaths:\n${rules}\n${templateIds === undefined ? "" : `Granted templates: ${templateIds.length ? templateIds.join(", ") : "None"}\n`}`;
162
+ }
163
+ if ("deleted" in payload)
164
+ return "Proxy deleted. Future requests receive no injected credential.\n";
165
+ return payload.granted
166
+ ? "Granted for the next start or resume.\n" + formatNetworkAccess(payload.network_access ?? [])
167
+ : "Grant removed for the next start or resume.\n";
168
+ }
@@ -0,0 +1,119 @@
1
+ import { isIP } from "node:net";
2
+ import { SanboxApiError } from "./api.js";
3
+ import { CliError, commandAction } from "./errors.js";
4
+ export async function resolveSecretProxies(client, names) {
5
+ if (!names.length)
6
+ return [];
7
+ const { proxies } = await client.listSecretProxies();
8
+ const selected = names.map(name => {
9
+ const proxy = proxies.find(item => item.id === name || item.name === name);
10
+ if (!proxy)
11
+ throw new CliError("proxy_not_found", `Secret proxy ${name} was not found in this organization.`);
12
+ return proxy;
13
+ });
14
+ return [...new Map(selected.map(proxy => [proxy.id, proxy])).values()];
15
+ }
16
+ // Keep static coverage consistent with the console's hostNetworkAccess helper.
17
+ // IP/CIDR rules require runtime DNS, so they cannot prove hostname coverage here.
18
+ export function hostNetworkAccess(host, rules) {
19
+ const normalize = (value) => value.trim().toLowerCase().replace(/\.$/, "");
20
+ const target = normalize(host);
21
+ const covers = (pattern, candidate) => pattern === candidate || pattern.startsWith("*.") && candidate.endsWith(pattern.slice(1));
22
+ const destinations = rules
23
+ .filter(rule => rule.ports.some(range => range.from <= 443 && range.to >= 443))
24
+ .map(rule => normalize(rule.destination));
25
+ if (destinations.some(destination => covers(destination, target)) ||
26
+ destinations.includes("0.0.0.0/0") && destinations.includes("::/0"))
27
+ return "allowed";
28
+ const addressRule = (destination) => destination.includes("/") || Boolean(isIP(destination));
29
+ if (target.startsWith("*.") && destinations.some(destination => !addressRule(destination) && covers(target, destination))) {
30
+ return "partial";
31
+ }
32
+ return destinations.some(addressRule) ? "ip-dependent" : "missing";
33
+ }
34
+ function checkNetworkAccess(template, proxy, added = false) {
35
+ const effective = template.network_access?.effective_rules;
36
+ const rules = effective ?? template.default_network_policy?.rules ?? [];
37
+ const coverage = hostNetworkAccess(proxy.host, rules);
38
+ const status = !effective && coverage !== "allowed" ? "unknown" : coverage;
39
+ const messages = {
40
+ missing: `${proxy.host} is not allowed on HTTPS port 443.`,
41
+ partial: `Only some subdomains of ${proxy.host} are allowed on HTTPS port 443.`,
42
+ "ip-dependent": `HTTPS access to ${proxy.host} depends on runtime DNS matching the IP/CIDR rules.`,
43
+ unknown: `The API did not provide enough network policy information to check HTTPS access to ${proxy.host}.`
44
+ };
45
+ return {
46
+ proxy_id: proxy.id, host: proxy.host, status, added,
47
+ ...(status === "allowed" ? {} : {
48
+ warning: `${messages[status]} Review the template's network settings or grant again with --add-network-access.`
49
+ })
50
+ };
51
+ }
52
+ export function formatNetworkAccess(checks) {
53
+ return checks.map(check => check.warning
54
+ ? `Warning: ${check.warning}\n`
55
+ : `HTTPS access: ${check.host}:443${check.added ? " (network rule added)" : " (already allowed)"}\n`).join("");
56
+ }
57
+ /** Grants and network edits are separate API operations. Keep successful work recoverable. */
58
+ export async function grantTemplateProxies(client, templateId, proxies, addNetworkAccess) {
59
+ const granted = [];
60
+ let checks = [];
61
+ try {
62
+ let { template } = await client.getTemplate(templateId);
63
+ templateId = template.id;
64
+ checks = proxies.map(proxy => checkNetworkAccess(template, proxy));
65
+ if (addNetworkAccess && checks.some(check => check.status !== "allowed")) {
66
+ const policy = template.default_network_policy;
67
+ if (!policy || policy.default_action !== "deny" || !Array.isArray(policy.rules)) {
68
+ throw new CliError("network_policy_unavailable", "Cannot add network access without the template's existing custom rules.");
69
+ }
70
+ // Send only stored custom rules. Managed provider/harness rules remain server-owned.
71
+ const rules = policy.rules.map(({ destination, ports, description }) => ({
72
+ destination, ports, ...(description === undefined ? {} : { description })
73
+ }));
74
+ const addedHosts = new Set();
75
+ for (const check of checks) {
76
+ if (check.status === "allowed" || hostNetworkAccess(check.host, rules) === "allowed")
77
+ continue;
78
+ rules.push({ destination: check.host, ports: [{ from: 443, to: 443 }] });
79
+ addedHosts.add(check.host);
80
+ }
81
+ if (rules.length > 16) {
82
+ throw new CliError("network_rule_limit", "Adding proxy hosts would exceed the template's limit of 16 custom network rules.");
83
+ }
84
+ ({ template } = await client.updateTemplateNetworkPolicy(templateId, { default_action: "deny", rules }));
85
+ checks = proxies.map(proxy => checkNetworkAccess(template, proxy, addedHosts.has(proxy.host)));
86
+ }
87
+ const failed = [];
88
+ for (const proxy of proxies) {
89
+ try {
90
+ await client.grantTemplateSecretProxy(templateId, proxy.id, true);
91
+ granted.push(proxy.id);
92
+ }
93
+ catch (error) {
94
+ // Do not echo upstream response bodies, which may contain sensitive context.
95
+ failed.push(`${proxy.name}${error instanceof SanboxApiError ? ` (HTTP ${error.status}, ${error.code})` : ""}`);
96
+ }
97
+ }
98
+ if (failed.length)
99
+ throw new CliError("proxy_grant_failed", `Could not save access for: ${failed.join(", ")}.`);
100
+ return { template, template_id: templateId, granted_proxy_ids: granted, network_access: checks };
101
+ }
102
+ catch (error) {
103
+ const pending = proxies.filter(proxy => !granted.includes(proxy.id));
104
+ const reason = error instanceof CliError ? ` ${error.message}`
105
+ : error instanceof SanboxApiError ? ` API request failed (HTTP ${error.status}, ${error.code}).` : "";
106
+ const nextActions = pending.map(proxy => commandAction(["sanbox", "templates", "secret-proxies", "grant", templateId, proxy.id,
107
+ ...(addNetworkAccess ? ["--add-network-access"] : []), "--api-url", client.config.apiUrl, "--json"], `Finish setting up ${proxy.name} on the existing template.`));
108
+ const commands = nextActions.map(action => action.type === "command" ? ` ${action.argv.join(" ")}` : "").join("\n");
109
+ throw new CliError("proxy_setup_incomplete", `Secret Proxy setup is incomplete for template ${templateId}.${reason}\nRetry on this template:\n${commands}`, {
110
+ details: {
111
+ template_id: templateId,
112
+ granted_proxy_ids: granted,
113
+ pending_proxy_ids: pending.map(proxy => proxy.id),
114
+ network_access: checks
115
+ },
116
+ nextActions
117
+ });
118
+ }
119
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.0.14";
1
+ export const version = "0.0.15";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanlabs/sanbox-cli",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",