gaslamp 2.2.1 → 2.2.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.
package/README.md CHANGED
@@ -288,15 +288,18 @@ threads, `--schema`, fleets — with these differences:
288
288
  kernel sandbox (`--sandbox read-only`: Seatbelt on macOS, Landlock on Linux;
289
289
  writes only to `~/.grok` and temp dirs). Grok's permission layer is not a
290
290
  substitute: headless `--permission-mode default` denied its write tool but
291
- ran an arbitrary shell `touch`. Antigravity's print mode soft-denies file
292
- writes, shell commands and URL fetches under its default `request-review`
293
- preset; standing allow rules in its permission files would run
294
- unprompted, so a read-only consult refuses to start when they grant more
295
- than reads, read-only git and the end_session off-ramp, or set a lax
296
- `toolPermission` or `agentMode: auto` (which approves writes and commands
297
- under any preset, unreported), and fails if the
298
- harness doesn't confirm the preset in its init event. Like claude's
299
- read-only, that is read-capable rather than airtight: an allowed
291
+ ran an arbitrary shell `touch`. On macOS, a read-only gemini consult runs
292
+ agy under Seatbelt (`sandbox-exec`): it can write its own state directory
293
+ (`~/.gemini/antigravity-cli`, minus the files that configure it) and temp
294
+ dirs, nothing else, whatever Antigravity's permission settings say — so a
295
+ user can keep `always-proceed` / `agentMode: auto` for interactive work, and
296
+ search and read-only commands still run. Elsewhere (Linux, or inside a
297
+ Codex sandbox, where Seatbelt does not nest) the guarantee is Antigravity's
298
+ `request-review` preset: the consult refuses to start when its permission
299
+ files grant more than reads, read-only git and the end_session off-ramp, or
300
+ set a lax `toolPermission` or `agentMode: auto` (which approves writes and
301
+ commands under any preset, unreported), and fails if init doesn't confirm
302
+ the preset. That path is read-capable rather than airtight: an allowed
300
303
  `git diff` still accepts git's own `--output=<file>`.
301
304
  Antigravity has no headless `workspace-write` (`--mode accept-edits` did
302
305
  not enable writes in print mode), so that is a usage error there.
@@ -337,6 +340,7 @@ it. This is loop prevention, not a security boundary. Set
337
340
  | `GASLAMP_DEBUG` | unset | verbose stderr (spawn argv) |
338
341
  | `CLAUDE_BIN` / `CODEX_BIN` / `AGY_BIN` / `GROK_BIN` | autodetected | backend binaries |
339
342
  | `GROK_HOME` | `~/.grok` | where grok keeps sessions (the system prompt hash is read from there) |
343
+ | `GASLAMP_GEMINI_SEATBELT` | on (macOS) | `0` runs read-only gemini consults on Antigravity's preset check instead of Seatbelt |
340
344
  | `CODEX_HOME` | `~/.codex` | where `setup --skill` puts Codex's copy |
341
345
 
342
346
  Exit codes: `0` ok · `1` consult failed/killed · `2` usage · `3` nested
package/bin/gaslamp.mjs CHANGED
@@ -120,6 +120,8 @@ Env knobs:
120
120
  GASLAMP_DEBUG verbose stderr (spawn argv)
121
121
  CLAUDE_BIN / CODEX_BIN / AGY_BIN / GROK_BIN
122
122
  backend binaries (autodetected otherwise)
123
+ GASLAMP_GEMINI_SEATBELT 0 = read-only gemini consults rely on Antigravity's
124
+ preset check instead of Seatbelt (macOS default)
123
125
 
124
126
  Every job record (meta.json) carries provenance: provider, harness version,
125
127
  the model the backend reported (or unknown), the vendor system prompt's hash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaslamp",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "Let Claude Code and Codex consult each other from the shell, and consult Gemini and Grok.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/consult.mjs CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  newJobId, readMeta, renderTrailer, writeMeta,
48
48
  } from "./jobs.mjs";
49
49
  import { readThread, validThreadName, writeThread } from "./threads.mjs";
50
+ import { readStdin } from "./stdin.mjs";
50
51
 
51
52
  // Tools permitted under the claude `read-only` override (advisory reviewer).
52
53
  // The git reads matter: a reviewer that can't run `git diff`/`git log` is
@@ -81,9 +82,14 @@ const RETRIEVAL_TOOLS = {
81
82
  claude: /^(WebSearch|WebFetch)$/,
82
83
  codex: /^web_search/,
83
84
  gemini: /^(search_web|read_url_content|open_browser_url|read_browser_page|browser_)/,
84
- grok: /^(web_search|web_fetch|x_search|x_)/,
85
+ grok: /^(web_search|web_fetch|x_)/,
85
86
  };
86
87
 
88
+ // Grok's X search is a backend tool: init never lists it, and a call arrives
89
+ // as a tool_use named "X search:" ({"variant":"XSearch","backend":true}) whose
90
+ // tool_result names the tool that ran (x_keyword_search, x_semantic_search).
91
+ const toolKey = (name) => String(name).toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
92
+
87
93
  // Variables a third-party harness has no business inheriting from the calling
88
94
  // agent: Claude Code's session id, messaging socket token, account and OAuth
89
95
  // details; Codex's and the API keys' equivalents. They reach the consultee's
@@ -113,9 +119,36 @@ function backendEnv(backend) {
113
119
  if (backend === "gemini" || backend === "grok")
114
120
  for (const k of Object.keys(env)) if (FOREIGN_ENV.test(k)) delete env[k];
115
121
  if (backend === "grok") Object.assign(env, GROK_COMPAT_OFF, { GROK_DISABLE_AUTOUPDATER: "1" });
122
+ if (backend === "gemini") env.AGY_CLI_DISABLE_AUTO_UPDATE = "true";
116
123
  return env;
117
124
  }
118
125
 
126
+ // Read-only for a gemini consult, on macOS: agy runs under Seatbelt, writing
127
+ // nothing but its own state directory and temp — the guarantee no longer
128
+ // depends on Antigravity's permission settings, which a user may keep lax
129
+ // for interactive work (toolPermission always-proceed, agentMode auto) and
130
+ // which no flag overrides per run. Inside that state directory the files
131
+ // that configure agy stay write-denied (later deny rules win in SBPL).
132
+ // (bin/ stays writable: agy extracts a helper, bin/agentapi, before running
133
+ // any shell command, and denying it breaks read-only commands like git diff.)
134
+ // Elsewhere — Linux, or inside a sandbox of Codex's own, where Seatbelt does
135
+ // not nest — the preset check below is the guarantee.
136
+ const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
137
+ const useSeatbelt = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC)
138
+ && !process.env.CODEX_SANDBOX && process.env.GASLAMP_GEMINI_SEATBELT !== "0";
139
+ function agySeatbeltProfile() {
140
+ const q = (p) => '"' + p.replace(/[\\"]/g, (c) => "\\" + c) + '"';
141
+ const state = join(homedir(), ".gemini", "antigravity-cli");
142
+ return [
143
+ "(version 1)",
144
+ "(allow default)",
145
+ "(deny file-write*)",
146
+ `(allow file-write* (subpath ${q(state)}) (subpath "/private/tmp") (subpath "/private/var/folders") (subpath "/dev"))`,
147
+ `(deny file-write* ${["settings.json", "keybindings.json"].map((f) => `(literal ${q(join(state, f))})`).join(" ")} ` +
148
+ `${["rules", "plugins", "skills", "updater"].map((d) => `(subpath ${q(join(state, d))})`).join(" ")})`,
149
+ ].join("\n");
150
+ }
151
+
119
152
  // What a read-only gemini consult may find pre-allowed in Antigravity's
120
153
  // permission files: reads, read-only git (read-capable like the claude read
121
154
  // set — git's own --output flags aside), and the end_session off-ramp. Any
@@ -491,12 +524,12 @@ export function runConsult(backend, argv) {
491
524
  let prompt = o.prompt;
492
525
  if (prompt == null || prompt === "-") {
493
526
  if (prompt == null && process.stdin.isTTY) die(2, "no prompt — pass one as an argument or pipe it on stdin");
494
- prompt = readFileSync(0, "utf8");
527
+ prompt = readStdin();
495
528
  } else if (!process.stdin.isTTY) {
496
529
  // Prompt on argv AND piped stdin: stdin is evidence — the diff, the log,
497
530
  // the failing output — appended as a <stdin> block. This is codex exec's
498
531
  // own convention for exactly this case, applied uniformly to both backends.
499
- const evidence = readFileSync(0, "utf8");
532
+ const evidence = readStdin();
500
533
  if (evidence.trim())
501
534
  prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
502
535
  }
@@ -522,19 +555,28 @@ export function runConsult(backend, argv) {
522
555
  // must not strand either.
523
556
  const bin = resolveBin(backend);
524
557
  const env = backendEnv(backend);
525
- let grants = null;
558
+ let grants = null, agySettings = null, wrap = null;
559
+ let enforcement = sandbox === "read-only" ? {
560
+ claude: "permission-mode default + read allowlist", codex: "codex read-only sandbox",
561
+ grok: "grok --sandbox read-only (kernel)", gemini: null,
562
+ }[backend] : null;
526
563
  if (backend === "gemini" && sandbox === "read-only") {
527
564
  const state = agyPermissionState();
565
+ grants = state.grants.map((g) => g.rule);
566
+ agySettings = [...state.presets.map((p) => `toolPermission ${p.preset}`), ...state.modes.map((m) => `agentMode ${m.mode}`)];
528
567
  const lax = state.presets.filter((p) => AGY_LAX_PRESETS.has(p.preset));
529
568
  const modes = state.modes.filter((m) => AGY_LAX_MODES.has(m.mode));
530
569
  const open = state.grants.filter((g) => !AGY_READ_GRANT.test(g.rule));
531
- if (lax.length || modes.length || open.length)
570
+ if (useSeatbelt()) {
571
+ wrap = [SANDBOX_EXEC, "-p", agySeatbeltProfile()];
572
+ enforcement = "seatbelt (sandbox-exec)";
573
+ } else if (lax.length || modes.length || open.length)
532
574
  die(1, "Antigravity's settings would let a read-only consult act unprompted — " +
533
575
  [...lax.map((p) => `toolPermission "${p.preset}" (${p.file})`),
534
576
  ...modes.map((m) => `agentMode "${m.mode}" (${m.file})`),
535
577
  ...open.map((g) => `${g.rule} (${g.file})`)].join(", ") +
536
578
  ". Remove them, or pass --sandbox danger-full-access to consult without the guarantee.");
537
- grants = state.grants.map((g) => g.rule);
579
+ else enforcement = "agy request-review preset";
538
580
  }
539
581
  const model = resolveModel(backend, o.model, bin, env);
540
582
  const version = harnessVersion(bin, env);
@@ -558,8 +600,8 @@ export function runConsult(backend, argv) {
558
600
  // `model` is what the backend reported answering with; unknown until it
559
601
  // does (codex never reports one in its event stream).
560
602
  model: "unknown", modelRequested: o.model ?? null, modelPassed: model,
561
- permissionMode: null, sandbox,
562
- ...(grants ? { grants } : {}),
603
+ permissionMode: null, sandbox, enforcement,
604
+ ...(grants ? { grants, agySettings } : {}),
563
605
  systemPrompt: { known: false },
564
606
  retrieval: { available: null, used: [] },
565
607
  gaslamp: { version: PKG_VERSION, revision: gaslampRevision() },
@@ -584,10 +626,13 @@ export function runConsult(backend, argv) {
584
626
  : backend === "gemini" ? geminiArgs(o, resumeSid, schemaText, model, sandbox)
585
627
  : grokArgs(o, resumeSid, schemaText, sandbox);
586
628
 
587
- log("spawn", bin, JSON.stringify(args));
629
+ // sandbox-exec applies the profile and execs the harness in place (same
630
+ // pid), so the group, the signals and the process name are agy's own.
631
+ const [cmd, argv0] = wrap ? [wrap[0], [...wrap.slice(1), bin, ...args]] : [bin, args];
632
+ log("spawn", cmd, JSON.stringify(argv0));
588
633
  // detached → the child leads its own process group, so signal forwarding can
589
634
  // kill the whole tree (backend + whatever it spawned), never just the top.
590
- const child = spawn(bin, args, { cwd: meta.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
635
+ const child = spawn(cmd, argv0, { cwd: meta.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
591
636
  meta.childPid = child.pid ?? null;
592
637
  writeMeta(meta);
593
638
  const killGroup = (sig) => {
@@ -680,12 +725,20 @@ export function runConsult(backend, argv) {
680
725
  if (j.model) prov.model = j.model;
681
726
  prov.permissionMode = j.permissionMode ?? null;
682
727
  if (Array.isArray(j.tools)) prov.retrieval.available = j.tools.filter((t) => RETRIEVAL_TOOLS[backend].test(t));
728
+ // Grok's backend X search is never listed but is there (see toolKey).
729
+ if (backend === "grok" && prov.retrieval.available) prov.retrieval.available.push("x_search");
683
730
  writeMeta(meta);
684
731
  } else if (claudeShaped && j.type === "assistant") {
685
732
  for (const c of j.message?.content ?? []) {
686
- if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(c.name);
733
+ if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(toolKey(c.name));
687
734
  if (c.type === "tool_use" && c.name === "use_tool") noteTool(c.input?.tool_name);
688
735
  }
736
+ } else if (backend === "grok" && j.type === "user") {
737
+ for (const c of j.message?.content ?? []) {
738
+ if (c.type !== "tool_result" || typeof c.content !== "string") continue;
739
+ let r; try { r = JSON.parse(c.content); } catch { continue; }
740
+ if (typeof r?.name === "string") noteTool(r.name);
741
+ }
689
742
  } else if (backend === "gemini" && j.event === "init") {
690
743
  initSeen = true;
691
744
  const init = j.init ?? {};
@@ -695,9 +748,9 @@ export function runConsult(backend, argv) {
695
748
  if (Array.isArray(init.tools)) prov.retrieval.available = [...new Set(init.tools
696
749
  .filter((t) => RETRIEVAL_TOOLS.gemini.test(t)).map((t) => t.startsWith("browser_") ? "browser_*" : t))];
697
750
  writeMeta(meta);
698
- // Read-only rests on Antigravity's request-review (or strict) preset;
699
- // a laxer one would let the consult act unprompted.
700
- if (sandbox === "read-only" && !["request-review", "strict"].includes(prov.permissionMode))
751
+ // Without Seatbelt, read-only rests on Antigravity's request-review (or
752
+ // strict) preset; a laxer one would let the consult act unprompted.
753
+ if (sandbox === "read-only" && !wrap && !["request-review", "strict"].includes(prov.permissionMode))
701
754
  refuse(`agy reports permission mode "${prov.permissionMode}", not request-review — refusing a read-only consult it would not enforce`);
702
755
  else initValidated = true;
703
756
  } else if (backend === "gemini" && j.event === "step_update") {
@@ -816,7 +869,7 @@ export function runConsult(backend, argv) {
816
869
  : structuredOut !== undefined ? JSON.stringify(structuredOut) : resultText;
817
870
 
818
871
  let status = killedBy ? "killed" : (refusal || code !== 0 || resultErr) ? "failed" : "done";
819
- if (status === "done" && backend === "gemini" && sandbox === "read-only" && !initValidated) {
872
+ if (status === "done" && backend === "gemini" && sandbox === "read-only" && !wrap && !initValidated) {
820
873
  status = "failed";
821
874
  refusal = "agy reported no init event, so its permission mode — the read-only guarantee — went unconfirmed";
822
875
  writeSync(2, `[gaslamp] ${refusal}\n`);
package/src/fleet.mjs CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  } from "./jobs.mjs";
44
44
  import { BACKEND_NAMES, isBackend } from "./backends.mjs";
45
45
  import { readThread } from "./threads.mjs";
46
+ import { readStdin } from "./stdin.mjs";
46
47
 
47
48
  const GASLAMP_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gaslamp.mjs");
48
49
  // Quota-shaped, not CPU-shaped: the bottleneck is API rate limits, not cores
@@ -116,7 +117,7 @@ function buildTasks(backend, o) {
116
117
  let raw;
117
118
  const stdinRequested = o.stdin || (o.prompt == null && !process.stdin.isTTY);
118
119
  if (stdinRequested) {
119
- const lines = readFileSync(0, "utf8").split("\n").map((l) => l.trim()).filter(Boolean);
120
+ const lines = readStdin().split("\n").map((l) => l.trim()).filter(Boolean);
120
121
  if (!lines.length) die(2, "no tasks on stdin");
121
122
  raw = lines.map((line, i) => norm(line.startsWith("{") ? parseJsonLine(line, i) : line, i));
122
123
  } else {
@@ -125,7 +126,7 @@ function buildTasks(backend, o) {
125
126
  // <stdin> convention as a single consult (git diff | gaslamp fleet …).
126
127
  let prompt = o.prompt;
127
128
  if (!process.stdin.isTTY) {
128
- const evidence = readFileSync(0, "utf8");
129
+ const evidence = readStdin();
129
130
  if (evidence.trim())
130
131
  prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
131
132
  }
package/src/setup.mjs CHANGED
@@ -121,7 +121,7 @@ Omit \`--model\` to use ${Other}'s own configured default. Match the tier to the
121
121
 
122
122
  Same contract as above — threads, \`--schema\`, fleets, records — with two differences:
123
123
 
124
- - **Read-only unless you say otherwise.** An omitted \`--sandbox\` means read-only, enforced by the harness: Grok runs in its kernel sandbox; Antigravity's print mode denies writes, shell commands and URL fetches, and gaslamp refuses the consult if Antigravity's settings pre-allow more than reads, read-only git and the end_session off-ramp. A denied tool call can end a turn with nothing to say, which gaslamp marks failed — so pipe the evidence in rather than asking them to go fetch it. Gemini has no \`workspace-write\`; \`danger-full-access\` exists on both.
124
+ - **Read-only unless you say otherwise.** An omitted \`--sandbox\` means read-only, kernel-enforced where possible: Grok runs in its own sandbox, and on macOS gaslamp runs Antigravity under Seatbelt, so neither writes outside its own state and temp dirs (Antigravity elsewhere relies on its request-review preset, and gaslamp refuses lax settings). Inside that they read local files and search — Google search on Gemini, web and X search on Grok — so for a search consult send the question and its constraints and let them fetch; for a review of material you already hold, pipe it in. What fails is writing: a write or a writing shell command errors and the turn goes on, except on Antigravity's preset path, where writes and shell commands are refused outright and can end the turn with nothing to say (gaslamp marks that failed), and its URL fetch is not offered at all. Gemini has no \`workspace-write\`; \`danger-full-access\` exists on both.
125
125
  - **Provenance on every job.** \`meta.json\` records the model the harness reported (Grok also shows the model it billed), the harness version, a hash of the vendor system prompt where the harness exposes it (Grok) or \`known: false\`, and which web/X retrieval tools the consultee had and actually used.
126
126
 
127
127
  \`gaslamp gemini --model\` takes \`argon\` (the default) or \`flash\`, resolved to the newest \`gemini-<version>-<tier>\` in Antigravity's own catalog; Antigravity also serves Claude and GPT models under the same account, so the target always pins a Gemini unless you pass another id. \`gaslamp grok\` uses the account's default model unless \`--model\` names one (\`grok models\` lists them).
package/src/stdin.mjs ADDED
@@ -0,0 +1,28 @@
1
+ // stdin.mjs — read all of stdin, cat-like, even when it is non-blocking.
2
+ //
3
+ // readFileSync(0) throws EAGAIN instead of waiting when the caller hands
4
+ // gaslamp a stdin with O_NONBLOCK set (seen from an agent harness's shell),
5
+ // and the consult died before reading its evidence. Reading in a loop that
6
+ // naps on EAGAIN waits for the data and the EOF the way a blocking read would;
7
+ // on macOS reopening /dev/stdin is no escape, since it dups the same
8
+ // non-blocking file description.
9
+
10
+ import { readSync } from "node:fs";
11
+
12
+ export function readStdin() {
13
+ const chunks = [];
14
+ const buf = Buffer.alloc(1 << 16);
15
+ const nap = new Int32Array(new SharedArrayBuffer(4));
16
+ for (;;) {
17
+ let n;
18
+ try { n = readSync(0, buf, 0, buf.length, null); }
19
+ catch (e) {
20
+ if (e.code === "EAGAIN") { Atomics.wait(nap, 0, 0, 10); continue; }
21
+ if (e.code === "EOF") break;
22
+ throw e;
23
+ }
24
+ if (n === 0) break;
25
+ chunks.push(Buffer.from(buf.subarray(0, n)));
26
+ }
27
+ return Buffer.concat(chunks).toString("utf8");
28
+ }