@solongate/proxy 0.83.10 → 0.83.12

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.
@@ -15,7 +15,7 @@ import { resolve, join, dirname } from "path";
15
15
  import { homedir } from "os";
16
16
  import { fileURLToPath } from "url";
17
17
  import { createInterface } from "readline";
18
- import { execFileSync } from "child_process";
18
+ import { execFileSync, spawn } from "child_process";
19
19
  var __dirname = dirname(fileURLToPath(import.meta.url));
20
20
  var HOOKS_DIR = resolve(__dirname, "..", "hooks");
21
21
  function globalPaths() {
@@ -25,6 +25,7 @@ function globalPaths() {
25
25
  const claudeDir = join(home, ".claude");
26
26
  const antigravityDir = join(home, ".gemini", "config");
27
27
  const codexDir = process.env["CODEX_HOME"] ? resolve(process.env["CODEX_HOME"]) : join(home, ".codex");
28
+ const opencodeDir = join(process.env["XDG_CONFIG_HOME"] ? resolve(process.env["XDG_CONFIG_HOME"]) : join(home, ".config"), "opencode");
28
29
  return {
29
30
  home,
30
31
  sgDir,
@@ -32,6 +33,7 @@ function globalPaths() {
32
33
  claudeDir,
33
34
  antigravityDir,
34
35
  codexDir,
36
+ opencodeDir,
35
37
  settingsPath: join(claudeDir, "settings.json"),
36
38
  backupPath: join(claudeDir, "settings.solongate.bak"),
37
39
  configPath: join(sgDir, "cloud-guard.json"),
@@ -45,7 +47,14 @@ function globalPaths() {
45
47
  // the user's TOML, which also holds the hook TRUST state Codex manages).
46
48
  codexHooksPath: join(codexDir, "hooks.json"),
47
49
  codexBackupPath: join(codexDir, "hooks.solongate.bak"),
48
- codexConfigPath: join(codexDir, "config.toml")
50
+ codexConfigPath: join(codexDir, "config.toml"),
51
+ // OpenCode scans its plugin folder at startup and loads every module in it.
52
+ // Measured on 1.18.10: BOTH `plugin/` and `plugins/` are scanned, so the
53
+ // docs and the field reports are each half right. We write the documented
54
+ // one. There is nothing to register anywhere — dropping the file IS the
55
+ // installation, which also means deleting the file IS the uninstall.
56
+ opencodePluginDir: join(opencodeDir, "plugins"),
57
+ opencodePluginPath: join(opencodeDir, "plugins", "solongate.js")
49
58
  };
50
59
  }
51
60
  function isOurCodexGroup(group) {
@@ -122,6 +131,20 @@ function codexHooksStatus() {
122
131
  }
123
132
  return { registered, trusted, disabled };
124
133
  }
134
+ function isOpencodeGuardInstalled() {
135
+ try {
136
+ return readFileSync(globalPaths().opencodePluginPath, "utf-8").includes("tool.execute.before");
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ function opencodeDetected() {
142
+ try {
143
+ return existsSync(globalPaths().opencodeDir);
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
125
148
  function installedGuardVersion() {
126
149
  try {
127
150
  const p = globalPaths();
@@ -132,6 +155,16 @@ function installedGuardVersion() {
132
155
  return null;
133
156
  }
134
157
  }
158
+ function isGuardInstalled() {
159
+ try {
160
+ const p = globalPaths();
161
+ if (!existsSync(p.settingsPath)) return false;
162
+ const s = JSON.parse(readFileSync(p.settingsPath, "utf-8"));
163
+ return !!s.hooks && JSON.stringify(s.hooks).includes(".solongate");
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
135
168
 
136
169
  // src/api-client/client.ts
137
170
  var DEFAULT_API_URL = "https://api.solongate.com";
@@ -267,7 +300,7 @@ async function request(method, path, opts = {}) {
267
300
  }
268
301
 
269
302
  // src/api-client/device-login.ts
270
- import { spawn } from "child_process";
303
+ import { spawn as spawn2 } from "child_process";
271
304
 
272
305
  // src/api-client/auth.ts
273
306
  var auth_exports = {};
@@ -1236,18 +1269,40 @@ async function collectChecks() {
1236
1269
  } catch {
1237
1270
  }
1238
1271
  }
1272
+ const claudeReg = isGuardInstalled();
1273
+ checks.push({
1274
+ name: "Claude hooks",
1275
+ ok: claudeReg,
1276
+ detail: claudeReg ? "guard registered" : "guard NOT registered - run `solongate repair`"
1277
+ });
1278
+ if (existsSync4(globalPaths().antigravityDir)) {
1279
+ const reg = existsSync4(globalPaths().antigravityHooksPath);
1280
+ checks.push({
1281
+ name: "agy hooks",
1282
+ ok: reg,
1283
+ detail: reg ? "guard registered" : "guard NOT registered - run `solongate repair`"
1284
+ });
1285
+ }
1239
1286
  if (codexDetected()) {
1240
1287
  const cx = codexHooksStatus();
1241
1288
  if (!cx.registered) {
1242
- checks.push({ name: "codex hooks", ok: false, detail: "guard not registered - run `solongate repair`" });
1289
+ checks.push({ name: "Codex hooks", ok: false, detail: "guard NOT registered - run `solongate repair`" });
1243
1290
  } else if (cx.disabled) {
1244
- checks.push({ name: "codex hooks", ok: false, detail: "hooks disabled in ~/.codex/config.toml ([features] hooks = false)" });
1291
+ checks.push({ name: "Codex hooks", ok: false, detail: "hooks disabled in ~/.codex/config.toml ([features] hooks = false)" });
1245
1292
  } else if (!cx.trusted) {
1246
- checks.push({ name: "codex hooks", ok: "warn", detail: "registered - run `/hooks` in Codex once and trust them (Codex skips untrusted hooks)" });
1293
+ checks.push({ name: "Codex hooks", ok: "warn", detail: "registered - run `/hooks` in Codex once and trust them (Codex skips untrusted hooks)" });
1247
1294
  } else {
1248
- checks.push({ name: "codex hooks", ok: true, detail: "registered + trusted" });
1295
+ checks.push({ name: "Codex hooks", ok: true, detail: "registered + trusted" });
1249
1296
  }
1250
1297
  }
1298
+ if (opencodeDetected()) {
1299
+ const reg = isOpencodeGuardInstalled();
1300
+ checks.push({
1301
+ name: "OpenCode hooks",
1302
+ ok: reg,
1303
+ detail: reg ? "guard registered (not active under `opencode --pure`)" : "guard NOT registered - run `solongate repair`"
1304
+ });
1305
+ }
1251
1306
  try {
1252
1307
  const raw = readFileSync5(join4(homedir4(), ".solongate", ".key-rejected.json"), "utf-8");
1253
1308
  const m = JSON.parse(raw);
@@ -22,6 +22,7 @@ export declare function globalPaths(): {
22
22
  claudeDir: string;
23
23
  antigravityDir: string;
24
24
  codexDir: string;
25
+ opencodeDir: string;
25
26
  settingsPath: string;
26
27
  backupPath: string;
27
28
  configPath: string;
@@ -30,6 +31,8 @@ export declare function globalPaths(): {
30
31
  codexHooksPath: string;
31
32
  codexBackupPath: string;
32
33
  codexConfigPath: string;
34
+ opencodePluginDir: string;
35
+ opencodePluginPath: string;
33
36
  };
34
37
  export declare function clearGuardUpdateCheck(): boolean;
35
38
  /** Is our guard currently registered in Codex's hooks.json on THIS device? */
@@ -48,6 +51,10 @@ export declare function codexHooksStatus(): {
48
51
  trusted: boolean;
49
52
  disabled: boolean;
50
53
  };
54
+ export declare function isOpencodeGuardInstalled(): boolean;
55
+ /** True when OpenCode looks present on this device, so callers can decide
56
+ * whether the OpenCode line is worth showing at all. */
57
+ export declare function opencodeDetected(): boolean;
51
58
  export declare function runGlobalRestore(): void;
52
59
  /**
53
60
  * TUI-safe (re)install — the dataroom's one-key "install / update guard" action.
@@ -4,7 +4,7 @@ import { resolve, join, dirname } from "path";
4
4
  import { homedir } from "os";
5
5
  import { fileURLToPath } from "url";
6
6
  import { createInterface } from "readline";
7
- import { execFileSync } from "child_process";
7
+ import { execFileSync, spawn } from "child_process";
8
8
  var __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  var HOOKS_DIR = resolve(__dirname, "..", "hooks");
10
10
  function lockFile(file) {
@@ -89,7 +89,11 @@ function protectedTargets() {
89
89
  p.configPath,
90
90
  p.settingsPath,
91
91
  p.antigravityHooksPath,
92
- p.codexHooksPath
92
+ p.codexHooksPath,
93
+ // The OpenCode plugin IS the registration — delete it and the guard is gone
94
+ // from that client with nothing left to notice the absence, so it belongs
95
+ // under the same OS lock as every other protection file.
96
+ p.opencodePluginPath
93
97
  ];
94
98
  }
95
99
  function lockProtected() {
@@ -121,6 +125,7 @@ function globalPaths() {
121
125
  const claudeDir = join(home, ".claude");
122
126
  const antigravityDir = join(home, ".gemini", "config");
123
127
  const codexDir = process.env["CODEX_HOME"] ? resolve(process.env["CODEX_HOME"]) : join(home, ".codex");
128
+ const opencodeDir = join(process.env["XDG_CONFIG_HOME"] ? resolve(process.env["XDG_CONFIG_HOME"]) : join(home, ".config"), "opencode");
124
129
  return {
125
130
  home,
126
131
  sgDir,
@@ -128,6 +133,7 @@ function globalPaths() {
128
133
  claudeDir,
129
134
  antigravityDir,
130
135
  codexDir,
136
+ opencodeDir,
131
137
  settingsPath: join(claudeDir, "settings.json"),
132
138
  backupPath: join(claudeDir, "settings.solongate.bak"),
133
139
  configPath: join(sgDir, "cloud-guard.json"),
@@ -141,7 +147,14 @@ function globalPaths() {
141
147
  // the user's TOML, which also holds the hook TRUST state Codex manages).
142
148
  codexHooksPath: join(codexDir, "hooks.json"),
143
149
  codexBackupPath: join(codexDir, "hooks.solongate.bak"),
144
- codexConfigPath: join(codexDir, "config.toml")
150
+ codexConfigPath: join(codexDir, "config.toml"),
151
+ // OpenCode scans its plugin folder at startup and loads every module in it.
152
+ // Measured on 1.18.10: BOTH `plugin/` and `plugins/` are scanned, so the
153
+ // docs and the field reports are each half right. We write the documented
154
+ // one. There is nothing to register anywhere — dropping the file IS the
155
+ // installation, which also means deleting the file IS the uninstall.
156
+ opencodePluginDir: join(opencodeDir, "plugins"),
157
+ opencodePluginPath: join(opencodeDir, "plugins", "solongate.js")
145
158
  };
146
159
  }
147
160
  function clearGuardUpdateCheck() {
@@ -341,6 +354,47 @@ function codexHooksStatus() {
341
354
  }
342
355
  return { registered, trusted, disabled };
343
356
  }
357
+ function installOpencodeGuard(p) {
358
+ mkdirSync(p.opencodePluginDir, { recursive: true });
359
+ const src = readHook("opencode-plugin.mjs").replace("__SOLONGATE_NODE__", process.execPath.replace(/\\/g, "\\\\"));
360
+ writeProtectedFile(p.opencodePluginPath, src);
361
+ }
362
+ function removeOpencodeGuard(p) {
363
+ try {
364
+ unlockFile(p.opencodePluginPath);
365
+ rmSync(p.opencodePluginPath, { force: true });
366
+ } catch {
367
+ }
368
+ }
369
+ function warmPolicyCache(hooksDir, agents) {
370
+ for (const agent of agents) {
371
+ try {
372
+ const child = spawn(process.execPath, [join(hooksDir, "guard.mjs"), agent, "--sg-refresh-policy"], {
373
+ detached: true,
374
+ stdio: "ignore",
375
+ windowsHide: true
376
+ });
377
+ child.on("error", () => {
378
+ });
379
+ child.unref();
380
+ } catch {
381
+ }
382
+ }
383
+ }
384
+ function isOpencodeGuardInstalled() {
385
+ try {
386
+ return readFileSync(globalPaths().opencodePluginPath, "utf-8").includes("tool.execute.before");
387
+ } catch {
388
+ return false;
389
+ }
390
+ }
391
+ function opencodeDetected() {
392
+ try {
393
+ return existsSync(globalPaths().opencodeDir);
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
344
398
  function ask(question) {
345
399
  const rl = createInterface({ input: process.stdin, output: process.stderr });
346
400
  return new Promise((res) => rl.question(question, (a) => {
@@ -357,6 +411,10 @@ function runGlobalRestore() {
357
411
  removeCodexGuard(p);
358
412
  } catch {
359
413
  }
414
+ try {
415
+ removeOpencodeGuard(p);
416
+ } catch {
417
+ }
360
418
  if (existsSync(p.backupPath)) {
361
419
  writeFileSync(p.settingsPath, readFileSync(p.backupPath, "utf-8"));
362
420
  console.log(` Restored ${p.settingsPath} from backup.`);
@@ -381,17 +439,19 @@ function repairQuiet() {
381
439
  const before = [
382
440
  line("guard hook file", has(guardFile), "present", "MISSING"),
383
441
  line("cloud credential", has(p.configPath), "present", "MISSING"),
384
- line("Claude Code settings", isGuardInstalled(), "guard registered", "guard NOT registered"),
385
- line("Antigravity hooks", has(p.antigravityHooksPath), "present", "MISSING"),
386
- line("Codex hooks", isCodexGuardInstalled(), "guard registered", "guard NOT registered")
442
+ line("Claude hooks", isGuardInstalled(), "guard registered", "guard NOT registered"),
443
+ line("agy hooks", has(p.antigravityHooksPath), "guard registered", "guard NOT registered"),
444
+ line("Codex hooks", isCodexGuardInstalled(), "guard registered", "guard NOT registered"),
445
+ line("OpenCode hooks", isOpencodeGuardInstalled(), "guard registered", "guard NOT registered")
387
446
  ];
388
447
  const r = installGlobalQuiet();
389
448
  if (!r.ok) return { ok: false, message: r.message, before, after: [], notes: [] };
390
449
  const after = [
391
450
  { label: "guard hook file", ok: true, detail: `present (v${installedGuardVersion() ?? "?"})` },
392
- line("Claude Code settings", isGuardInstalled(), "guard registered", "NOT registered"),
393
- line("Antigravity hooks", has(p.antigravityHooksPath), "present", "MISSING"),
394
- line("Codex hooks", isCodexGuardInstalled(), "guard registered", "NOT registered")
451
+ line("Claude hooks", isGuardInstalled(), "guard registered", "NOT registered"),
452
+ line("agy hooks", has(p.antigravityHooksPath), "guard registered", "NOT registered"),
453
+ line("Codex hooks", isCodexGuardInstalled(), "guard registered", "NOT registered"),
454
+ line("OpenCode hooks", isOpencodeGuardInstalled(), "guard registered", "NOT registered")
395
455
  ];
396
456
  const notes = [];
397
457
  const cx = codexHooksStatus();
@@ -494,6 +554,11 @@ function installGlobalQuiet() {
494
554
  installCodexGuard(p, p.hooksDir);
495
555
  } catch {
496
556
  }
557
+ try {
558
+ installOpencodeGuard(p);
559
+ } catch {
560
+ }
561
+ warmPolicyCache(p.hooksDir, ["claude-code", "codex", "antigravity", "opencode"]);
497
562
  if (process.env["SOLONGATE_NO_OS_LOCK"] !== "1") lockProtected();
498
563
  return { ok: true, message: "guard installed (open a new session)" };
499
564
  } catch (e) {
@@ -541,6 +606,10 @@ function uninstallGlobalQuiet() {
541
606
  removeCodexGuard(p);
542
607
  } catch {
543
608
  }
609
+ try {
610
+ removeOpencodeGuard(p);
611
+ } catch {
612
+ }
544
613
  if (!existsSync(p.settingsPath)) return { ok: true, message: "guard removed (open a new session)" };
545
614
  const s = JSON.parse(readFileSync(p.settingsPath, "utf-8"));
546
615
  delete s.hooks;
@@ -703,6 +772,13 @@ async function runGlobalInstall(opts = {}) {
703
772
  }
704
773
  } catch {
705
774
  }
775
+ try {
776
+ installOpencodeGuard(p);
777
+ console.log(` Installed OpenCode plugin \u2192 ${p.opencodePluginPath}`);
778
+ console.log(" OpenCode: `opencode --pure` runs without external plugins and");
779
+ console.log(" therefore without the guard. That is its own escape hatch.");
780
+ } catch {
781
+ }
706
782
  if (process.env["SOLONGATE_NO_OS_LOCK"] !== "1") {
707
783
  lockProtected();
708
784
  console.log(" Locked protection files (OS-level read-only/immutable).");
@@ -723,7 +799,9 @@ export {
723
799
  installedGuardVersion,
724
800
  isCodexGuardInstalled,
725
801
  isGuardInstalled,
802
+ isOpencodeGuardInstalled,
726
803
  lockProtected,
804
+ opencodeDetected,
727
805
  removeClaudeShim,
728
806
  repairQuiet,
729
807
  runGlobalInstall,
package/dist/index.js CHANGED
@@ -6201,7 +6201,9 @@ __export(global_install_exports, {
6201
6201
  installedGuardVersion: () => installedGuardVersion,
6202
6202
  isCodexGuardInstalled: () => isCodexGuardInstalled,
6203
6203
  isGuardInstalled: () => isGuardInstalled,
6204
+ isOpencodeGuardInstalled: () => isOpencodeGuardInstalled,
6204
6205
  lockProtected: () => lockProtected,
6206
+ opencodeDetected: () => opencodeDetected,
6205
6207
  removeClaudeShim: () => removeClaudeShim,
6206
6208
  repairQuiet: () => repairQuiet,
6207
6209
  runGlobalInstall: () => runGlobalInstall,
@@ -6216,7 +6218,7 @@ import { resolve as resolve3, join as join4, dirname } from "path";
6216
6218
  import { homedir as homedir2 } from "os";
6217
6219
  import { fileURLToPath } from "url";
6218
6220
  import { createInterface } from "readline";
6219
- import { execFileSync as execFileSync2 } from "child_process";
6221
+ import { execFileSync as execFileSync2, spawn } from "child_process";
6220
6222
  function lockFile(file) {
6221
6223
  if (!existsSync3(file)) return;
6222
6224
  try {
@@ -6299,7 +6301,11 @@ function protectedTargets() {
6299
6301
  p.configPath,
6300
6302
  p.settingsPath,
6301
6303
  p.antigravityHooksPath,
6302
- p.codexHooksPath
6304
+ p.codexHooksPath,
6305
+ // The OpenCode plugin IS the registration — delete it and the guard is gone
6306
+ // from that client with nothing left to notice the absence, so it belongs
6307
+ // under the same OS lock as every other protection file.
6308
+ p.opencodePluginPath
6303
6309
  ];
6304
6310
  }
6305
6311
  function lockProtected() {
@@ -6331,6 +6337,7 @@ function globalPaths() {
6331
6337
  const claudeDir = join4(home, ".claude");
6332
6338
  const antigravityDir = join4(home, ".gemini", "config");
6333
6339
  const codexDir = process.env["CODEX_HOME"] ? resolve3(process.env["CODEX_HOME"]) : join4(home, ".codex");
6340
+ const opencodeDir = join4(process.env["XDG_CONFIG_HOME"] ? resolve3(process.env["XDG_CONFIG_HOME"]) : join4(home, ".config"), "opencode");
6334
6341
  return {
6335
6342
  home,
6336
6343
  sgDir,
@@ -6338,6 +6345,7 @@ function globalPaths() {
6338
6345
  claudeDir,
6339
6346
  antigravityDir,
6340
6347
  codexDir,
6348
+ opencodeDir,
6341
6349
  settingsPath: join4(claudeDir, "settings.json"),
6342
6350
  backupPath: join4(claudeDir, "settings.solongate.bak"),
6343
6351
  configPath: join4(sgDir, "cloud-guard.json"),
@@ -6351,7 +6359,14 @@ function globalPaths() {
6351
6359
  // the user's TOML, which also holds the hook TRUST state Codex manages).
6352
6360
  codexHooksPath: join4(codexDir, "hooks.json"),
6353
6361
  codexBackupPath: join4(codexDir, "hooks.solongate.bak"),
6354
- codexConfigPath: join4(codexDir, "config.toml")
6362
+ codexConfigPath: join4(codexDir, "config.toml"),
6363
+ // OpenCode scans its plugin folder at startup and loads every module in it.
6364
+ // Measured on 1.18.10: BOTH `plugin/` and `plugins/` are scanned, so the
6365
+ // docs and the field reports are each half right. We write the documented
6366
+ // one. There is nothing to register anywhere — dropping the file IS the
6367
+ // installation, which also means deleting the file IS the uninstall.
6368
+ opencodePluginDir: join4(opencodeDir, "plugins"),
6369
+ opencodePluginPath: join4(opencodeDir, "plugins", "solongate.js")
6355
6370
  };
6356
6371
  }
6357
6372
  function clearGuardUpdateCheck() {
@@ -6548,6 +6563,47 @@ function codexHooksStatus() {
6548
6563
  }
6549
6564
  return { registered, trusted, disabled };
6550
6565
  }
6566
+ function installOpencodeGuard(p) {
6567
+ mkdirSync3(p.opencodePluginDir, { recursive: true });
6568
+ const src = readHook("opencode-plugin.mjs").replace("__SOLONGATE_NODE__", process.execPath.replace(/\\/g, "\\\\"));
6569
+ writeProtectedFile(p.opencodePluginPath, src);
6570
+ }
6571
+ function removeOpencodeGuard(p) {
6572
+ try {
6573
+ unlockFile(p.opencodePluginPath);
6574
+ rmSync2(p.opencodePluginPath, { force: true });
6575
+ } catch {
6576
+ }
6577
+ }
6578
+ function warmPolicyCache(hooksDir, agents) {
6579
+ for (const agent of agents) {
6580
+ try {
6581
+ const child = spawn(process.execPath, [join4(hooksDir, "guard.mjs"), agent, "--sg-refresh-policy"], {
6582
+ detached: true,
6583
+ stdio: "ignore",
6584
+ windowsHide: true
6585
+ });
6586
+ child.on("error", () => {
6587
+ });
6588
+ child.unref();
6589
+ } catch {
6590
+ }
6591
+ }
6592
+ }
6593
+ function isOpencodeGuardInstalled() {
6594
+ try {
6595
+ return readFileSync4(globalPaths().opencodePluginPath, "utf-8").includes("tool.execute.before");
6596
+ } catch {
6597
+ return false;
6598
+ }
6599
+ }
6600
+ function opencodeDetected() {
6601
+ try {
6602
+ return existsSync3(globalPaths().opencodeDir);
6603
+ } catch {
6604
+ return false;
6605
+ }
6606
+ }
6551
6607
  function ask(question) {
6552
6608
  const rl = createInterface({ input: process.stdin, output: process.stderr });
6553
6609
  return new Promise((res) => rl.question(question, (a) => {
@@ -6564,6 +6620,10 @@ function runGlobalRestore() {
6564
6620
  removeCodexGuard(p);
6565
6621
  } catch {
6566
6622
  }
6623
+ try {
6624
+ removeOpencodeGuard(p);
6625
+ } catch {
6626
+ }
6567
6627
  if (existsSync3(p.backupPath)) {
6568
6628
  writeFileSync3(p.settingsPath, readFileSync4(p.backupPath, "utf-8"));
6569
6629
  console.log(` Restored ${p.settingsPath} from backup.`);
@@ -6588,17 +6648,19 @@ function repairQuiet() {
6588
6648
  const before = [
6589
6649
  line("guard hook file", has(guardFile), "present", "MISSING"),
6590
6650
  line("cloud credential", has(p.configPath), "present", "MISSING"),
6591
- line("Claude Code settings", isGuardInstalled(), "guard registered", "guard NOT registered"),
6592
- line("Antigravity hooks", has(p.antigravityHooksPath), "present", "MISSING"),
6593
- line("Codex hooks", isCodexGuardInstalled(), "guard registered", "guard NOT registered")
6651
+ line("Claude hooks", isGuardInstalled(), "guard registered", "guard NOT registered"),
6652
+ line("agy hooks", has(p.antigravityHooksPath), "guard registered", "guard NOT registered"),
6653
+ line("Codex hooks", isCodexGuardInstalled(), "guard registered", "guard NOT registered"),
6654
+ line("OpenCode hooks", isOpencodeGuardInstalled(), "guard registered", "guard NOT registered")
6594
6655
  ];
6595
6656
  const r = installGlobalQuiet();
6596
6657
  if (!r.ok) return { ok: false, message: r.message, before, after: [], notes: [] };
6597
6658
  const after = [
6598
6659
  { label: "guard hook file", ok: true, detail: `present (v${installedGuardVersion() ?? "?"})` },
6599
- line("Claude Code settings", isGuardInstalled(), "guard registered", "NOT registered"),
6600
- line("Antigravity hooks", has(p.antigravityHooksPath), "present", "MISSING"),
6601
- line("Codex hooks", isCodexGuardInstalled(), "guard registered", "NOT registered")
6660
+ line("Claude hooks", isGuardInstalled(), "guard registered", "NOT registered"),
6661
+ line("agy hooks", has(p.antigravityHooksPath), "guard registered", "NOT registered"),
6662
+ line("Codex hooks", isCodexGuardInstalled(), "guard registered", "NOT registered"),
6663
+ line("OpenCode hooks", isOpencodeGuardInstalled(), "guard registered", "NOT registered")
6602
6664
  ];
6603
6665
  const notes = [];
6604
6666
  const cx = codexHooksStatus();
@@ -6701,6 +6763,11 @@ function installGlobalQuiet() {
6701
6763
  installCodexGuard(p, p.hooksDir);
6702
6764
  } catch {
6703
6765
  }
6766
+ try {
6767
+ installOpencodeGuard(p);
6768
+ } catch {
6769
+ }
6770
+ warmPolicyCache(p.hooksDir, ["claude-code", "codex", "antigravity", "opencode"]);
6704
6771
  if (process.env["SOLONGATE_NO_OS_LOCK"] !== "1") lockProtected();
6705
6772
  return { ok: true, message: "guard installed (open a new session)" };
6706
6773
  } catch (e) {
@@ -6748,6 +6815,10 @@ function uninstallGlobalQuiet() {
6748
6815
  removeCodexGuard(p);
6749
6816
  } catch {
6750
6817
  }
6818
+ try {
6819
+ removeOpencodeGuard(p);
6820
+ } catch {
6821
+ }
6751
6822
  if (!existsSync3(p.settingsPath)) return { ok: true, message: "guard removed (open a new session)" };
6752
6823
  const s = JSON.parse(readFileSync4(p.settingsPath, "utf-8"));
6753
6824
  delete s.hooks;
@@ -6908,6 +6979,13 @@ async function runGlobalInstall(opts = {}) {
6908
6979
  }
6909
6980
  } catch {
6910
6981
  }
6982
+ try {
6983
+ installOpencodeGuard(p);
6984
+ console.log(` Installed OpenCode plugin \u2192 ${p.opencodePluginPath}`);
6985
+ console.log(" OpenCode: `opencode --pure` runs without external plugins and");
6986
+ console.log(" therefore without the guard. That is its own escape hatch.");
6987
+ } catch {
6988
+ }
6911
6989
  if (process.env["SOLONGATE_NO_OS_LOCK"] !== "1") {
6912
6990
  lockProtected();
6913
6991
  console.log(" Locked protection files (OS-level read-only/immutable).");
@@ -6949,7 +7027,7 @@ __export(self_update_exports, {
6949
7027
  tuiUpdateFlow: () => tuiUpdateFlow,
6950
7028
  updateNow: () => updateNow
6951
7029
  });
6952
- import { execFile, execFileSync as execFileSync3, spawn } from "child_process";
7030
+ import { execFile, execFileSync as execFileSync3, spawn as spawn2 } from "child_process";
6953
7031
  import { access, mkdirSync as mkdirSync4, openSync, readFileSync as readFileSync5, writeFileSync as writeFileSync4, constants as FS } from "fs";
6954
7032
  import { homedir as homedir3 } from "os";
6955
7033
  import { dirname as dirname2, join as join5, sep } from "path";
@@ -7023,7 +7101,7 @@ function spawnGlobalInstall(version) {
7023
7101
  try {
7024
7102
  mkdirSync4(join5(homedir3(), ".solongate"), { recursive: true });
7025
7103
  const log3 = openSync(LOG_FILE, "a");
7026
- const p = spawn("npm", ["install", "-g", `${PKG}@${version}`], {
7104
+ const p = spawn2("npm", ["install", "-g", `${PKG}@${version}`], {
7027
7105
  stdio: ["ignore", log3, log3],
7028
7106
  detached: true,
7029
7107
  windowsHide: true,
@@ -7297,7 +7375,7 @@ __export(logs_server_daemon_exports, {
7297
7375
  startLogsServerDaemon: () => startLogsServerDaemon,
7298
7376
  stopLogsServerDaemon: () => stopLogsServerDaemon
7299
7377
  });
7300
- import { spawn as spawn2 } from "child_process";
7378
+ import { spawn as spawn3 } from "child_process";
7301
7379
  import { mkdirSync as mkdirSync5, openSync as openSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
7302
7380
  import { homedir as homedir4 } from "os";
7303
7381
  import { dirname as dirname3, join as join6 } from "path";
@@ -7344,7 +7422,7 @@ function startLogsServerDaemon() {
7344
7422
  mkdirSync5(DIR, { recursive: true });
7345
7423
  const log3 = openSync2(LOG_FILE2, "a");
7346
7424
  const cli = join6(dirname3(fileURLToPath3(import.meta.url)), "index.js");
7347
- const p = spawn2(process.execPath, [cli, "logs-server"], {
7425
+ const p = spawn3(process.execPath, [cli, "logs-server"], {
7348
7426
  detached: true,
7349
7427
  stdio: ["ignore", log3, log3],
7350
7428
  windowsHide: true,
@@ -7995,12 +8073,12 @@ var init_types = __esm({
7995
8073
  });
7996
8074
 
7997
8075
  // src/api-client/device-login.ts
7998
- import { spawn as spawn3 } from "child_process";
8076
+ import { spawn as spawn4 } from "child_process";
7999
8077
  function openBrowser(url) {
8000
8078
  try {
8001
8079
  const cmd = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
8002
8080
  const args = process.platform === "win32" ? ["/c", "start", '""', url] : [url];
8003
- const child = spawn3(cmd, args, { stdio: "ignore", detached: true });
8081
+ const child = spawn4(cmd, args, { stdio: "ignore", detached: true });
8004
8082
  child.on("error", () => {
8005
8083
  });
8006
8084
  child.unref();
@@ -8353,24 +8431,24 @@ var init_api_client = __esm({
8353
8431
  });
8354
8432
 
8355
8433
  // src/tui/notify.ts
8356
- import { spawn as spawn4 } from "child_process";
8434
+ import { spawn as spawn5 } from "child_process";
8357
8435
  function desktopNotify(title, msg) {
8358
8436
  try {
8359
8437
  if (process.platform === "linux") {
8360
- const p = spawn4("notify-send", ["-a", "SolonGate", title, msg], { stdio: "ignore", detached: true });
8438
+ const p = spawn5("notify-send", ["-a", "SolonGate", title, msg], { stdio: "ignore", detached: true });
8361
8439
  p.on("error", () => {
8362
8440
  });
8363
8441
  p.unref();
8364
8442
  } else if (process.platform === "win32") {
8365
8443
  const q = (s) => s.replace(/'/g, "''");
8366
8444
  const ps = `Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing;$n=New-Object System.Windows.Forms.NotifyIcon;$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;$n.ShowBalloonTip(6000,'${q(title)}','${q(msg)}',[System.Windows.Forms.ToolTipIcon]::Warning);Start-Sleep -Milliseconds 6500;$n.Dispose()`;
8367
- const p = spawn4("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], { stdio: "ignore", detached: true, windowsHide: true });
8445
+ const p = spawn5("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], { stdio: "ignore", detached: true, windowsHide: true });
8368
8446
  p.on("error", () => {
8369
8447
  });
8370
8448
  p.unref();
8371
8449
  } else if (process.platform === "darwin") {
8372
8450
  const esc = (s) => s.replace(/"/g, '\\"');
8373
- const p = spawn4("osascript", ["-e", `display notification "${esc(msg)}" with title "${esc(title)}"`], { stdio: "ignore", detached: true });
8451
+ const p = spawn5("osascript", ["-e", `display notification "${esc(msg)}" with title "${esc(title)}"`], { stdio: "ignore", detached: true });
8374
8452
  p.on("error", () => {
8375
8453
  });
8376
8454
  p.unref();
@@ -11529,18 +11607,40 @@ async function collectChecks() {
11529
11607
  } catch {
11530
11608
  }
11531
11609
  }
11610
+ const claudeReg = isGuardInstalled();
11611
+ checks.push({
11612
+ name: "Claude hooks",
11613
+ ok: claudeReg,
11614
+ detail: claudeReg ? "guard registered" : "guard NOT registered - run `solongate repair`"
11615
+ });
11616
+ if (existsSync6(globalPaths().antigravityDir)) {
11617
+ const reg = existsSync6(globalPaths().antigravityHooksPath);
11618
+ checks.push({
11619
+ name: "agy hooks",
11620
+ ok: reg,
11621
+ detail: reg ? "guard registered" : "guard NOT registered - run `solongate repair`"
11622
+ });
11623
+ }
11532
11624
  if (codexDetected()) {
11533
11625
  const cx = codexHooksStatus();
11534
11626
  if (!cx.registered) {
11535
- checks.push({ name: "codex hooks", ok: false, detail: "guard not registered - run `solongate repair`" });
11627
+ checks.push({ name: "Codex hooks", ok: false, detail: "guard NOT registered - run `solongate repair`" });
11536
11628
  } else if (cx.disabled) {
11537
- checks.push({ name: "codex hooks", ok: false, detail: "hooks disabled in ~/.codex/config.toml ([features] hooks = false)" });
11629
+ checks.push({ name: "Codex hooks", ok: false, detail: "hooks disabled in ~/.codex/config.toml ([features] hooks = false)" });
11538
11630
  } else if (!cx.trusted) {
11539
- checks.push({ name: "codex hooks", ok: "warn", detail: "registered - run `/hooks` in Codex once and trust them (Codex skips untrusted hooks)" });
11631
+ checks.push({ name: "Codex hooks", ok: "warn", detail: "registered - run `/hooks` in Codex once and trust them (Codex skips untrusted hooks)" });
11540
11632
  } else {
11541
- checks.push({ name: "codex hooks", ok: true, detail: "registered + trusted" });
11633
+ checks.push({ name: "Codex hooks", ok: true, detail: "registered + trusted" });
11542
11634
  }
11543
11635
  }
11636
+ if (opencodeDetected()) {
11637
+ const reg = isOpencodeGuardInstalled();
11638
+ checks.push({
11639
+ name: "OpenCode hooks",
11640
+ ok: reg,
11641
+ detail: reg ? "guard registered (not active under `opencode --pure`)" : "guard NOT registered - run `solongate repair`"
11642
+ });
11643
+ }
11544
11644
  try {
11545
11645
  const raw = readFileSync10(join12(homedir10(), ".solongate", ".key-rejected.json"), "utf-8");
11546
11646
  const m = JSON.parse(raw);
package/dist/tui/index.js CHANGED
@@ -14,7 +14,7 @@ import { resolve, join as join3, dirname } from "path";
14
14
  import { homedir as homedir3 } from "os";
15
15
  import { fileURLToPath } from "url";
16
16
  import { createInterface } from "readline";
17
- import { execFileSync } from "child_process";
17
+ import { execFileSync, spawn } from "child_process";
18
18
  function lockFile(file) {
19
19
  if (!existsSync2(file)) return;
20
20
  try {
@@ -97,7 +97,11 @@ function protectedTargets() {
97
97
  p.configPath,
98
98
  p.settingsPath,
99
99
  p.antigravityHooksPath,
100
- p.codexHooksPath
100
+ p.codexHooksPath,
101
+ // The OpenCode plugin IS the registration — delete it and the guard is gone
102
+ // from that client with nothing left to notice the absence, so it belongs
103
+ // under the same OS lock as every other protection file.
104
+ p.opencodePluginPath
101
105
  ];
102
106
  }
103
107
  function lockProtected() {
@@ -129,6 +133,7 @@ function globalPaths() {
129
133
  const claudeDir = join3(home, ".claude");
130
134
  const antigravityDir = join3(home, ".gemini", "config");
131
135
  const codexDir = process.env["CODEX_HOME"] ? resolve(process.env["CODEX_HOME"]) : join3(home, ".codex");
136
+ const opencodeDir = join3(process.env["XDG_CONFIG_HOME"] ? resolve(process.env["XDG_CONFIG_HOME"]) : join3(home, ".config"), "opencode");
132
137
  return {
133
138
  home,
134
139
  sgDir,
@@ -136,6 +141,7 @@ function globalPaths() {
136
141
  claudeDir,
137
142
  antigravityDir,
138
143
  codexDir,
144
+ opencodeDir,
139
145
  settingsPath: join3(claudeDir, "settings.json"),
140
146
  backupPath: join3(claudeDir, "settings.solongate.bak"),
141
147
  configPath: join3(sgDir, "cloud-guard.json"),
@@ -149,7 +155,14 @@ function globalPaths() {
149
155
  // the user's TOML, which also holds the hook TRUST state Codex manages).
150
156
  codexHooksPath: join3(codexDir, "hooks.json"),
151
157
  codexBackupPath: join3(codexDir, "hooks.solongate.bak"),
152
- codexConfigPath: join3(codexDir, "config.toml")
158
+ codexConfigPath: join3(codexDir, "config.toml"),
159
+ // OpenCode scans its plugin folder at startup and loads every module in it.
160
+ // Measured on 1.18.10: BOTH `plugin/` and `plugins/` are scanned, so the
161
+ // docs and the field reports are each half right. We write the documented
162
+ // one. There is nothing to register anywhere — dropping the file IS the
163
+ // installation, which also means deleting the file IS the uninstall.
164
+ opencodePluginDir: join3(opencodeDir, "plugins"),
165
+ opencodePluginPath: join3(opencodeDir, "plugins", "solongate.js")
153
166
  };
154
167
  }
155
168
  function readHook(filename) {
@@ -338,6 +351,47 @@ function codexHooksStatus() {
338
351
  }
339
352
  return { registered, trusted, disabled };
340
353
  }
354
+ function installOpencodeGuard(p) {
355
+ mkdirSync(p.opencodePluginDir, { recursive: true });
356
+ const src = readHook("opencode-plugin.mjs").replace("__SOLONGATE_NODE__", process.execPath.replace(/\\/g, "\\\\"));
357
+ writeProtectedFile(p.opencodePluginPath, src);
358
+ }
359
+ function removeOpencodeGuard(p) {
360
+ try {
361
+ unlockFile(p.opencodePluginPath);
362
+ rmSync(p.opencodePluginPath, { force: true });
363
+ } catch {
364
+ }
365
+ }
366
+ function warmPolicyCache(hooksDir, agents) {
367
+ for (const agent of agents) {
368
+ try {
369
+ const child = spawn(process.execPath, [join3(hooksDir, "guard.mjs"), agent, "--sg-refresh-policy"], {
370
+ detached: true,
371
+ stdio: "ignore",
372
+ windowsHide: true
373
+ });
374
+ child.on("error", () => {
375
+ });
376
+ child.unref();
377
+ } catch {
378
+ }
379
+ }
380
+ }
381
+ function isOpencodeGuardInstalled() {
382
+ try {
383
+ return readFileSync3(globalPaths().opencodePluginPath, "utf-8").includes("tool.execute.before");
384
+ } catch {
385
+ return false;
386
+ }
387
+ }
388
+ function opencodeDetected() {
389
+ try {
390
+ return existsSync2(globalPaths().opencodeDir);
391
+ } catch {
392
+ return false;
393
+ }
394
+ }
341
395
  function repairQuiet() {
342
396
  const p = globalPaths();
343
397
  const has = (f) => existsSync2(f);
@@ -346,17 +400,19 @@ function repairQuiet() {
346
400
  const before = [
347
401
  line("guard hook file", has(guardFile), "present", "MISSING"),
348
402
  line("cloud credential", has(p.configPath), "present", "MISSING"),
349
- line("Claude Code settings", isGuardInstalled(), "guard registered", "guard NOT registered"),
350
- line("Antigravity hooks", has(p.antigravityHooksPath), "present", "MISSING"),
351
- line("Codex hooks", isCodexGuardInstalled(), "guard registered", "guard NOT registered")
403
+ line("Claude hooks", isGuardInstalled(), "guard registered", "guard NOT registered"),
404
+ line("agy hooks", has(p.antigravityHooksPath), "guard registered", "guard NOT registered"),
405
+ line("Codex hooks", isCodexGuardInstalled(), "guard registered", "guard NOT registered"),
406
+ line("OpenCode hooks", isOpencodeGuardInstalled(), "guard registered", "guard NOT registered")
352
407
  ];
353
408
  const r = installGlobalQuiet();
354
409
  if (!r.ok) return { ok: false, message: r.message, before, after: [], notes: [] };
355
410
  const after = [
356
411
  { label: "guard hook file", ok: true, detail: `present (v${installedGuardVersion() ?? "?"})` },
357
- line("Claude Code settings", isGuardInstalled(), "guard registered", "NOT registered"),
358
- line("Antigravity hooks", has(p.antigravityHooksPath), "present", "MISSING"),
359
- line("Codex hooks", isCodexGuardInstalled(), "guard registered", "NOT registered")
412
+ line("Claude hooks", isGuardInstalled(), "guard registered", "NOT registered"),
413
+ line("agy hooks", has(p.antigravityHooksPath), "guard registered", "NOT registered"),
414
+ line("Codex hooks", isCodexGuardInstalled(), "guard registered", "NOT registered"),
415
+ line("OpenCode hooks", isOpencodeGuardInstalled(), "guard registered", "NOT registered")
360
416
  ];
361
417
  const notes = [];
362
418
  const cx = codexHooksStatus();
@@ -425,6 +481,11 @@ function installGlobalQuiet() {
425
481
  installCodexGuard(p, p.hooksDir);
426
482
  } catch {
427
483
  }
484
+ try {
485
+ installOpencodeGuard(p);
486
+ } catch {
487
+ }
488
+ warmPolicyCache(p.hooksDir, ["claude-code", "codex", "antigravity", "opencode"]);
428
489
  if (process.env["SOLONGATE_NO_OS_LOCK"] !== "1") lockProtected();
429
490
  return { ok: true, message: "guard installed (open a new session)" };
430
491
  } catch (e) {
@@ -472,6 +533,10 @@ function uninstallGlobalQuiet() {
472
533
  removeCodexGuard(p);
473
534
  } catch {
474
535
  }
536
+ try {
537
+ removeOpencodeGuard(p);
538
+ } catch {
539
+ }
475
540
  if (!existsSync2(p.settingsPath)) return { ok: true, message: "guard removed (open a new session)" };
476
541
  const s = JSON.parse(readFileSync3(p.settingsPath, "utf-8"));
477
542
  delete s.hooks;
@@ -1146,12 +1211,12 @@ async function request(method, path, opts = {}) {
1146
1211
  }
1147
1212
 
1148
1213
  // src/api-client/device-login.ts
1149
- import { spawn } from "child_process";
1214
+ import { spawn as spawn2 } from "child_process";
1150
1215
  function openBrowser(url) {
1151
1216
  try {
1152
1217
  const cmd = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
1153
1218
  const args = process.platform === "win32" ? ["/c", "start", '""', url] : [url];
1154
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1219
+ const child = spawn2(cmd, args, { stdio: "ignore", detached: true });
1155
1220
  child.on("error", () => {
1156
1221
  });
1157
1222
  child.unref();
@@ -1433,24 +1498,24 @@ function list4() {
1433
1498
  var api = { auth: auth_exports, policies: policies_exports, settings: settings_exports, stats: stats_exports, audit: audit_exports, agents: agents_exports, keys: keys_exports, mcp: mcp_exports };
1434
1499
 
1435
1500
  // src/tui/notify.ts
1436
- import { spawn as spawn2 } from "child_process";
1501
+ import { spawn as spawn3 } from "child_process";
1437
1502
  function desktopNotify(title, msg) {
1438
1503
  try {
1439
1504
  if (process.platform === "linux") {
1440
- const p = spawn2("notify-send", ["-a", "SolonGate", title, msg], { stdio: "ignore", detached: true });
1505
+ const p = spawn3("notify-send", ["-a", "SolonGate", title, msg], { stdio: "ignore", detached: true });
1441
1506
  p.on("error", () => {
1442
1507
  });
1443
1508
  p.unref();
1444
1509
  } else if (process.platform === "win32") {
1445
1510
  const q = (s) => s.replace(/'/g, "''");
1446
1511
  const ps = `Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing;$n=New-Object System.Windows.Forms.NotifyIcon;$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;$n.ShowBalloonTip(6000,'${q(title)}','${q(msg)}',[System.Windows.Forms.ToolTipIcon]::Warning);Start-Sleep -Milliseconds 6500;$n.Dispose()`;
1447
- const p = spawn2("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], { stdio: "ignore", detached: true, windowsHide: true });
1512
+ const p = spawn3("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], { stdio: "ignore", detached: true, windowsHide: true });
1448
1513
  p.on("error", () => {
1449
1514
  });
1450
1515
  p.unref();
1451
1516
  } else if (process.platform === "darwin") {
1452
1517
  const esc = (s) => s.replace(/"/g, '\\"');
1453
- const p = spawn2("osascript", ["-e", `display notification "${esc(msg)}" with title "${esc(title)}"`], { stdio: "ignore", detached: true });
1518
+ const p = spawn3("osascript", ["-e", `display notification "${esc(msg)}" with title "${esc(title)}"`], { stdio: "ignore", detached: true });
1454
1519
  p.on("error", () => {
1455
1520
  });
1456
1521
  p.unref();
@@ -4411,18 +4476,40 @@ async function collectChecks() {
4411
4476
  } catch {
4412
4477
  }
4413
4478
  }
4479
+ const claudeReg = isGuardInstalled();
4480
+ checks.push({
4481
+ name: "Claude hooks",
4482
+ ok: claudeReg,
4483
+ detail: claudeReg ? "guard registered" : "guard NOT registered - run `solongate repair`"
4484
+ });
4485
+ if (existsSync4(globalPaths().antigravityDir)) {
4486
+ const reg = existsSync4(globalPaths().antigravityHooksPath);
4487
+ checks.push({
4488
+ name: "agy hooks",
4489
+ ok: reg,
4490
+ detail: reg ? "guard registered" : "guard NOT registered - run `solongate repair`"
4491
+ });
4492
+ }
4414
4493
  if (codexDetected()) {
4415
4494
  const cx = codexHooksStatus();
4416
4495
  if (!cx.registered) {
4417
- checks.push({ name: "codex hooks", ok: false, detail: "guard not registered - run `solongate repair`" });
4496
+ checks.push({ name: "Codex hooks", ok: false, detail: "guard NOT registered - run `solongate repair`" });
4418
4497
  } else if (cx.disabled) {
4419
- checks.push({ name: "codex hooks", ok: false, detail: "hooks disabled in ~/.codex/config.toml ([features] hooks = false)" });
4498
+ checks.push({ name: "Codex hooks", ok: false, detail: "hooks disabled in ~/.codex/config.toml ([features] hooks = false)" });
4420
4499
  } else if (!cx.trusted) {
4421
- checks.push({ name: "codex hooks", ok: "warn", detail: "registered - run `/hooks` in Codex once and trust them (Codex skips untrusted hooks)" });
4500
+ checks.push({ name: "Codex hooks", ok: "warn", detail: "registered - run `/hooks` in Codex once and trust them (Codex skips untrusted hooks)" });
4422
4501
  } else {
4423
- checks.push({ name: "codex hooks", ok: true, detail: "registered + trusted" });
4502
+ checks.push({ name: "Codex hooks", ok: true, detail: "registered + trusted" });
4424
4503
  }
4425
4504
  }
4505
+ if (opencodeDetected()) {
4506
+ const reg = isOpencodeGuardInstalled();
4507
+ checks.push({
4508
+ name: "OpenCode hooks",
4509
+ ok: reg,
4510
+ detail: reg ? "guard registered (not active under `opencode --pure`)" : "guard NOT registered - run `solongate repair`"
4511
+ });
4512
+ }
4426
4513
  try {
4427
4514
  const raw = readFileSync5(join7(homedir7(), ".solongate", ".key-rejected.json"), "utf-8");
4428
4515
  const m = JSON.parse(raw);
@@ -4446,7 +4533,7 @@ async function collectChecks() {
4446
4533
  }
4447
4534
 
4448
4535
  // src/logs-server-daemon.ts
4449
- import { spawn as spawn3 } from "child_process";
4536
+ import { spawn as spawn4 } from "child_process";
4450
4537
  import { mkdirSync as mkdirSync5, openSync as openSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
4451
4538
  import { homedir as homedir8 } from "os";
4452
4539
  import { dirname as dirname2, join as join8 } from "path";
@@ -4494,7 +4581,7 @@ function startLogsServerDaemon() {
4494
4581
  mkdirSync5(DIR, { recursive: true });
4495
4582
  const log = openSync2(LOG_FILE, "a");
4496
4583
  const cli = join8(dirname2(fileURLToPath2(import.meta.url)), "index.js");
4497
- const p = spawn3(process.execPath, [cli, "logs-server"], {
4584
+ const p = spawn4(process.execPath, [cli, "logs-server"], {
4498
4585
  detached: true,
4499
4586
  stdio: ["ignore", log, log],
4500
4587
  windowsHide: true,
@@ -4524,7 +4611,7 @@ function stopLogsServerDaemon() {
4524
4611
  }
4525
4612
 
4526
4613
  // src/self-update.ts
4527
- import { execFile, execFileSync as execFileSync2, spawn as spawn4 } from "child_process";
4614
+ import { execFile, execFileSync as execFileSync2, spawn as spawn5 } from "child_process";
4528
4615
  import { access, mkdirSync as mkdirSync6, openSync as openSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync7, constants as FS } from "fs";
4529
4616
  import { homedir as homedir9 } from "os";
4530
4617
  import { dirname as dirname3, join as join9, sep } from "path";
@@ -6536,7 +6536,7 @@ import { resolve, join, dirname, isAbsolute } from "node:path";
6536
6536
  import { homedir } from "node:os";
6537
6537
  import { gunzipSync } from "node:zlib";
6538
6538
  import { createHash } from "node:crypto";
6539
- var HOOK_VERSION = 75;
6539
+ var HOOK_VERSION = 76;
6540
6540
  function localLogsOnly(security) {
6541
6541
  if (security !== void 0) {
6542
6542
  const l = security && security.localLogs;
@@ -6925,6 +6925,20 @@ var CLIENTS = {
6925
6925
  redactsOutput: false
6926
6926
  // has a post-tool stage, but it rejects output rewrites
6927
6927
  },
6928
+ // OpenCode: no subprocess hook contract at all — a plugin runs in-process and
6929
+ // refuses a call by throwing. The shim that does the throwing (see
6930
+ // hooks/opencode-plugin.mjs) spawns this guard with a flat Claude-shaped
6931
+ // payload and reads the Claude dialect back, so both halves are reused as-is
6932
+ // and only the identity differs.
6933
+ opencode: {
6934
+ parse: parseFlatPayload,
6935
+ emit: emitHookSpecific,
6936
+ // tool.execute.after does hand the plugin the tool's result, but whether
6937
+ // writing to it changes what the model sees is untested. Claiming the
6938
+ // capability we have not proven would let a secret through masked-in-name-
6939
+ // only; false makes any masking we cannot apply a block instead.
6940
+ redactsOutput: false
6941
+ },
6928
6942
  // Antigravity CLI: nested payload, and a decision dialect of its own.
6929
6943
  antigravity: {
6930
6944
  redactsOutput: false,
package/hooks/guard.mjs CHANGED
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
32
32
  // the installed hook self-updates when the cloud version is higher (see
33
33
  // maybeSelfUpdate). This is what makes guard fixes propagate without a manual
34
34
  // reinstall — the same trust model as the OPA WASM this hook already runs.
35
- const HOOK_VERSION = 75;
35
+ const HOOK_VERSION = 76;
36
36
 
37
37
  // True when local log storage is ON. In that mode logs are kept LOCAL ONLY and
38
38
  // nothing is sent to the cloud audit log — local and cloud are one or the
@@ -617,6 +617,21 @@ const CLIENTS = {
617
617
  redactsOutput: false, // has a post-tool stage, but it rejects output rewrites
618
618
  },
619
619
 
620
+ // OpenCode: no subprocess hook contract at all — a plugin runs in-process and
621
+ // refuses a call by throwing. The shim that does the throwing (see
622
+ // hooks/opencode-plugin.mjs) spawns this guard with a flat Claude-shaped
623
+ // payload and reads the Claude dialect back, so both halves are reused as-is
624
+ // and only the identity differs.
625
+ opencode: {
626
+ parse: parseFlatPayload,
627
+ emit: emitHookSpecific,
628
+ // tool.execute.after does hand the plugin the tool's result, but whether
629
+ // writing to it changes what the model sees is untested. Claiming the
630
+ // capability we have not proven would let a secret through masked-in-name-
631
+ // only; false makes any masking we cannot apply a block instead.
632
+ redactsOutput: false,
633
+ },
634
+
620
635
  // Antigravity CLI: nested payload, and a decision dialect of its own.
621
636
  antigravity: {
622
637
  redactsOutput: false, // no post-tool stage at all (its output is ignored)
@@ -0,0 +1,151 @@
1
+ /**
2
+ * SolonGate plugin for OpenCode.
3
+ *
4
+ * OpenCode is the odd one out among the guarded clients: it has no subprocess
5
+ * hook contract. Plugins are JavaScript modules Bun loads INTO the running
6
+ * process, and a tool call is refused by throwing from `tool.execute.before`.
7
+ *
8
+ * So this file is a shim, not an engine. It spawns the same guard every other
9
+ * client runs and turns its answer into the shape OpenCode wants — which keeps
10
+ * one implementation of policy, DLP, rate limiting, ghost paths and auditing
11
+ * instead of a second one that drifts.
12
+ *
13
+ * Measured against opencode 1.18.10, because none of this is documented:
14
+ * tool.execute.before(input, output)
15
+ * input = { tool, sessionID, callID }
16
+ * output = { args } ← mutable, so a rewrite lands here
17
+ * throwing blocks the call, and the Error message is handed to the model as
18
+ * the tool result (it reads it and reports it, so the reason is visible)
19
+ * tool.execute.after(input, output)
20
+ * input = { tool, sessionID, callID, args }
21
+ * output = { title, metadata, output, attachments }
22
+ *
23
+ * Verified on the same version: subagent calls spawned through the `task` tool
24
+ * DO reach this hook (in their own sessionID), and so do MCP tool calls (named
25
+ * `<server>_<tool>`). Both were once bypasses; both are closed.
26
+ *
27
+ * Not covered, and it cannot be: `opencode --pure` runs with external plugins
28
+ * disabled, which switches the guard off. That is the client's design and there
29
+ * is no hook that survives it.
30
+ */
31
+ import { spawn } from 'node:child_process';
32
+ import { existsSync } from 'node:fs';
33
+ import { homedir } from 'node:os';
34
+ import { join } from 'node:path';
35
+
36
+ const HOOKS_DIR = join(homedir(), '.solongate', 'hooks');
37
+ const GUARD = join(HOOKS_DIR, 'guard.mjs');
38
+ const AUDIT = join(HOOKS_DIR, 'audit.mjs');
39
+
40
+ // The node binary, written in by the installer.
41
+ //
42
+ // NOT process.execPath: OpenCode runs plugins inside its own Bun-based binary,
43
+ // so in here process.execPath is `.../opencode-ai/bin/opencode.exe`. Spawning
44
+ // THAT with the guard as an argument does not run the guard — it re-runs
45
+ // opencode with nonsense arguments, which exits non-2, which this shim reads as
46
+ // "allowed". The result was a guard that never guarded and never said so.
47
+ // Measured: execPath=/…/opencode-ai/bin/opencode.exe, versions.bun=1.3.14.
48
+ const NODE_BAKED = '__SOLONGATE_NODE__';
49
+ const NODE = NODE_BAKED.startsWith('__SOLONGATE') || !existsSync(NODE_BAKED) ? 'node' : NODE_BAKED;
50
+
51
+ // The guard carries its own 8s backstop; this is the outer bound for the whole
52
+ // round trip so a wedged process can never hang the editor.
53
+ const GUARD_TIMEOUT_MS = 15000;
54
+
55
+ /**
56
+ * Run a hook with a JSON payload on stdin. Resolves to {code, stdout, stderr},
57
+ * or null when the hook could not be run at all.
58
+ */
59
+ function runHook(script, payload, timeoutMs) {
60
+ return new Promise((resolve) => {
61
+ if (!existsSync(script)) return resolve(null);
62
+ let done = false;
63
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
64
+ let child;
65
+ try {
66
+ child = spawn(NODE, [script, 'opencode', 'OpenCode'], {
67
+ stdio: ['pipe', 'pipe', 'pipe'],
68
+ windowsHide: true,
69
+ });
70
+ } catch {
71
+ return finish(null);
72
+ }
73
+ let stdout = '', stderr = '';
74
+ child.stdout.on('data', (d) => { stdout += d; });
75
+ child.stderr.on('data', (d) => { stderr += d; });
76
+ child.on('error', () => finish(null));
77
+ child.on('close', (code) => finish({ code, stdout, stderr }));
78
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} finish(null); }, timeoutMs);
79
+ if (typeof timer.unref === 'function') timer.unref();
80
+ try { child.stdin.end(JSON.stringify(payload)); } catch { finish(null); }
81
+ });
82
+ }
83
+
84
+ /** The deny reason, preferring the structured field over the stderr line. */
85
+ function denyReason(res) {
86
+ try {
87
+ const j = JSON.parse(res.stdout);
88
+ const r = j?.hookSpecificOutput?.permissionDecisionReason;
89
+ if (typeof r === 'string' && r.trim()) return r.trim();
90
+ } catch { /* not JSON — fall back to stderr */ }
91
+ const line = String(res.stderr || '').split('\n').map((s) => s.trim()).filter(Boolean).pop();
92
+ return line || 'Blocked by SolonGate.';
93
+ }
94
+
95
+ /** A rewrite instruction, when the guard asked for one (ghost-path listings). */
96
+ function rewritePatch(res) {
97
+ try {
98
+ const j = JSON.parse(res.stdout);
99
+ const h = j?.hookSpecificOutput;
100
+ if (h?.permissionDecision === 'allow' && h.updatedInput && typeof h.updatedInput === 'object') {
101
+ return h.updatedInput;
102
+ }
103
+ } catch { /* no patch */ }
104
+ return null;
105
+ }
106
+
107
+ export const SolonGate = async ({ directory, worktree } = {}) => {
108
+ const cwd = directory || worktree || process.cwd();
109
+
110
+ return {
111
+ 'tool.execute.before': async (input, output) => {
112
+ const res = await runHook(GUARD, {
113
+ tool_name: input?.tool ?? '',
114
+ tool_input: output?.args ?? {},
115
+ session_id: input?.sessionID ?? '',
116
+ tool_use_id: input?.callID ?? '',
117
+ cwd,
118
+ }, GUARD_TIMEOUT_MS);
119
+
120
+ // The guard could not run (not installed, spawn refused, wedged). Fail
121
+ // OPEN, the same way every enforcement layer inside it does: a guard that
122
+ // cannot answer must not become an editor that cannot work.
123
+ if (!res) return;
124
+
125
+ if (res.code === 2) {
126
+ throw new Error(denyReason(res));
127
+ }
128
+
129
+ // Allow-with-rewrite: `output.args` is what actually runs, so replacing it
130
+ // here is how a hidden path gets filtered out of a listing before the
131
+ // model ever sees the result.
132
+ const patch = rewritePatch(res);
133
+ if (patch && output && typeof output === 'object') {
134
+ try { Object.assign(output.args, patch); } catch { /* leave the call as it was */ }
135
+ }
136
+ },
137
+
138
+ 'tool.execute.after': async (input, output) => {
139
+ // The ALLOW-path audit record. Fire and forget: it must never delay the
140
+ // editor, and losing one line is better than a stalled tool.
141
+ void runHook(AUDIT, {
142
+ tool_name: input?.tool ?? '',
143
+ tool_input: input?.args ?? {},
144
+ tool_response: output?.output ?? output?.metadata ?? {},
145
+ session_id: input?.sessionID ?? '',
146
+ tool_use_id: input?.callID ?? '',
147
+ cwd,
148
+ }, GUARD_TIMEOUT_MS);
149
+ },
150
+ };
151
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.83.10",
3
+ "version": "0.83.12",
4
4
  "description": "AI tool security proxy: protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. No code changes required.",
5
5
  "type": "module",
6
6
  "bin": {