@bitkyc08/opencodex 2.7.9-preview.20260712 → 2.7.9-preview.20260712.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/src/config.ts CHANGED
@@ -3,9 +3,41 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSy
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import * as z from "zod/v4";
6
+ import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
7
+ import { providerDestinationConfigError } from "./lib/destination-policy";
6
8
  import type { OcxConfig } from "./types";
7
9
 
8
10
  let _atomicSeq = 0;
11
+
12
+ interface AtomicRenameIO {
13
+ platform: NodeJS.Platform;
14
+ rename: (source: string, destination: string) => void;
15
+ sleep: (milliseconds: number) => void;
16
+ }
17
+
18
+ export function renameAtomicFile(
19
+ source: string,
20
+ destination: string,
21
+ io: AtomicRenameIO = {
22
+ platform: process.platform,
23
+ rename: renameSync,
24
+ sleep: Bun.sleepSync,
25
+ },
26
+ ): void {
27
+ for (let attempt = 0; ; attempt += 1) {
28
+ try {
29
+ io.rename(source, destination);
30
+ return;
31
+ } catch (error) {
32
+ const code = (error as NodeJS.ErrnoException).code;
33
+ const transientWindowsError = io.platform === "win32"
34
+ && (code === "EBUSY" || code === "EPERM" || code === "EACCES");
35
+ if (!transientWindowsError || attempt >= 2) throw error;
36
+ io.sleep(25 * (attempt + 1));
37
+ }
38
+ }
39
+ }
40
+
9
41
  /**
10
42
  * Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
11
43
  * proxy's own shutdown handler both restoring Codex — can never leave a half-written file.
@@ -13,7 +45,7 @@ let _atomicSeq = 0;
13
45
  export function atomicWriteFile(path: string, content: string): void {
14
46
  const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
15
47
  writeFileSync(tmp, content, { encoding: "utf-8", mode: 0o600 });
16
- renameSync(tmp, path);
48
+ renameAtomicFile(tmp, path);
17
49
  }
18
50
 
19
51
  /**
@@ -54,6 +86,7 @@ const warnedConfigFallbacks = new Set<string>();
54
86
  const providerConfigSchema = z.object({
55
87
  adapter: z.string().min(1),
56
88
  baseUrl: z.string().min(1),
89
+ allowPrivateNetwork: z.boolean().optional(),
57
90
  }).passthrough();
58
91
 
59
92
  const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
@@ -128,6 +161,15 @@ const configSchema = z.object({
128
161
  path: ["providers", name, "baseUrl"],
129
162
  message: baseUrlError,
130
163
  });
164
+ } else {
165
+ const destinationError = providerDestinationConfigError(name, provider);
166
+ if (destinationError) {
167
+ ctx.addIssue({
168
+ code: "custom",
169
+ path: ["providers", name, "baseUrl"],
170
+ message: destinationError,
171
+ });
172
+ }
131
173
  }
132
174
  const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers);
133
175
  if (headersError) {
@@ -178,15 +220,20 @@ export function hardenConfigDir(): void {
178
220
  const dir = getConfigDir();
179
221
  if (existsSync(dir)) {
180
222
  try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
223
+ if (process.platform === "win32") {
224
+ hardenSecretDir(dir, { required: false });
225
+ }
181
226
  }
182
227
  }
183
228
 
184
229
  export function hardenExistingSecret(path: string): void {
185
230
  if (existsSync(path)) {
186
231
  try { chmodSync(path, 0o600); } catch { /* best-effort */ }
232
+ if (process.platform === "win32") {
233
+ hardenSecretPath(path, { required: false });
234
+ }
187
235
  }
188
236
  }
189
-
190
237
  export function loadConfig(): OcxConfig {
191
238
  const dir = getConfigDir();
192
239
  const configPath = getConfigPath();
@@ -305,7 +352,11 @@ export function saveConfig(config: OcxConfig): void {
305
352
  } else {
306
353
  try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
307
354
  }
308
- atomicWriteFile(getConfigPath(), JSON.stringify(config, null, 2) + "\n");
355
+ if (process.platform === "win32") {
356
+ hardenSecretDir(dir, { required: true });
357
+ }
358
+ const configPath = getConfigPath();
359
+ atomicWriteFile(configPath, JSON.stringify(config, null, 2) + "\n");
309
360
  }
310
361
 
311
362
  export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
@@ -0,0 +1,167 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ import { getProviderRegistryEntry } from "../providers/registry";
4
+ import type { OcxProviderConfig } from "../types";
5
+
6
+ const BLOCKED_METADATA_HOSTS = new Set([
7
+ "instance-data.ec2.internal",
8
+ "metadata.azure.internal",
9
+ "metadata.google.internal",
10
+ ]);
11
+
12
+ const BLOCKED_METADATA_IPV4 = new Set([
13
+ "100.100.100.200",
14
+ "169.254.169.254",
15
+ "169.254.170.2",
16
+ ]);
17
+
18
+ const BLOCKED_METADATA_IPV6 = new Set([
19
+ "fd00:ec2::254",
20
+ ]);
21
+
22
+ type DestinationKind =
23
+ | "public"
24
+ | "hostname"
25
+ | "localhost"
26
+ | "loopback"
27
+ | "private"
28
+ | "link-local"
29
+ | "unspecified"
30
+ | "metadata";
31
+
32
+ interface DestinationAssessment {
33
+ kind: DestinationKind;
34
+ detail: string;
35
+ }
36
+
37
+ function normalizeHostname(hostname: string): string {
38
+ const trimmed = hostname.trim().toLowerCase().replace(/\.+$/, "");
39
+ return trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
40
+ }
41
+
42
+ function parseIpv4(hostname: string): number[] | null {
43
+ const parts = hostname.split(".");
44
+ if (parts.length !== 4) return null;
45
+ const octets = parts.map(part => Number(part));
46
+ return octets.every(octet => Number.isInteger(octet) && octet >= 0 && octet <= 255) ? octets : null;
47
+ }
48
+
49
+ function classifyIpv4(hostname: string): DestinationAssessment {
50
+ if (BLOCKED_METADATA_IPV4.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
51
+ const octets = parseIpv4(hostname);
52
+ if (!octets) return { kind: "public", detail: "public IP" };
53
+ const [a, b, c] = octets;
54
+ if (a === 127) return { kind: "loopback", detail: "loopback address" };
55
+ if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127)) {
56
+ return { kind: "private", detail: "private-network address" };
57
+ }
58
+ if (a === 169 && b === 254) return { kind: "link-local", detail: "link-local address" };
59
+ if (a === 0) return { kind: "unspecified", detail: "unspecified address" };
60
+ // Reserved / non-public ranges (review finding, PR #96): protocol-assignment,
61
+ // documentation, benchmark, multicast, and reserved-future space never name a
62
+ // legitimate provider endpoint.
63
+ if (a === 192 && b === 0 && (c === 0 || c === 2)) return { kind: "private", detail: "reserved address" };
64
+ if (a === 198 && (b === 18 || b === 19)) return { kind: "private", detail: "benchmark address" };
65
+ if (a === 198 && b === 51 && c === 100) return { kind: "private", detail: "documentation address" };
66
+ if (a === 203 && b === 0 && c === 113) return { kind: "private", detail: "documentation address" };
67
+ if (a >= 224) return { kind: "private", detail: "multicast/reserved address" };
68
+ return { kind: "public", detail: "public IP" };
69
+ }
70
+
71
+ function firstIpv6Hextet(hostname: string): number | null {
72
+ const head = hostname.split(":")[0];
73
+ if (!head) return 0;
74
+ const parsed = Number.parseInt(head, 16);
75
+ return Number.isNaN(parsed) ? null : parsed;
76
+ }
77
+
78
+ function classifyIpv6(hostname: string): DestinationAssessment {
79
+ if (BLOCKED_METADATA_IPV6.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
80
+ const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)?.[1];
81
+ if (mappedIpv4) return classifyIpv4(mappedIpv4);
82
+ if (hostname === "::1") return { kind: "loopback", detail: "loopback address" };
83
+ if (hostname === "::") return { kind: "unspecified", detail: "unspecified address" };
84
+ const hextet = firstIpv6Hextet(hostname);
85
+ if (hextet === null) return { kind: "public", detail: "public IP" };
86
+ if (hextet >= 0xfc00 && hextet <= 0xfdff) return { kind: "private", detail: "private-network address" };
87
+ if (hextet >= 0xfe80 && hextet <= 0xfebf) return { kind: "link-local", detail: "link-local address" };
88
+ return { kind: "public", detail: "public IP" };
89
+ }
90
+
91
+ function assessDestination(baseUrl: string): DestinationAssessment | null {
92
+ try {
93
+ const parsed = new URL(baseUrl.trim());
94
+ const hostname = normalizeHostname(parsed.hostname);
95
+ if (!hostname) return null;
96
+ if (BLOCKED_METADATA_HOSTS.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
97
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) {
98
+ return { kind: "localhost", detail: "localhost destination" };
99
+ }
100
+ const ipKind = isIP(hostname);
101
+ if (ipKind === 4) return classifyIpv4(hostname);
102
+ if (ipKind === 6) return classifyIpv6(hostname);
103
+ return { kind: "hostname", detail: "hostname destination" };
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function registryAllowsPrivateNetwork(name: string): boolean {
110
+ return getProviderRegistryEntry(name)?.allowPrivateNetworkByDefault === true;
111
+ }
112
+
113
+ export function providerDestinationConfigError(name: string, provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">): string | null {
114
+ const assessment = assessDestination(provider.baseUrl);
115
+ if (!assessment) return null;
116
+ if (assessment.kind === "public" || assessment.kind === "hostname") return null;
117
+ if (assessment.kind === "metadata") return "baseUrl targets a blocked metadata endpoint";
118
+ if (registryAllowsPrivateNetwork(name)) return null;
119
+ if (provider.allowPrivateNetwork === true) return null;
120
+ return `baseUrl points to a ${assessment.detail}; set allowPrivateNetwork:true only for intentionally local/self-hosted providers`;
121
+ }
122
+
123
+ export function assertProviderDestinationAllowed(name: string, provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">): void {
124
+ const error = providerDestinationConfigError(name, provider);
125
+ if (error) throw new Error(`provider ${name} ${error}`);
126
+ }
127
+
128
+ /**
129
+ * Async companion to {@link providerDestinationConfigError} for hostname destinations:
130
+ * resolves A/AAAA records and classifies every address, so a hostname that points at
131
+ * loopback/private/metadata space is caught at provider write time (review finding,
132
+ * PR #96 — the sync path must stay literal-only because the router hot path and
133
+ * config load are synchronous). DNS failures return null: config-time validation is
134
+ * advisory and must not hard-fail offline startups. DNS rebinding after validation is
135
+ * a recorded residual for this loopback proxy (devlog 260712_pr_batch_landing 000).
136
+ */
137
+ export async function providerDestinationResolvedError(
138
+ name: string,
139
+ provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">,
140
+ ): Promise<string | null> {
141
+ const syncError = providerDestinationConfigError(name, provider);
142
+ if (syncError) return syncError;
143
+ let hostname: string;
144
+ try {
145
+ hostname = normalizeHostname(new URL(provider.baseUrl.trim()).hostname);
146
+ } catch {
147
+ return null;
148
+ }
149
+ if (!hostname || isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) {
150
+ return null; // literals and localhost are fully handled by the sync path
151
+ }
152
+ if (registryAllowsPrivateNetwork(name) || provider.allowPrivateNetwork === true) return null;
153
+ let addresses: { address: string }[];
154
+ try {
155
+ addresses = await lookup(hostname, { all: true, verbatim: true });
156
+ } catch {
157
+ return null; // unresolvable now ≠ malicious; the provider simply won't connect
158
+ }
159
+ for (const { address } of addresses) {
160
+ const ipKind = isIP(address);
161
+ const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
162
+ if (!assessment || assessment.kind === "public") continue;
163
+ if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`;
164
+ return `baseUrl hostname ${hostname} resolves to a ${assessment.detail} (${address}); set allowPrivateNetwork:true only for intentionally local/self-hosted providers`;
165
+ }
166
+ return null;
167
+ }
@@ -0,0 +1,34 @@
1
+ /** In-memory ring buffer of multi-agent guidance-injection / effort-cap log lines.
2
+ *
3
+ * Injection debug lines were previously console-only, so the GUI had an "Injection log"
4
+ * toggle with nothing to display. This buffer mirrors the provider debug buffer so the
5
+ * management API and GUI can tail injection lines the same way. Callers keep their own
6
+ * `isInjectionDebugEnabled()` guard; this module only stores what it is given. */
7
+
8
+ import type { DebugLogEntry } from "./debug-log-buffer";
9
+
10
+ const MAX_LINES = 2_000;
11
+ const buffer: DebugLogEntry[] = [];
12
+ let nextSeq = 1;
13
+
14
+ /** Append a line to the injection buffer and echo it to the server console. */
15
+ export function injectionDebugLog(line: string): void {
16
+ const entry: DebugLogEntry = { seq: nextSeq++, at: Date.now(), line };
17
+ buffer.push(entry);
18
+ if (buffer.length > MAX_LINES) buffer.splice(0, buffer.length - MAX_LINES);
19
+ console.log(line);
20
+ }
21
+
22
+ export function getInjectionDebugLogEntries(options?: { after?: number; limit?: number }): DebugLogEntry[] {
23
+ const after = options?.after ?? 0;
24
+ const limit = options?.limit ?? 500;
25
+ const filtered = after > 0 ? buffer.filter(entry => entry.seq > after) : buffer;
26
+ if (filtered.length <= limit) return filtered;
27
+ return filtered.slice(-limit);
28
+ }
29
+
30
+ /** Test isolation. */
31
+ export function resetInjectionDebugLogBufferForTests(): void {
32
+ buffer.length = 0;
33
+ nextSeq = 1;
34
+ }
@@ -14,12 +14,19 @@
14
14
  * MUST stay a leaf module: imports nothing from server.ts or adapters (kiro-retry imports
15
15
  * the shared abort helpers from here).
16
16
  */
17
+ import { clearableDeadline } from "./abort";
17
18
 
18
19
  // 1 initial + 2 retries: the pool may hold more than one stale socket.
19
20
  const RESET_RETRY_MAX_ATTEMPTS = 3;
20
21
  const RESET_RETRY_BASE_DELAY_MS = 150;
21
22
  const RESET_RETRY_MAX_DELAY_MS = 1_000;
22
23
 
24
+ export interface RetryBackoffOptions {
25
+ baseDelayMs: number;
26
+ maxDelayMs: number;
27
+ headers?: Headers;
28
+ }
29
+
23
30
  export function abortError(signal?: AbortSignal): unknown {
24
31
  return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
25
32
  }
@@ -56,11 +63,51 @@ export function isConnectionResetError(err: unknown): boolean {
56
63
  || msg.includes("connection reset by peer");
57
64
  }
58
65
 
59
- function retryDelayMs(attempt: number): number {
60
- const exp = Math.min(RESET_RETRY_BASE_DELAY_MS * (2 ** attempt), RESET_RETRY_MAX_DELAY_MS);
66
+ function retryAfterDelayMs(headers: Headers): number | undefined {
67
+ const raw = headers.get("retry-after")?.trim();
68
+ if (!raw) return undefined;
69
+ const seconds = Number(raw);
70
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
71
+ const dateMs = Date.parse(raw);
72
+ if (!Number.isFinite(dateMs)) return undefined;
73
+ return Math.max(0, dateMs - Date.now());
74
+ }
75
+
76
+ export function retryBackoffDelayMs(attempt: number, opts: RetryBackoffOptions): number {
77
+ const retryAfter = opts.headers ? retryAfterDelayMs(opts.headers) : undefined;
78
+ if (retryAfter !== undefined) return Math.min(retryAfter, opts.maxDelayMs);
79
+ const exp = Math.min(opts.baseDelayMs * (2 ** attempt), opts.maxDelayMs);
61
80
  return Math.floor(exp * (0.8 + Math.random() * 0.4));
62
81
  }
63
82
 
83
+ export function cancelResponseBodyBestEffort(res: Response): void {
84
+ try {
85
+ const cancellation = res.body?.cancel();
86
+ if (cancellation) void cancellation.catch(() => {});
87
+ } catch {
88
+ // Cancellation is cleanup only; retries must not wait for or fail because of it.
89
+ }
90
+ }
91
+
92
+ export async function fetchWithAttemptDeadline(
93
+ url: string,
94
+ init: RequestInit,
95
+ timeoutMs: number,
96
+ abortSignal?: AbortSignal,
97
+ ): Promise<Response> {
98
+ const attemptTimeout = clearableDeadline(timeoutMs, abortSignal);
99
+ try {
100
+ return await fetch(url, {
101
+ ...init,
102
+ signal: attemptTimeout.signal,
103
+ });
104
+ } finally {
105
+ // Only the header timer is cleared. The composed signal still contains the parent, so a
106
+ // caller abort after headers continue to cancel consumption of the returned response body.
107
+ attemptTimeout.clear();
108
+ }
109
+ }
110
+
64
111
  export interface ResetRetryOptions {
65
112
  abortSignal?: AbortSignal;
66
113
  /** Short host/path label for the retry warn log (no secrets/query strings). */
@@ -89,7 +136,10 @@ export async function fetchWithResetRetry(
89
136
  console.warn(
90
137
  `[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
91
138
  );
92
- await sleepWithAbort(retryDelayMs(attempt), opts.abortSignal);
139
+ await sleepWithAbort(retryBackoffDelayMs(attempt, {
140
+ baseDelayMs: RESET_RETRY_BASE_DELAY_MS,
141
+ maxDelayMs: RESET_RETRY_MAX_DELAY_MS,
142
+ }), opts.abortSignal);
93
143
  }
94
144
  }
95
145
  throw lastError ?? new Error("upstream fetch failed");
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Windows per-user NTFS ACL hardening for secret files and directories.
3
+ *
4
+ * On Windows, `chmod` only controls POSIX-style bits in the ACE list and does NOT remove
5
+ * inherited permissions from other users. Real per-user isolation requires icacls to:
6
+ * 1. Disable inheritance (icacls path /inheritance:r)
7
+ * 2. Strip broad explicit grants by SID (Everyone, Users, Authenticated Users)
8
+ * 3. Grant the current user full control (icacls path /grant:r "CURRENTUSER:(F)")
9
+ *
10
+ * On non-Windows platforms the helpers fall through to the caller's existing chmod-based
11
+ * behaviour: they return ok:true without invoking any external process.
12
+ *
13
+ * Design:
14
+ * hardenSecretPath(path, { required: false }) — non-fatal read-path mode.
15
+ * Never throws. Returns { ok, diagnostics? }.
16
+ * hardenSecretPath(path, { required: true }) — write-path mode.
17
+ * Throws a sanitized error (no raw path) on Windows ACL failure.
18
+ * hardenSecretDir — same contract for directories.
19
+ */
20
+
21
+ import { execFileSync } from "node:child_process";
22
+ import { existsSync } from "node:fs";
23
+ import { env, platform } from "node:process";
24
+
25
+ const hardenedDirectories = new Set<string>();
26
+ const hardenedPaths = new Set<string>();
27
+
28
+ export interface HardenResult {
29
+ ok: boolean;
30
+ diagnostics?: string;
31
+ }
32
+
33
+ export interface HardenOptions {
34
+ required: boolean;
35
+ }
36
+
37
+ /**
38
+ * Return the current Windows username from the environment.
39
+ * Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous.
40
+ * The value is used directly in icacls arguments, so it must be present.
41
+ */
42
+ function currentWindowsUser(): string | undefined {
43
+ const username = env["USERNAME"];
44
+ const domain = env["USERDOMAIN"];
45
+ if (!username) return undefined;
46
+ // USERDOMAIN is the machine/domain name; USERNAME is the account name.
47
+ // icacls accepts "DOMAIN\User" or just "User" for local accounts.
48
+ return domain ? `${domain}\\${username}` : username;
49
+ }
50
+
51
+ /**
52
+ * Run icacls to harden a single file system entry.
53
+ * - Disables inheritance (keeps nothing: /inheritance:r)
54
+ * - Grants the current user Full Control
55
+ *
56
+ * We do NOT use a shell string; all arguments are passed as an array so no
57
+ * shell injection is possible even for paths with unusual characters.
58
+ *
59
+ * Throws the raw child_process error on failure (caller sanitizes).
60
+ */
61
+ function runIcacls(targetPath: string, directory: boolean): void {
62
+ const user = currentWindowsUser();
63
+ if (!user) {
64
+ throw new Error("Cannot determine current Windows user for ACL hardening");
65
+ }
66
+
67
+ // Step 1: disable inheritance and remove inherited ACEs
68
+ execFileSync("icacls.exe", [targetPath, "/inheritance:r"], {
69
+ stdio: ["ignore", "pipe", "ignore"],
70
+ timeout: 5000,
71
+ shell: false,
72
+ });
73
+
74
+ // Step 2: remove broad explicit grants using stable SIDs (not localized names).
75
+ execFileSync("icacls.exe", [
76
+ targetPath,
77
+ "/remove:g",
78
+ "*S-1-1-0",
79
+ "*S-1-5-11",
80
+ "*S-1-5-32-545",
81
+ ], {
82
+ stdio: ["ignore", "pipe", "ignore"],
83
+ timeout: 5000,
84
+ shell: false,
85
+ });
86
+
87
+ // Step 3: grant current user full control.
88
+ const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
89
+ execFileSync("icacls.exe", [targetPath, "/grant:r", grant], {
90
+ stdio: ["ignore", "pipe", "ignore"],
91
+ timeout: 5000,
92
+ shell: false,
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Sanitize an error from a failed ACL operation into a safe diagnostic string.
98
+ * The raw path must not appear in the returned string (it may contain
99
+ * sensitive username components or PII from the home directory path).
100
+ */
101
+ function sanitizeDiagnostics(error: unknown): string {
102
+ // We do not expose the raw error message or any path-like fragments.
103
+ // Just describe what failed generically.
104
+ const code = error instanceof Error && "code" in error ? String((error as NodeJS.ErrnoException).code) : "";
105
+ const codePart = code ? ` (${code})` : "";
106
+ return `ACL hardening failed${codePart} — filesystem may not support per-user NTFS ACLs`;
107
+ }
108
+
109
+ /**
110
+ * Harden a single file path with per-user NTFS ACLs on Windows.
111
+ * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
112
+ *
113
+ * @param targetPath Absolute path to the file to harden.
114
+ * @param opts { required: boolean } — required:true throws on failure.
115
+ */
116
+ export function hardenSecretPath(targetPath: string, opts: HardenOptions): HardenResult {
117
+ // Skip for missing files — we cannot harden what does not exist yet.
118
+ if (!existsSync(targetPath)) {
119
+ return { ok: true };
120
+ }
121
+
122
+ // Non-Windows: no NTFS ACLs; caller handles chmod.
123
+ if (platform !== "win32") {
124
+ return { ok: true };
125
+ }
126
+
127
+ if (hardenedPaths.has(targetPath)) return { ok: true };
128
+
129
+ try {
130
+ runIcacls(targetPath, false);
131
+ hardenedPaths.add(targetPath);
132
+ return { ok: true };
133
+ } catch (err) {
134
+ const diagnostics = sanitizeDiagnostics(err);
135
+ if (opts.required) {
136
+ throw new Error(diagnostics);
137
+ }
138
+ return { ok: false, diagnostics };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Harden a directory path with per-user NTFS ACLs on Windows.
144
+ * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
145
+ *
146
+ * @param targetPath Absolute path to the directory to harden.
147
+ * @param opts { required: boolean } — required:true throws on failure.
148
+ */
149
+ export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult {
150
+ // Skip for missing directories — we cannot harden what does not exist yet.
151
+ if (!existsSync(targetPath)) {
152
+ return { ok: true };
153
+ }
154
+
155
+ // Non-Windows: no NTFS ACLs; caller handles chmod.
156
+ if (platform !== "win32") {
157
+ return { ok: true };
158
+ }
159
+
160
+ if (hardenedDirectories.has(targetPath)) return { ok: true };
161
+
162
+ try {
163
+ runIcacls(targetPath, true);
164
+ hardenedDirectories.add(targetPath);
165
+ return { ok: true };
166
+ } catch (err) {
167
+ const diagnostics = sanitizeDiagnostics(err);
168
+ if (opts.required) {
169
+ throw new Error(diagnostics);
170
+ }
171
+ return { ok: false, diagnostics };
172
+ }
173
+ }
@@ -54,6 +54,7 @@ function persist(store: AuthStore): void {
54
54
  } else {
55
55
  try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
56
56
  }
57
+ hardenConfigDir();
57
58
  atomicWriteFile(authPath(), JSON.stringify(store, null, 2) + "\n");
58
59
  }
59
60
 
@@ -18,6 +18,7 @@ export interface ProviderRegistryEntry {
18
18
  adapter: string;
19
19
  baseUrl: string;
20
20
  authKind: ProviderAuthKind;
21
+ allowPrivateNetworkByDefault?: boolean;
21
22
  keyOptional?: boolean;
22
23
  allowBaseUrlOverride?: boolean;
23
24
  modelSuffixBracketStrip?: boolean;
@@ -436,9 +437,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
436
437
  { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
437
438
  { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.5-flash-low", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
438
439
  { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
439
- { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
440
- { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
441
- { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" },
440
+ { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
441
+ { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
442
+ { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" },
442
443
  {
443
444
  id: "deepseek",
444
445
  label: "DeepSeek",
@@ -521,6 +522,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
521
522
  {
522
523
  id: "litellm", label: "LiteLLM (self-hosted)", baseUrl: "http://localhost:4000/v1", adapter: "openai-chat", authKind: "key",
523
524
  dashboardUrl: "https://docs.litellm.ai/docs/proxy/quick_start",
525
+ allowPrivateNetworkByDefault: true,
524
526
  allowBaseUrlOverride: true,
525
527
  // A self-hosted proxy may legitimately run without a master key.
526
528
  keyOptional: true,
package/src/router.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OcxConfig, OcxProviderConfig } from "./types";
2
2
  import { hasOwnProvider, resolveEnvValue } from "./config";
3
+ import { assertProviderDestinationAllowed } from "./lib/destination-policy";
3
4
  import { PROVIDER_REGISTRY } from "./providers/registry";
4
5
 
5
6
  interface RouteResult {
@@ -79,7 +80,10 @@ function mergeStringArrayRecord(
79
80
 
80
81
  function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
81
82
  const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
82
- if (!registryEntry) return { ...provider, apiKey: resolveEnvValue(provider.apiKey) };
83
+ if (!registryEntry) {
84
+ assertProviderDestinationAllowed(providerName, provider);
85
+ return { ...provider, apiKey: resolveEnvValue(provider.apiKey) };
86
+ }
83
87
  const canonicalAuthMode = registryEntry.authKind === "forward" || registryEntry.authKind === "oauth"
84
88
  ? registryEntry.authKind
85
89
  : provider.authMode === "forward" ? undefined : provider.authMode;
@@ -107,6 +111,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
107
111
  const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved
108
112
  ? userBaseUrl
109
113
  : registryEntry.baseUrl;
114
+ assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork });
110
115
 
111
116
  return {
112
117
  ...provider,
@@ -5,6 +5,7 @@ import {
5
5
  providerBaseUrlConfigError,
6
6
  providerHeadersConfigError,
7
7
  } from "../config";
8
+ import { providerDestinationConfigError } from "../lib/destination-policy";
8
9
  import type { OcxConfig, OcxProviderConfig } from "../types";
9
10
 
10
11
  let _corsOrigin = "http://localhost:10100";
@@ -156,6 +157,8 @@ export function requireApiAuth(req: Request, config: OcxConfig, kind: "managemen
156
157
  export function providerManagementConfigError(name: string, provider: OcxProviderConfig): string | null {
157
158
  const baseUrlError = providerBaseUrlConfigError(provider.baseUrl);
158
159
  if (baseUrlError) return `provider ${name} ${baseUrlError}`;
160
+ const destinationError = providerDestinationConfigError(name, provider);
161
+ if (destinationError) return `provider ${name} ${destinationError}`;
159
162
  const headersError = providerHeadersConfigError(provider.headers);
160
163
  if (headersError) return `provider ${name} ${headersError}`;
161
164
  if (provider.authMode === "forward") {
@@ -205,6 +208,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
205
208
  for (const key of [
206
209
  "defaultModel",
207
210
  "disabled",
211
+ "allowPrivateNetwork",
208
212
  "authMode",
209
213
  "liveModels",
210
214
  "models",