@suveren/gateway 0.12.0 → 0.13.0

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/lib/policy.mjs ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Managed-settings policy — IT-controlled overrides that an employee cannot
3
+ * change locally (W3 of the Windows installer track; see
4
+ * suveren-as/docs/work-plan.md, "Added 2026-10-02 — Windows installer").
5
+ *
6
+ * Sources, highest precedence first:
7
+ * 1. Windows registry `HKLM\SOFTWARE\Policies\Suveren\Gateway`, then
8
+ * `HKCU\SOFTWARE\Policies\Suveren\Gateway` (HKLM wins over HKCU).
9
+ * Read via `reg query` (child_process) — no native modules. Skipped
10
+ * entirely on non-Windows.
11
+ * 2. A JSON file: `%ProgramData%\Suveren\gateway-policy.json` (Windows),
12
+ * `/Library/Application Support/Suveren/gateway-policy.json` (macOS),
13
+ * `/etc/suveren/gateway-policy.json` (Linux) — overridable for tests
14
+ * via `SUVEREN_POLICY_FILE`.
15
+ *
16
+ * A key present in EITHER source is LOCKED: every resolver in this codebase
17
+ * (resolveAsUrl, resolveCaFile, resolvePinTls, resolveProxyUrl,
18
+ * resolveSimulation / isSimulationMode) must return the policy value
19
+ * regardless of env var, saved config.json, or CLI flag — see those
20
+ * modules' own doc comments for where this is wired in. Overall precedence:
21
+ * policy > env > saved config > defaults.
22
+ *
23
+ * `Proxy` is the one key that OVERRIDES an environment variable rather than
24
+ * merely out-ranking a saved value: on a Windows company laptop the proxy is
25
+ * set in system settings, not `HTTP_PROXY`/`HTTPS_PROXY` — see
26
+ * bundle/server.js, which sets both env vars from the policy value
27
+ * unconditionally when `Proxy` is locked (every other locked key has no env
28
+ * tier to override in the first place). `NoProxy` is read the same way but
29
+ * has no saved-config/CLI counterpart at all — NO_PROXY stays env-only
30
+ * except for this one override.
31
+ *
32
+ * Registry value names = JSON file keys (PascalCase, matching what an IT
33
+ * admin would see in either place): AsUrl, CaFile, PinTls, Proxy, NoProxy,
34
+ * Simulation, InstallMethod.
35
+ *
36
+ * Mirrored (same logic, duplicated — no shared runtime package between the
37
+ * CLI and the two apps, see config.mjs's doc comment for the established
38
+ * pattern) in `apps/mcp-server/src/lib/policy.ts` and
39
+ * `apps/control-plane/src/lib/policy.ts`. Keep all three in step.
40
+ */
41
+
42
+ import { existsSync, readFileSync } from 'node:fs';
43
+ import { join } from 'node:path';
44
+ import { spawnSync } from 'node:child_process';
45
+
46
+ const POLICY_KEYS = ['AsUrl', 'CaFile', 'PinTls', 'Proxy', 'NoProxy', 'Simulation', 'InstallMethod'];
47
+
48
+ /** The documented production path, relative to the hive — never changes for
49
+ * a real install. Overridable ONLY via `SUVEREN_POLICY_REGISTRY_KEY`, which
50
+ * exists purely so `gateway-policy-windows-registry.test.ts` can point at a
51
+ * unique per-run key instead of the real one: `pnpm -r test` runs every
52
+ * workspace package in parallel, so a test writing to the REAL key would
53
+ * race every OTHER package's tests reading the real registry on the same
54
+ * Windows CI runner at the same time (this raced in practice — see
55
+ * `SUVEREN_POLICY_REGISTRY` below for the other half of the fix). */
56
+ const DEFAULT_REGISTRY_BASE_KEY = 'SOFTWARE\\Policies\\Suveren\\Gateway';
57
+
58
+ /** The full `HIVE\base\key` path `reg query`/`reg add` operate on. A pure
59
+ * function (no I/O) so a test can assert the default without mocking
60
+ * anything — see gateway-policy.test.ts's "documented default paths". */
61
+ export function registryKeyPath(hive) {
62
+ const baseKey = process.env.SUVEREN_POLICY_REGISTRY_KEY || DEFAULT_REGISTRY_BASE_KEY;
63
+ return `${hive}\\${baseKey}`;
64
+ }
65
+
66
+ /**
67
+ * Validate + normalize a candidate Authority Server URL — the SAME rule
68
+ * `config set as-url` enforces (see config.mjs's validateAsUrl), duplicated
69
+ * rather than imported so this module has no dependency on config.mjs (the
70
+ * TS mirrors of each live in different files entirely).
71
+ */
72
+ function validateAsUrl(candidate) {
73
+ const trimmed = (candidate ?? '').trim();
74
+ if (!trimmed) return { ok: false, error: 'The Authority Server URL is empty.' };
75
+ let parsed;
76
+ try {
77
+ parsed = new URL(trimmed);
78
+ } catch {
79
+ return { ok: false, error: `"${trimmed}" is not a valid URL.` };
80
+ }
81
+ const isLocalHttp =
82
+ parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1');
83
+ if (parsed.protocol !== 'https:' && !isLocalHttp) {
84
+ return {
85
+ ok: false,
86
+ error: `"${trimmed}" must use https:// (http:// is only allowed for http://localhost or http://127.0.0.1).`,
87
+ };
88
+ }
89
+ let normalized = `${parsed.origin}${parsed.pathname}${parsed.search}${parsed.hash}`;
90
+ if (normalized.endsWith('/') && normalized !== `${parsed.origin}/`) {
91
+ normalized = normalized.slice(0, -1);
92
+ } else if (normalized === `${parsed.origin}/`) {
93
+ normalized = parsed.origin;
94
+ }
95
+ return { ok: true, url: normalized };
96
+ }
97
+
98
+ /** Same rule as config.mjs's validateCaFile: must exist on disk. Resolved
99
+ * against cwd like the CLI flag is — a policy-set relative path is
100
+ * unusual, but behaving consistently with `config set ca-file` beats a
101
+ * second rule nobody remembers. */
102
+ function validateCaFile(candidate) {
103
+ const trimmed = (candidate ?? '').trim();
104
+ if (!trimmed) return { ok: false, error: 'The CA file path is empty.' };
105
+ if (!existsSync(trimmed)) return { ok: false, error: `No file at "${trimmed}".` };
106
+ return { ok: true, path: trimmed };
107
+ }
108
+
109
+ /** Same rule as config.mjs's validateProxyUrl: http:// or https:// only. */
110
+ function validateProxyUrl(candidate) {
111
+ const trimmed = (candidate ?? '').trim();
112
+ if (!trimmed) return { ok: false, error: 'The proxy URL is empty.' };
113
+ let parsed;
114
+ try {
115
+ parsed = new URL(trimmed);
116
+ } catch {
117
+ return { ok: false, error: `"${trimmed}" is not a valid URL.` };
118
+ }
119
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
120
+ return { ok: false, error: `"${trimmed}" must use http:// or https://.` };
121
+ }
122
+ return { ok: true, url: trimmed };
123
+ }
124
+
125
+ /** Accepts a JSON boolean, or the DWORD-style 0/1 (as a number OR a string,
126
+ * since the registry parser below hands back numbers but a hand-edited
127
+ * JSON file might use either). Returns undefined — not a default — for
128
+ * anything else, so the caller can tell "not a boolean" from "false". */
129
+ function coerceBoolean(value) {
130
+ if (typeof value === 'boolean') return value;
131
+ if (typeof value === 'number') {
132
+ if (value === 0) return false;
133
+ if (value === 1) return true;
134
+ return undefined;
135
+ }
136
+ if (typeof value === 'string') {
137
+ const v = value.trim().toLowerCase();
138
+ if (v === 'true' || v === '1') return true;
139
+ if (v === 'false' || v === '0') return false;
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ // ─── Registry (Windows only) ────────────────────────────────────────────
145
+
146
+ /**
147
+ * Parse `reg query "HKLM\...\Gateway"` stdout. Real output looks like:
148
+ *
149
+ * HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Suveren\Gateway
150
+ * AsUrl REG_SZ https://as.company.internal
151
+ * PinTls REG_DWORD 0x1
152
+ *
153
+ * Value lines are 4-space-indented; columns are separated by runs of
154
+ * whitespace. REG_DWORD values print as hex ("0x1"), parsed to a number
155
+ * here so coerceBoolean can treat it like any other 0/1.
156
+ */
157
+ export function parseRegQueryOutput(stdout) {
158
+ const out = {};
159
+ const lineRe = /^ {4}(\S+)\s+(REG_SZ|REG_EXPAND_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_MULTI_SZ)\s+(.*)$/;
160
+ for (const line of stdout.split(/\r?\n/)) {
161
+ const m = lineRe.exec(line);
162
+ if (!m) continue;
163
+ const [, name, type, rawValue] = m;
164
+ if (!POLICY_KEYS.includes(name)) continue;
165
+ if (type === 'REG_DWORD' || type === 'REG_QWORD') {
166
+ out[name] = parseInt(rawValue, 16);
167
+ } else {
168
+ out[name] = rawValue;
169
+ }
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /** `{ values, source }` for one registry hive, or `{ values: {}, source }`
175
+ * on any failure (key absent — the normal "no policy set" case — missing
176
+ * `reg` binary, or a platform that isn't Windows at all). Never throws:
177
+ * an operator without IT policy must see the gateway behave exactly as it
178
+ * always has.
179
+ *
180
+ * `SUVEREN_POLICY_REGISTRY=off` skips the registry entirely, returning
181
+ * `{}` without even spawning `reg` — unit tests set this globally (see
182
+ * each app's vitest setup) so a real IT policy on the machine running the
183
+ * suite, or another package's test writing to the registry concurrently,
184
+ * cannot change what these tests see. Production never sets this. */
185
+ function readRegistryHive(hive) {
186
+ const path = registryKeyPath(hive);
187
+ const source = `the registry policy (${path})`;
188
+ // Checked BEFORE the platform branch so the switch is testable on any OS,
189
+ // not just Windows — see gateway-policy.test.ts.
190
+ if (process.env.SUVEREN_POLICY_REGISTRY === 'off') return { values: {}, source };
191
+ if (process.platform !== 'win32') return { values: {}, source };
192
+ let result;
193
+ try {
194
+ result = spawnSync('reg', ['query', path], { encoding: 'utf8' });
195
+ } catch {
196
+ return { values: {}, source };
197
+ }
198
+ if (!result || result.status !== 0 || !result.stdout) return { values: {}, source };
199
+ return { values: parseRegQueryOutput(result.stdout), source };
200
+ }
201
+
202
+ // ─── Policy file ────────────────────────────────────────────────────────
203
+
204
+ export function policyFilePath() {
205
+ if (process.env.SUVEREN_POLICY_FILE) return process.env.SUVEREN_POLICY_FILE;
206
+ if (process.platform === 'win32') {
207
+ const programData = process.env.ProgramData || process.env.PROGRAMDATA || 'C:\\ProgramData';
208
+ return join(programData, 'Suveren', 'gateway-policy.json');
209
+ }
210
+ if (process.platform === 'darwin') {
211
+ return '/Library/Application Support/Suveren/gateway-policy.json';
212
+ }
213
+ return '/etc/suveren/gateway-policy.json';
214
+ }
215
+
216
+ /** Throws (fail closed, audibly — same rule as config.mjs's
217
+ * readConfigStrict) when the file exists but is not valid JSON. A missing
218
+ * file is the normal "no policy set" case and returns `{}`. */
219
+ function readPolicyFile() {
220
+ const path = policyFilePath();
221
+ const source = `the policy file (${path})`;
222
+ if (!existsSync(path)) return { values: {}, source };
223
+ let data;
224
+ try {
225
+ data = JSON.parse(readFileSync(path, 'utf8'));
226
+ } catch (err) {
227
+ throw new Error(`Could not parse policy file ${path}: ${err instanceof Error ? err.message : String(err)}`);
228
+ }
229
+ const values = {};
230
+ for (const key of POLICY_KEYS) {
231
+ if (Object.prototype.hasOwnProperty.call(data, key)) values[key] = data[key];
232
+ }
233
+ return { values, source };
234
+ }
235
+
236
+ // ─── Merge + validate ───────────────────────────────────────────────────
237
+
238
+ /** First entry (in precedence order) that defines each key wins. Returns
239
+ * the merged raw values plus, per key, which source they came from (for
240
+ * error messages). */
241
+ function mergeBySource(entries) {
242
+ const merged = {};
243
+ const sourceOf = {};
244
+ for (const key of POLICY_KEYS) {
245
+ for (const entry of entries) {
246
+ if (Object.prototype.hasOwnProperty.call(entry.values, key) && entry.values[key] !== undefined) {
247
+ merged[key] = entry.values[key];
248
+ sourceOf[key] = entry.source;
249
+ break;
250
+ }
251
+ }
252
+ }
253
+ return { merged, sourceOf };
254
+ }
255
+
256
+ let cached = null;
257
+
258
+ /**
259
+ * Read + validate the effective policy. Memoized for the life of the
260
+ * process (registry reads spawn a child process — doing that on every
261
+ * `isSimulationMode()` call, which runs per tool call, would be a
262
+ * synchronous fork on the hot path). Policy changes take effect on the next
263
+ * restart anyway, same as every other setting here — see
264
+ * `_resetPolicyCacheForTests` for the one case that needs otherwise.
265
+ *
266
+ * Throws a named error — mentioning the key, the offending value, and which
267
+ * source it came from — on an invalid value, exactly like a bad `config
268
+ * set` input (see config.mjs's resolveAsUrl doc comment on failing closed
269
+ * and audibly rather than silently falling back).
270
+ */
271
+ export function readPolicy() {
272
+ if (cached) return cached;
273
+
274
+ const hklm = readRegistryHive('HKLM');
275
+ const hkcu = readRegistryHive('HKCU');
276
+ const file = readPolicyFile(); // may throw — propagated, not swallowed
277
+
278
+ const { merged, sourceOf } = mergeBySource([hklm, hkcu, file]);
279
+
280
+ const policy = {};
281
+ const locked = new Set();
282
+
283
+ if (merged.AsUrl !== undefined) {
284
+ const v = validateAsUrl(String(merged.AsUrl));
285
+ if (!v.ok) {
286
+ throw new Error(`Invalid policy AsUrl ("${merged.AsUrl}") from ${sourceOf.AsUrl}: ${v.error}`);
287
+ }
288
+ policy.asUrl = v.url;
289
+ locked.add('asUrl');
290
+ }
291
+
292
+ if (merged.CaFile !== undefined) {
293
+ const v = validateCaFile(String(merged.CaFile));
294
+ if (!v.ok) {
295
+ throw new Error(`Invalid policy CaFile ("${merged.CaFile}") from ${sourceOf.CaFile}: ${v.error}`);
296
+ }
297
+ policy.caFile = v.path;
298
+ locked.add('caFile');
299
+ }
300
+
301
+ if (merged.PinTls !== undefined) {
302
+ const b = coerceBoolean(merged.PinTls);
303
+ if (b === undefined) {
304
+ throw new Error(
305
+ `Invalid policy PinTls ("${merged.PinTls}") from ${sourceOf.PinTls}: must be a boolean or 0/1.`,
306
+ );
307
+ }
308
+ policy.pinTls = b;
309
+ locked.add('pinTls');
310
+ }
311
+
312
+ if (merged.Proxy !== undefined) {
313
+ const v = validateProxyUrl(String(merged.Proxy));
314
+ if (!v.ok) {
315
+ throw new Error(`Invalid policy Proxy ("${merged.Proxy}") from ${sourceOf.Proxy}: ${v.error}`);
316
+ }
317
+ policy.proxy = v.url;
318
+ locked.add('proxy');
319
+ }
320
+
321
+ // NoProxy has no saved-config/CLI counterpart — just a string, no URL
322
+ // shape to validate (it's a NO_PROXY-style hostname list, same grammar as
323
+ // the env var itself).
324
+ if (merged.NoProxy !== undefined) {
325
+ const raw = String(merged.NoProxy).trim();
326
+ if (!raw) {
327
+ throw new Error(`Invalid policy NoProxy ("${merged.NoProxy}") from ${sourceOf.NoProxy}: must not be empty.`);
328
+ }
329
+ policy.noProxy = raw;
330
+ locked.add('noProxy');
331
+ }
332
+
333
+ if (merged.Simulation !== undefined) {
334
+ const b = coerceBoolean(merged.Simulation);
335
+ if (b === undefined) {
336
+ throw new Error(
337
+ `Invalid policy Simulation ("${merged.Simulation}") from ${sourceOf.Simulation}: must be a boolean or 0/1.`,
338
+ );
339
+ }
340
+ policy.simulation = b;
341
+ locked.add('simulation');
342
+ }
343
+
344
+ if (merged.InstallMethod !== undefined) {
345
+ const raw = String(merged.InstallMethod).trim().toLowerCase();
346
+ if (raw !== 'managed') {
347
+ throw new Error(
348
+ `Invalid policy InstallMethod ("${merged.InstallMethod}") from ${sourceOf.InstallMethod}: only "managed" is supported.`,
349
+ );
350
+ }
351
+ policy.installMethod = 'managed';
352
+ locked.add('installMethod');
353
+ }
354
+
355
+ cached = { policy, locked };
356
+ return cached;
357
+ }
358
+
359
+ /** Convenience: is this one key locked by policy? */
360
+ export function isPolicyLocked(key) {
361
+ return readPolicy().locked.has(key);
362
+ }
363
+
364
+ /** Drop the memoized policy so the next `readPolicy()` call re-reads the
365
+ * registry/file. Test-only — a real process never needs this, since
366
+ * policy is only ever consulted again after a restart. */
367
+ export function _resetPolicyCacheForTests() {
368
+ cached = null;
369
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suveren/gateway",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Suveren gateway — local agent gateway built in compliance with the Human Agency Protocol (HAP). Runs the UI, control plane, and MCP server in one Node process.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -12,7 +12,8 @@ import { dirname, join } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { homedir, tmpdir, constants as osConstants } from 'node:os';
14
14
  import { existsSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
15
- import { resolveCaFile, resolveSimulation } from './lib/config.mjs';
15
+ import { resolveCaFile, resolveProxyUrl, resolveSimulation } from './lib/config.mjs';
16
+ import { readPolicy, isPolicyLocked } from './lib/policy.mjs';
16
17
  import { unsupportedNodeReason } from './lib/node-version.mjs';
17
18
 
18
19
  // Docker and the login service start this file directly, not through the CLI,
@@ -121,6 +122,43 @@ if (savedCaFile && !process.env.SUVEREN_CA_REEXEC_DONE) {
121
122
  // via `...process.env`, all the way down to individual integrations.
122
123
  delete process.env.SUVEREN_CA_REEXEC_DONE;
123
124
 
125
+ // ─── Corporate proxy (`config set proxy <url>` / `--proxy` / IT policy) ────
126
+ //
127
+ // `HTTP_PROXY`/`HTTPS_PROXY` are read fresh on every outbound call (see
128
+ // proxy-env.ts in both apps) — unlike NODE_EXTRA_CA_CERTS above, there is no
129
+ // Node-startup-time requirement, so no re-exec dance is needed here.
130
+ //
131
+ // Two different precedence rules apply, because `resolveProxyUrl` already
132
+ // folds IT policy ahead of the saved value (see config.mjs):
133
+ // - IT policy LOCKED (`Proxy` — see lib/policy.mjs): OVERRIDES an
134
+ // operator's own HTTP_PROXY/HTTPS_PROXY unconditionally — "locked"
135
+ // means locked even against a pre-existing env var, the one case among
136
+ // all the policy-able settings where there IS a meaningful env tier to
137
+ // override (every other locked key has none to begin with). This is
138
+ // the Windows-company-laptop case: the proxy lives in system settings,
139
+ // not an env var an employee thinks to set.
140
+ // - Otherwise (a plain saved `config set proxy`): fills in whichever of
141
+ // HTTP_PROXY/HTTPS_PROXY is still unset — an operator's own value
142
+ // always wins, same as every other env-vs-saved precedence here.
143
+ const resolvedProxyUrl = resolveProxyUrl(DATA_DIR);
144
+ if (resolvedProxyUrl) {
145
+ if (isPolicyLocked('proxy')) {
146
+ process.env.HTTP_PROXY = resolvedProxyUrl;
147
+ process.env.HTTPS_PROXY = resolvedProxyUrl;
148
+ } else {
149
+ process.env.HTTP_PROXY ??= resolvedProxyUrl;
150
+ process.env.HTTPS_PROXY ??= resolvedProxyUrl;
151
+ }
152
+ }
153
+
154
+ // `NoProxy` has no saved-config/CLI counterpart at all (see policy.mjs) —
155
+ // NO_PROXY stays env-only except for this one IT-policy override, which (like
156
+ // `Proxy` above) overrides an operator's own NO_PROXY unconditionally when set.
157
+ const policyNoProxy = readPolicy().policy.noProxy;
158
+ if (policyNoProxy) {
159
+ process.env.NO_PROXY = policyNoProxy;
160
+ }
161
+
124
162
  const CP_PORT = process.env.SUVEREN_CP_PORT ?? '3400';
125
163
  const MCP_PORT = process.env.SUVEREN_MCP_PORT ?? '3430';
126
164
  const UI_DIST = process.env.HAP_UI_DIST ?? join(__dirname, 'dist', 'ui');
@@ -147,13 +185,18 @@ const env = {
147
185
  SUVEREN_PROFILES_DIR: PROFILES_DIR,
148
186
  // Single shared internal secret so CP↔MCP authenticate the bridge.
149
187
  SUVEREN_INTERNAL_SECRET: process.env.SUVEREN_INTERNAL_SECRET ?? randomHex(32),
150
- // Simulation mode — same precedence as the CA bundle above: an explicit env
151
- // var (set directly, or by `start --simulation` setting process.env for
152
- // THIS run — see bundle/bin/suveren-gateway.js) wins; otherwise the saved
153
- // config.json value, which is what `suveren-gateway simulation on|off`
154
- // changes for every future start/restart, including autostart (which
155
- // spawns this file directly, bypassing the CLI's own flag handling).
156
- SUVEREN_SIMULATION: process.env.SUVEREN_SIMULATION ?? (resolveSimulation(DATA_DIR) ? '1' : '0'),
188
+ // Simulation mode — IT policy (see lib/policy.mjs) wins UNCONDITIONALLY,
189
+ // ahead of even an explicit env var: that is what "locked" means. Below
190
+ // that: an explicit env var (set directly, or by `start --simulation`
191
+ // setting process.env for THIS run — see bundle/bin/suveren-gateway.js)
192
+ // wins; otherwise the saved config.json value, which is what
193
+ // `suveren-gateway simulation on|off` changes for every future
194
+ // start/restart, including autostart (which spawns this file directly,
195
+ // bypassing the CLI's own flag handling).
196
+ SUVEREN_SIMULATION:
197
+ readPolicy().policy.simulation !== undefined
198
+ ? (readPolicy().policy.simulation ? '1' : '0')
199
+ : (process.env.SUVEREN_SIMULATION ?? (resolveSimulation(DATA_DIR) ? '1' : '0')),
157
200
  // Passed by the login service (see bundle/lib/autostart-templates.mjs) so the
158
201
  // control-plane knows a human is NOT sitting in front of a terminal watching
159
202
  // it start — that is when a locked gateway needs to announce itself.