@kaydash9999/banker-plugins 0.15.0 → 0.15.2

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.
@@ -5,17 +5,19 @@
5
5
  * node payload-scan.mjs <file>
6
6
  *
7
7
  * Prints {"file", "count", "hits": [{"line", "kind"}]} and exits 0 when nothing was found,
8
- * 1 when something was, 2 when the file cannot be read or the arguments are not one file. A
9
- * private key hit also carries `end`, the line of its END marker (null when there is none):
10
- * the whole block is the secret, not just the BEGIN line. The matched text itself is never
8
+ * 1 when something was, 2 when the file cannot be read or scanned or the arguments are not one
9
+ * file. A private key hit also carries `end`, the line of its END marker (null when there is
10
+ * none): the whole block is the secret, not just the BEGIN line. The matched text itself is never
11
11
  * printed, so the report cannot become the leak. The patterns cover common key and token
12
12
  * formats, private key blocks (PGP too), secret assignments (quoted, or unquoted with letters
13
13
  * and digits), passwords (quoted, with a symbol, alone on a config or env-file line, or after a
14
14
  * Dockerfile `ENV KEY value`), secrets given as command-line flags, curl's user and password flag,
15
15
  * hex keys, Basic and Bearer credentials, credentials in URLs and
16
- * long high-entropy strings (a lockfile's sha512- integrity hash aside); they do not prove a
17
- * payload clean. Every repeat and lookahead is
18
- * bounded or anchored, so a long line takes linear time.
16
+ * long high-entropy strings (a lockfile's sha512- integrity hash and a Go or Terraform h1: hash
17
+ * aside); they do not prove a payload clean. A Docker secret file's path after a *_FILE key is
18
+ * not read as the password it points to. Every repeat and lookahead is bounded (a run longer than
19
+ * 65536 characters is read in pieces) or anchored, so a long line takes linear time and cannot
20
+ * overflow the regex stack.
19
21
  */
20
22
  import { readFileSync, realpathSync } from "node:fs";
21
23
  import { fileURLToPath } from "node:url";
@@ -27,24 +29,26 @@ const PASSWORD_KEY = String.raw`(?:${PASS_WORD}|비밀번호|패스워드)[\w-]{
27
29
  // An unquoted value that is not code: none of the characters an expression or a reference
28
30
  // holds, and at least one symbol (an identifier has none).
29
31
  const BARE = String.raw`[^\s;,"'().\[\]{}$<>]`;
30
- const PRIVATE_KEY = String.raw`(?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----`;
32
+ // at most 8 words before PRIVATE KEY (RSA, EC, OPENSSH, ENCRYPTED, PGP): an open nested repeat
33
+ // overflows V8's regex stack on a key marker before millions of words
34
+ const PRIVATE_KEY = String.raw`(?:[A-Z0-9]{1,32} ){0,8}PRIVATE KEY(?: BLOCK)?-----`;
31
35
 
32
36
  export const PATTERNS = [
33
37
  ["aws-access-key", /(?<![A-Z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])/],
34
- ["github-token", /(?<![A-Za-z0-9])(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/],
35
- ["gitlab-token", /(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,}/],
36
- ["anthropic-key", /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,}/],
37
- ["openai-key", /(?<![A-Za-z0-9-])sk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/],
38
- ["stripe-key", /(?<![A-Za-z0-9])[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/],
38
+ ["github-token", /(?<![A-Za-z0-9])(?:gh[pousr]_[A-Za-z0-9]{36,65536}|github_pat_[A-Za-z0-9_]{22,65536})/],
39
+ ["gitlab-token", /(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,65536}/],
40
+ ["anthropic-key", /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,65536}/],
41
+ ["openai-key", /(?<![A-Za-z0-9-])sk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,65536}/],
42
+ ["stripe-key", /(?<![A-Za-z0-9])[rs]k_(?:live|test)_[A-Za-z0-9]{16,65536}/],
39
43
  ["npm-token", /(?<![A-Za-z0-9])npm_[A-Za-z0-9]{36}/],
40
- ["huggingface-token", /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{30,}/],
44
+ ["huggingface-token", /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{30,65536}/],
41
45
  ["google-api-key", /(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}/],
42
- ["slack-token", /(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}/],
46
+ ["slack-token", /(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,65536}/],
43
47
  ["slack-webhook", /hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/],
44
48
  ["private-key", new RegExp(`-----BEGIN ${PRIVATE_KEY}`)],
45
- ["jwt", /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/],
46
- ["bearer-token", /\bbearer\s+[A-Za-z0-9._~+/-]{20,}/i],
47
- ["basic-auth", /\bauthorization["']?\s*[:=]\s*["']?basic\s+[A-Za-z0-9+/=]{8,}/i],
49
+ ["jwt", /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,65536}\.eyJ[A-Za-z0-9_-]{10,65536}\.[A-Za-z0-9_-]{10,65536}/],
50
+ ["bearer-token", /\bbearer\s+[A-Za-z0-9._~+/-]{20,65536}/i],
51
+ ["basic-auth", /\bauthorization["']?\s*[:=]\s*["']?basic\s+[A-Za-z0-9+/=]{8,65536}/i],
48
52
  ["url-credentials", /[a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/]{0,256}:[^\s@/]{3,256}@/i],
49
53
  // quoted: 12 characters or more, or 8 to 11 holding a digit (a word like "development" is not one)
50
54
  ["assigned-secret", new RegExp(SECRET_KEY + String.raw`["'](?:[^"'\s]{12,256}|(?=[^"'\s]{0,10}\d)[^"'\s]{8,11})["']`, "i")],
@@ -56,15 +60,17 @@ export const PATTERNS = [
56
60
  // a Dockerfile's legacy `ENV KEY value` form, the value alone after the space
57
61
  ["dockerfile-env-password", new RegExp(String.raw`^[+\- ]?\s*ENV\s+[\w.-]{0,64}${PASS_WORD}[\w.-]{0,64}\s+["']?(?![-$<{%])[^\s"',;()\[\]{}=]{6,256}["']?\s*(?:#.*)?$`, "i")],
58
62
  ["flag-secret", /--(?:password|passwd|pass|token|api-key|apikey|secret|client-secret)(?:=|\s+)(?![-$])[^\s"']{6,256}/i],
59
- // curl's -u flag with a user and password pair, glued to it or not; a uid:gid (docker) or a
60
- // host:/path (rsync) is no credential
61
- ["curl-user", /(?<!\S)(?:-u\s*|--user(?:\s+|=))(?!\d{1,10}:\d{1,10}\b(?![:@]))[^\s:=]{1,256}:(?!\/)\S{3,256}/],
62
- ["assigned-hex", /(?:key|auth|token|secret)[\w-]{0,64}["']?\s*[:=]\s*["']?[0-9a-f]{32,}(?![0-9a-z])/i],
63
+ // curl's -u flag with a user and password pair, glued to it or not; a uid:gid (docker) that is
64
+ // the whole value, quoted or not, a host:/path (rsync), a date format (date -u +%H:%M) or an id
65
+ // substitution ($(id -u):$(id -g)) is no credential. A user:group of names is not told apart:
66
+ // that would let admin:admin through.
67
+ ["curl-user", /(?<!\S)(?:-u\s*|--user(?:\s+|=))(?!(["']?)\d{1,10}:\d{1,10}\1(?=["'`\s;|&)]|$))(?!["']?\+%|\))[^\s:=]{1,256}:(?!\/)\S{3,256}/],
68
+ ["assigned-hex", /(?:key|auth|token|secret)[\w-]{0,64}["']?\s*[:=]\s*["']?[0-9a-f]{32,65536}(?![0-9a-z])/i],
63
69
  ];
64
70
 
65
71
  // Long runs of base64-like characters whose Shannon entropy is that of random data, as
66
72
  // lineage's redaction measures them (32 characters or more, 4.5 bits per character or more).
67
- const LONG_RUN = /[A-Za-z0-9+/=]{32,}/g;
73
+ const LONG_RUN = /[A-Za-z0-9+/=]{32,65536}/g;
68
74
  const ENTROPY_FLOOR = 4.5;
69
75
 
70
76
  export function entropy(s) {
@@ -75,10 +81,21 @@ export function entropy(s) {
75
81
  return bits;
76
82
  }
77
83
 
78
- // A subresource-integrity hash (`sha512-<base64>`, as lockfiles hold) is a public checksum.
84
+ // A subresource-integrity hash (`sha512-<base64>`, as lockfiles hold) and a Go module or
85
+ // Terraform lock hash (`h1:` and 43 base64 characters and `=`, as go.sum and .terraform.lock.hcl
86
+ // hold) are public checksums.
79
87
  const SRI_PREFIX = /sha(?:256|384|512)-$/;
80
- const highEntropy = (line) => [...line.matchAll(LONG_RUN)]
81
- .some((m) => !SRI_PREFIX.test(line.slice(Math.max(0, m.index - 7), m.index)) && entropy(m[0]) >= ENTROPY_FLOOR);
88
+ const H1_PREFIX = /(?:^|[^A-Za-z0-9+/=])h1:$/;
89
+ const publicHash = (line, m) => SRI_PREFIX.test(line.slice(Math.max(0, m.index - 7), m.index))
90
+ || (/^[A-Za-z0-9+/]{43}=$/.test(m[0]) && H1_PREFIX.test(line.slice(Math.max(0, m.index - 4), m.index)));
91
+ const highEntropy = (line) => [...line.matchAll(LONG_RUN)].some((m) => !publicHash(line, m) && entropy(m[0]) >= ENTROPY_FLOOR);
92
+
93
+ // A *_FILE key's absolute path, the Docker secret file official images take a password from, is
94
+ // not that password: the kinds that read a value after a key name see a `-` there instead. The
95
+ // other kinds still read the line as it is, so a key's own format shows wherever it sits.
96
+ const SECRET_FILE_PATH = /(?<![\w.-])([\w.-]{0,128}_FILE["']?(?:[ \t]*[:=][ \t]*|[ \t]+)["']?)\/[\w.@-]{1,128}(?:\/[\w.@-]{1,128}){0,32}(?=["']?(?:[\s,;#]|$))/gi;
97
+ const KEYED = new Set(["assigned-secret", "assigned-secret-bare", "assigned-password", "env-password", "config-password",
98
+ "dockerfile-env-password"]);
82
99
 
83
100
  const KEY_END = new RegExp(`-----END ${PRIVATE_KEY}`);
84
101
 
@@ -92,8 +109,9 @@ export function scan(text) {
92
109
  let next = 0;
93
110
  lines.forEach((line, i) => {
94
111
  while (next < ends.length && ends[next] < i + 1) next += 1;
112
+ const keyed = line.replace(SECRET_FILE_PATH, "$1-");
95
113
  for (const [kind, re] of PATTERNS) {
96
- if (!re.test(line)) continue;
114
+ if (!re.test(KEYED.has(kind) ? keyed : line)) continue;
97
115
  hits.push(kind === "private-key" ? { line: i + 1, kind, end: ends[next] ?? null } : { line: i + 1, kind });
98
116
  }
99
117
  if (highEntropy(line)) hits.push({ line: i + 1, kind: "high-entropy" });
@@ -114,7 +132,13 @@ function main(argv) {
114
132
  process.stderr.write(`payload-scan: cannot read ${file}: ${e.code ?? e.message}\n`);
115
133
  return 2;
116
134
  }
117
- const result = scan(text);
135
+ let result;
136
+ try {
137
+ result = scan(text);
138
+ } catch (e) {
139
+ process.stderr.write(`payload-scan: cannot scan ${file}: ${e.message}\n`);
140
+ return 2;
141
+ }
118
142
  process.stdout.write(JSON.stringify({ file, ...result }, null, 2) + "\n");
119
143
  return result.count === 0 ? 0 : 1;
120
144
  }
@@ -2,8 +2,9 @@
2
2
  /**
3
3
  * verifier-probe: which external CLI seats ralph-qa can seat, and with which model,
4
4
  * worked out from this machine alone. It sends no HTTP request and no prompt itself. The
5
- * only child processes are `codex --version`, `codex debug models --bundled` and
6
- * `opencode --version`, the last with opencode's model-list refresh turned off.
5
+ * only child processes are `codex --version` and `codex debug models --bundled`; it starts no
6
+ * gemini and no opencode (opencode 1.3.10 empties its cache folder, the models.dev copy the probe
7
+ * reads too, on any start that finds the folder's version file missing or old).
7
8
  *
8
9
  * node verifier-probe.mjs --runtime=claude|codex [--codex[=<model>]] [--gemini[=<model>]] [--opencode[=<provider/model>]]
9
10
  *
@@ -187,21 +188,20 @@ export const DECISION_REASONS = [
187
188
 
188
189
  export const CLI_DEFAULT = "@cli-default";
189
190
 
190
- // opencode refreshes its models.dev list on every start, `--version` included, unless told not to.
191
- const OPENCODE_QUIET = { OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_AUTOUPDATE: "1" };
192
-
193
191
  const firstLine = (res) => (res?.status === 0 ? String(res.stdout).trim().split("\n")[0] || null : null);
194
192
  const absent = (cli) => ({ cli, present: false, decision: "unseated", reason: "cli-absent" });
195
193
  const adopt = (seat, model, reason) => ({ ...seat, decision: "adopt", model, reason });
196
194
  const withNotes = (seat, notes) => (notes.length ? { ...seat, notes: [...new Set([...(seat.notes ?? []), ...notes])] } : seat);
197
195
 
198
- // TOML keys: bare, "basic" or 'literal', dotted with optional spaces (`a."b.c" . d`).
196
+ // TOML keys: bare, "basic" or 'literal', dotted with optional spaces (`a."b.c" . d`). TOML ends a
197
+ // line only at \n or \r\n, so `.` runs over U+2028 and U+2029 too (the s flag): without it a
198
+ // comment holding one hid a header or a key that codex reads.
199
199
  const KEY_SEG = String.raw`(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)`;
200
200
  const KEY_PATH = String.raw`${KEY_SEG}(?:\s*\.\s*${KEY_SEG})*`;
201
- const HEADER_RE = new RegExp(String.raw`^\s*\[(\[?)\s*(${KEY_PATH})\s*\]\]?\s*(?:#.*)?$`);
202
- const ASSIGN_RE = new RegExp(String.raw`^\s*(${KEY_PATH})\s*=\s*(.*)$`);
203
- const SEG_RE = /"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+)/g;
204
- const STRING_VALUE = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(?:#.*)?$/;
201
+ const HEADER_RE = new RegExp(String.raw`^\s*\[(\[?)\s*(${KEY_PATH})\s*\]\]?\s*(?:#.*)?$`, "s");
202
+ const ASSIGN_RE = new RegExp(String.raw`^\s*(${KEY_PATH})\s*=\s*(.*)$`, "s");
203
+ const SEG_RE = /"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+)/gs;
204
+ const STRING_VALUE = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(?:#.*)?$/s;
205
205
 
206
206
  // A dotted key's segments, quotes taken off (escapes kept as written).
207
207
  const keyPath = (text) => [...text.matchAll(SEG_RE)].map((m) => m[1] ?? m[2] ?? m[3]);
@@ -209,7 +209,7 @@ const keyPath = (text) => [...text.matchAll(SEG_RE)].map((m) => m[1] ?? m[2] ??
209
209
  // The walk's state: where it is (open string, nesting depth, table, array being gathered) and
210
210
  // what it found so far.
211
211
  const tomlState = () => ({ values: {}, arrays: {}, mcpServers: new Set(), mcpUnread: false, modelKeys: false,
212
- open: null, depth: 0, table: [], roots: new Set(), array: null });
212
+ open: null, depth: 0, table: [], roots: new Set(), array: null, unplaced: false });
213
213
 
214
214
  // The index just past the one-line string that opens at `i` (the line's end when it never closes).
215
215
  function stringEnd(text, i) {
@@ -279,8 +279,9 @@ function noteMcp(state, full) {
279
279
  // True when a key path ends in `model`, or its inline-table value sets one.
280
280
  const setsModel = (full, value) => full[full.length - 1] === "model" || (value.startsWith("{") && /[{,]\s*model\s*=/.test(value));
281
281
 
282
- // The strings of a top-level array, gathered line by line until it closes; comments aside.
283
- const ARRAY_ITEM = /"((?:[^"\\]|\\.)*)"|'([^']*)'|#.*/g;
282
+ // The strings of a top-level array, gathered line by line until it closes; comments aside. The
283
+ // gathered text spans lines, so a comment ends at the line's end, U+2028 and U+2029 inside it.
284
+ const ARRAY_ITEM = /"((?:[^"\\]|\\.)*)"|'([^']*)'|#[^\r\n]*/gs;
284
285
  function gatherArray(state, text) {
285
286
  if (!state.array) return;
286
287
  state.array.text += text + "\n";
@@ -312,7 +313,9 @@ function enterTable(state, header) {
312
313
  }
313
314
 
314
315
  // One line of readToml into `state`: walked inside a multi-line string, array or inline table
315
- // (a `model =` there still counts), else a table header or a `key = value`.
316
+ // (a `model =` there still counts), else a table header or a `key = value`. Any other line but
317
+ // a comment or a blank one is a line the probe could not place (a key it cannot spell, such as a
318
+ // non-ASCII bare key), and the read is not whole.
316
319
  function tomlLine(state, line) {
317
320
  if (state.open || state.depth > 0) {
318
321
  if (!state.open && /(?:^|[{,])\s*model\s*=/.test(line)) state.modelKeys = true;
@@ -323,7 +326,8 @@ function tomlLine(state, line) {
323
326
  const header = HEADER_RE.exec(line);
324
327
  if (header) return enterTable(state, header);
325
328
  const assign = ASSIGN_RE.exec(line);
326
- if (assign) assignment(state, keyPath(assign[1]), assign[2]);
329
+ if (assign) return assignment(state, keyPath(assign[1]), assign[2]);
330
+ if (!/^\s*(?:#|$)/.test(line)) state.unplaced = true;
327
331
  }
328
332
 
329
333
  // The servers each [mcp_servers] header in the text names when the lines after it are walked
@@ -356,9 +360,9 @@ const rawMcpNames = (lines) => lines.filter((l) => !/^\s*#/.test(l))
356
360
  // the first segment of every key and table (`roots`), the MCP server names wherever they are
357
361
  // defined (table headers, under [mcp_servers], dotted keys), whether servers were written in a
358
362
  // form it does not list or that the text names beyond what it listed (`mcpUnread`), whether any
359
- // table sets a model (`modelKeys`) and whether a multi-line string, array or inline table was
360
- // left open, and the strings of each top-level array (`arrays`). Enough for those keys; not a
361
- // TOML parser.
363
+ // table sets a model (`modelKeys`), whether a multi-line string, array or inline table was left
364
+ // open or a line could not be placed (`broken`), and the strings of each top-level array
365
+ // (`arrays`). Enough for those keys; not a TOML parser.
362
366
  export function readToml(text) {
363
367
  const state = tomlState();
364
368
  const lines = String(text ?? "").split(/\r?\n/);
@@ -371,7 +375,7 @@ export function readToml(text) {
371
375
  mcpServers: [...state.mcpServers],
372
376
  mcpUnread: state.mcpUnread || unlisted.length > 0,
373
377
  modelKeys: state.modelKeys,
374
- broken: state.open !== null || state.depth > 0,
378
+ broken: state.open !== null || state.depth > 0 || state.unplaced,
375
379
  };
376
380
  }
377
381
 
@@ -509,7 +513,7 @@ function mcpBlockedSeat(seat, conf, notes) {
509
513
  if (!bad && !conf.mcpUnread && !conf.broken) return null;
510
514
  if (bad) notes.push(`${bad} MCP server name(s) hold characters \`-c mcp_servers.<name>.enabled=false\` cannot address: rename them to letters, digits, _ and -`);
511
515
  if (conf.mcpUnread) notes.push("the probe could not list every MCP server the codex config defines (one inline mcp_servers table, or a name its walk did not place): write each server as its own [mcp_servers.<name>] table");
512
- if (conf.broken) notes.push("the probe could not read the codex config to its end (a multi-line string, array or inline table left open), so its list of MCP servers may be short");
516
+ if (conf.broken) notes.push("the probe could not read the codex config to its end (a multi-line string, array or inline table left open, or a line it could not place), so its list of MCP servers may be short");
513
517
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
514
518
  }
515
519
 
@@ -519,12 +523,14 @@ function mcpBlockedSeat(seat, conf, notes) {
519
523
  const SEAT_KEYS = ["notify", "otel", "mcp_servers", "web_search", "tools", "analytics", "features", "hooks",
520
524
  "sandbox_mode", "sandbox_workspace_write", "approval_policy"];
521
525
 
522
- // The unseated seat when the managed file sets one of those keys; null otherwise.
526
+ // The unseated seat when the managed file sets one of those keys, or could not be read to its
527
+ // end (a line read short may set one); null otherwise.
523
528
  function managedBlockedSeat(seat, world, notes) {
524
529
  if (world.platform === "win32") return null;
525
- const keys = readToml(world.readFile(CODEX_MANAGED)).roots.filter((k) => SEAT_KEYS.includes(k));
526
- if (!keys.length) return null;
527
- notes.push(`${CODEX_MANAGED} sets ${keys.join(", ")}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
530
+ const read = readToml(world.readFile(CODEX_MANAGED));
531
+ const keys = read.roots.filter((k) => SEAT_KEYS.includes(k));
532
+ if (!keys.length && !read.broken) return null;
533
+ notes.push(`${CODEX_MANAGED} ${keys.length ? `sets ${keys.join(", ")}` : "could not be read to its end"}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
528
534
  return { ...seat, decision: "unseated", reason: "cli-call-failed" };
529
535
  }
530
536
 
@@ -696,17 +702,21 @@ function geminiStatsNotes(settings) {
696
702
  return set === false ? [] : ["gemini sends usage statistics (metadata, not the payload) to play.googleapis.com; set privacy.usageStatisticsEnabled to false in its settings to stop it"];
697
703
  }
698
704
 
699
- // gemini runs the hooks its settings define in every session, this seat too; no flag turns them off.
700
- // gemini's own sandbox (tools.sandbox, or GEMINI_SANDBOX, which wins) restarts gemini in a docker or
701
- // podman container that the admin policy file does not reach, so every tool opens (0.62.0). The
702
- // seat command turns it off with GEMINI_SANDBOX=false; this says so when it is on.
705
+ // gemini's own sandbox (tools.sandbox, or GEMINI_SANDBOX, which wins) restarts gemini inside it: a
706
+ // docker or podman container, or sandbox-exec on macOS when the value is true. The admin policy
707
+ // file does not reach a container, so every tool opens (0.62.0). The seat command turns it off
708
+ // with GEMINI_SANDBOX=false; this says so when it is on. gemini reads "0" and "false" as off, the
709
+ // env lower-cased and trimmed, the setting as written: "FALSE" there is no off value but a sandbox
710
+ // command gemini does not know, and it stops (Invalid sandbox command). The seat's env goes first.
703
711
  function geminiSandboxNotes(settings, env) {
712
+ const off = (v) => v === "0" || v === "false";
704
713
  const fromEnv = String(env.GEMINI_SANDBOX ?? "").trim().toLowerCase();
705
714
  const set = settings.map((r) => r.value?.tools?.sandbox).find((v) => v !== undefined);
706
- const on = fromEnv ? !["0", "false"].includes(fromEnv) : Boolean(set && (typeof set !== "object" || set.enabled));
715
+ const on = fromEnv ? !off(fromEnv) : Boolean(set && !off(set) && (typeof set !== "object" || set.enabled));
707
716
  return on ? ["gemini's own sandbox is on (tools.sandbox or GEMINI_SANDBOX): the seat command turns it off with GEMINI_SANDBOX=false, as the admin policy does not reach its container"] : [];
708
717
  }
709
718
 
719
+ // gemini runs the hooks its settings define in every session, this seat too; no flag turns them off.
710
720
  function geminiHookNotes(settings) {
711
721
  const off = settings.some((r) => r.value?.hooksConfig?.enabled === false);
712
722
  const hooks = settings.some((r) => Object.keys(r.value?.hooks ?? {}).length > 0);
@@ -816,13 +826,12 @@ function opencodeDecision(seat, { configured, unsure, closed, catalog }) {
816
826
  function opencodeSeat(flag, { world, authorFamilies }) {
817
827
  if (!world.which("opencode")) return absent("opencode");
818
828
  const notes = [];
819
- const version = firstLine(world.run("opencode", ["--version"], OPENCODE_QUIET));
820
829
  const cfg = opencodeConfig(world);
821
830
  const { configured: raw, models } = opencodeModels(cfg);
822
831
  const usable = safeOnly(models, "opencode config", notes);
823
832
  const configured = vetConfigured(usable.includes(raw) ? raw : null, authorFamilies, notes);
824
833
  const sorted = sortModels(usable, authorFamilies);
825
- const seat = { cli: "opencode", present: true, version, configured, candidates: sorted.independent, unclassified: sorted.unclassified };
834
+ const seat = { cli: "opencode", present: true, configured, candidates: sorted.independent, unclassified: sorted.unclassified };
826
835
  notes.push(...cfg.unreadable.map((f) => `config not readable: ${f}`));
827
836
  if (world.env.OPENCODE_CONFIG_CONTENT) notes.push("$OPENCODE_CONFIG_CONTENT is not read: the seat command replaces it with its own reviewer agent");
828
837
  const catalog = catalogProviders(world);