@themoltnet/sandbox-gondolin 0.3.0 → 0.4.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/README.md CHANGED
@@ -14,10 +14,9 @@ implementation can be added beside it without going through Pi.
14
14
  with VFS shadowing (`deny` / `tmpfs`), egress allowlist through Gondolin's
15
15
  host-side HTTP hooks, explicit guest environment, resource limits, resume
16
16
  commands, and abort-safe cleanup.
17
- - The guest credential boundary: `guest-config` injects the complete
18
- `.moltnet/<agent>` tree; `host-authenticated` withholds it and refuses
19
- `MOLTNET_*` names in runtime-controlled guest environment
20
- (`assertGuestEnvironmentBoundary`).
17
+ - The guest credential boundary: the guest never receives the
18
+ `.moltnet/<agent>` tree, and `MOLTNET_*` names are refused in
19
+ runtime-controlled guest environment (`assertGuestEnvironmentBoundary`).
21
20
  - `VmConfig.brokeredSecrets` — host-brokered secrets: the guest sees a
22
21
  placeholder, Gondolin's `SecretManager` substitutes the value only in
23
22
  requests to the declared protocol, host patterns, and ports.
@@ -27,10 +26,10 @@ implementation can be added beside it without going through Pi.
27
26
 
28
27
  ## What it deliberately does not own
29
28
 
30
- - **Provider authentication.** A runtime supplies `VmConfig.providerAuth`
31
- (`{ load(): string | null; guestPath }`); the sandbox only carries the blob
32
- across the boundary. Pi's `~/.pi/agent/auth.json` handling lives in
33
- `@themoltnet/pi-runtime` (`piProviderAuth`, and its `resumeVm` wrapper).
29
+ - **Provider authentication.** The coding-agent session and its model calls run
30
+ host-side (Pi's `createAgentSession` reads the host `~/.pi/agent` auth), so no
31
+ provider auth is projected into the guest — the guest only executes tools via
32
+ `vm.exec`.
34
33
  - Tool definitions, tool policy, task execution, workspace preparation —
35
34
  all Pi-runtime concerns.
36
35
 
@@ -54,7 +53,6 @@ contain a value or choose a host secret-provider coordinate.
54
53
  const managed = await resumeVm({
55
54
  checkpointPath,
56
55
  agentName: 'worker',
57
- guestCredentialMode: 'host-authenticated',
58
56
  mountPath: workspace,
59
57
  sandboxConfig: {
60
58
  network: { allowedHosts: ['api.example.com'] },
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { HttpIpAllowInfo } from '@earendil-works/gondolin';
1
2
  import { MemoryProvider } from '@earendil-works/gondolin';
2
3
  import { SecretDefinition } from '@earendil-works/gondolin';
3
4
  import { VirtualFileHandle } from '@earendil-works/gondolin';
@@ -20,12 +21,11 @@ declare interface AbortableResourceOptions<T> {
20
21
  export declare function activateAgentEnv(agentEnv: Record<string, string | undefined>, repoRoot: string): void;
21
22
 
22
23
  export declare function assertGuestEnvironmentBoundary(options: {
23
- guestCredentialMode: GuestCredentialMode;
24
24
  forwardEnv?: readonly string[];
25
25
  sandboxEnv?: Readonly<Record<string, string>>;
26
26
  }): void;
27
27
 
28
- /** @deprecated Prefer assertGuestEnvironmentBoundary for mode-aware checks. */
28
+ /** @deprecated Alias of assertGuestEnvironmentBoundary (a single boundary). */
29
29
  export declare function assertHostAuthenticatedGuestEnvironment(options: {
30
30
  forwardEnv?: readonly string[];
31
31
  sandboxEnv?: Readonly<Record<string, string>>;
@@ -85,6 +85,20 @@ export declare interface BrokeredHttpSecretManager {
85
85
  */
86
86
  export declare function canonicalizeBrokeredHttpSecretDescriptor(descriptor: BrokeredHttpSecretDescriptor): Required<BrokeredHttpSecretDescriptor>;
87
87
 
88
+ /**
89
+ * Refine Gondolin's hostname allowlist for hosts carrying brokered
90
+ * credentials. The built-in allowlist is hostname-granular; these callbacks
91
+ * enforce the descriptor protocol and port before request or IP dispatch.
92
+ */
93
+ export declare function createBrokeredHttpNetworkOriginPolicy(bindings: readonly BrokeredHttpSecretDescriptor[], options?: {
94
+ onDenied?: (denial: RequestOrigin & {
95
+ phase: 'request' | 'ip';
96
+ }) => void;
97
+ }): {
98
+ isRequestAllowed: (request: Request) => boolean;
99
+ isIpAllowed: (info: HttpIpAllowInfo) => boolean;
100
+ };
101
+
88
102
  export declare function delay(ms: number, signal: AbortSignal | undefined, label: string): Promise<void>;
89
103
 
90
104
  /**
@@ -100,6 +114,14 @@ export declare interface EnsureSnapshotOptions {
100
114
  maxCached?: number;
101
115
  }
102
116
 
117
+ /**
118
+ * Execute one login-shell command in a Gondolin VM. Successful commands may
119
+ * reuse the VM. Timeout or cancellation retires the complete VM through the
120
+ * host API before returning, because no guest process/session boundary can
121
+ * prove containment against daemonization or a guest-mutated root filesystem.
122
+ */
123
+ export declare function execManagedCommand(vm: VM, command: string, options?: ManagedExecOptions): Promise<ManagedExecResult>;
124
+
103
125
  /**
104
126
  * Resolve the main worktree root (where .moltnet/ lives — it's untracked,
105
127
  * only exists in the main worktree, not in git worktrees).
@@ -137,8 +159,6 @@ export declare const GUEST_TASK_CONTEXT_MOUNT = "/moltnet-task-context";
137
159
  /** @deprecated Use GUEST_TASK_CONTEXT_MOUNT. */
138
160
  export declare const GUEST_TASK_SKILLS_MOUNT = "/moltnet-task-context";
139
161
 
140
- export declare type GuestCredentialMode = 'guest-config' | 'host-authenticated';
141
-
142
162
  export declare class GuestEnvironmentBoundaryError extends Error {
143
163
  readonly refusedNames: readonly string[];
144
164
  constructor(refusedNames: readonly string[]);
@@ -187,7 +207,43 @@ declare type HostOriginHandler = (request: Request) => Promise<Response>;
187
207
  */
188
208
  export declare function isResolvedPathInsideRoot(path: string, root: string): boolean;
189
209
 
190
- export declare function loadCredentials(agentDir: string, mode?: GuestCredentialMode, onDiagnostic?: (diagnostic: VmDiagnostic) => void, providerAuth?: ProviderAuthSource): VmCredentials;
210
+ export declare function loadCredentials(): VmCredentials;
211
+
212
+ export declare interface ManagedExecDiagnostic {
213
+ event: 'managed_exec.backend_retirement';
214
+ trigger: 'cancellation' | 'timeout';
215
+ outcome: 'started' | 'succeeded' | 'failed';
216
+ message: string;
217
+ }
218
+
219
+ export declare interface ManagedExecOptions {
220
+ cwd?: string;
221
+ env?: Record<string, string>;
222
+ signal?: AbortSignal;
223
+ timeoutMs?: number;
224
+ onData?: (data: Buffer, stream: 'stdout' | 'stderr') => void;
225
+ onDiagnostic?: (diagnostic: ManagedExecDiagnostic) => void;
226
+ onStarted?: () => void;
227
+ }
228
+
229
+ export declare interface ManagedExecResult {
230
+ exitCode: number;
231
+ timedOut: boolean;
232
+ cancelled: boolean;
233
+ termination: ManagedExecTermination;
234
+ }
235
+
236
+ export declare type ManagedExecTermination = {
237
+ status: 'not-required';
238
+ } | {
239
+ status: 'not-started';
240
+ } | {
241
+ status: 'backend-retired';
242
+ mode: 'vm-close';
243
+ } | {
244
+ status: 'recovery-required';
245
+ reason: 'backend-retirement-failed';
246
+ };
191
247
 
192
248
  export declare interface ManagedVm {
193
249
  vm: VM;
@@ -211,11 +267,10 @@ export declare function prepareBrokeredHttpSecrets(options: {
211
267
  occupiedGuestEnvNames?: readonly string[];
212
268
  }): Record<string, SecretDefinition>;
213
269
 
214
- export declare interface ProviderAuthSource {
215
- /** Read the auth blob from the host, or return null when absent. */
216
- load(): string | null;
217
- /** Absolute guest path to write the blob to (mode 0600). */
218
- guestPath: string;
270
+ declare interface RequestOrigin {
271
+ hostname: string;
272
+ protocol: string;
273
+ port: number;
219
274
  }
220
275
 
221
276
  export declare function resolveVfsShadowConfig(config: SandboxConfig | undefined): {
@@ -259,44 +314,6 @@ export declare interface ResumeCommandWhen {
259
314
  */
260
315
  export declare function resumeVm(config: VmConfig): Promise<ManagedVm>;
261
316
 
262
- /**
263
- * Rewrite host-absolute paths inside an agent gitconfig to VM-local
264
- * equivalents before injecting it into the guest.
265
- *
266
- * Two rewrites:
267
- * - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
268
- * - `... credential-helper --credentials <host moltnet.json>`
269
- * → `<vmAgentDir>/moltnet.json`
270
- *
271
- * The credential-helper line is generated host-side by `moltnet github setup`
272
- * with a host-absolute `--credentials` path; inside the guest that path is
273
- * invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
274
- * rewrite rule and every other line are workspace-independent and pass through
275
- * unchanged. A gitconfig without a credential helper is rewritten only for
276
- * `signingKey`.
277
- *
278
- * This is the single source of truth for git push auth in the guest: the
279
- * injected gitconfig carries the tokenless mint-on-demand helper, so the VM
280
- * no longer hand-rolls a credential-helper script or runs an imperative
281
- * `git config --global ... insteadOf` against the guest $HOME.
282
- */
283
- export declare function rewriteGitconfigPaths(gitconfig: string, vmSshDir: string, vmAgentDir: string): string;
284
-
285
- /**
286
- * Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
287
- *
288
- * Fields rewritten:
289
- * ssh.private_key_path → <vmSshDir>/<basename of original>
290
- * ssh.public_key_path → <vmSshDir>/<basename of original>
291
- * git.config_path → <vmAgentDir>/gitconfig
292
- * github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
293
- *
294
- * All other fields are passed through unchanged.
295
- * Throws if moltnetJson is not valid JSON — callers must not inject a broken
296
- * moltnet.json into the guest.
297
- */
298
- export declare function rewriteMoltnetJsonPaths(moltnetJson: string, vmAgentDir: string, vmSshDir: string, githubAppPemFilename: string | null): string;
299
-
300
317
  export declare interface SandboxConfig {
301
318
  /**
302
319
  * Operator-owned snapshot build settings. Runtime profiles must never
@@ -386,12 +403,6 @@ export declare interface VmConfig {
386
403
  checkpointPath: string;
387
404
  /** MoltNet agent name (used to resolve credentials). */
388
405
  agentName: string;
389
- /**
390
- * Trust boundary for guest credentials. `guest-config` injects the complete
391
- * legacy agent directory. `host-authenticated` never reads or injects it and
392
- * relies exclusively on the supplied host-side Agent for MoltNet operations.
393
- */
394
- guestCredentialMode?: GuestCredentialMode;
395
406
  /**
396
407
  * Host root that owns `.moltnet/<agentName>/`.
397
408
  *
@@ -438,43 +449,21 @@ export declare interface VmConfig {
438
449
  onDiagnostic?: (diagnostic: VmDiagnostic) => void;
439
450
  /** Abort resume/setup work, closing any live VM owned by resumeVm. */
440
451
  signal?: AbortSignal;
441
- /**
442
- * Coding-agent provider authentication to place in the guest (for example
443
- * Pi's `auth.json`). The sandbox knows nothing about the provider: the
444
- * runtime above it supplies the loader and the guest path. When omitted,
445
- * nothing is written and the guest relies on forwarded env providers.
446
- */
447
- providerAuth?: ProviderAuthSource;
448
452
  }
449
453
 
450
454
  export declare interface VmCredentials {
451
- /** Empty in host-authenticated mode; retained as strings for API stability. */
452
- moltnetJson: string;
453
- /** Empty in host-authenticated mode; retained as strings for API stability. */
454
- agentEnvRaw: string;
455
455
  /**
456
- * Provider auth blob supplied by `VmConfig.providerAuth`, or null when the
457
- * runtime supplied none or the host file is absent — the guest then relies
458
- * on env-var providers (`ANTHROPIC_API_KEY`, etc.) carried via `agentEnv`
459
- * and the forwarded host environment instead. CI uses this path.
456
+ * Guest env carried across the boundary. Always empty: the guest receives no
457
+ * MoltNet credential material. Retained for API stability and because
458
+ * consumers read (empty) provider/diary/team hints from it.
460
459
  */
461
- providerAuthJson: string | null;
462
460
  agentEnv: Record<string, string | undefined>;
463
- gitconfig: string | null;
464
- sshPrivateKey: string | null;
465
- sshPublicKey: string | null;
466
- allowedSigners: string | null;
467
- /** Raw PEM content of the GitHub App private key, or null if not configured. */
468
- githubAppPem: string | null;
469
- /** VM-local filename for the GitHub App PEM (basename of host path), or null. */
470
- githubAppPemFilename: string | null;
471
461
  }
472
462
 
473
463
  export declare interface VmDiagnostic {
474
- event: 'vm.credentials.mode' | 'vm.credentials.github_key_missing' | 'vm.http_secrets.bound' | 'vm.host_origins.bound' | 'vm.guest_projection.applied' | 'vm.guest_service.not_ready';
464
+ event: 'vm.credentials.mode' | 'vm.http_secrets.bound' | 'vm.host_origins.bound' | 'vm.guest_projection.applied' | 'vm.guest_service.not_ready' | 'vm.network.origin_denied';
475
465
  level: 'info' | 'warning';
476
466
  message: string;
477
- credentialMode: GuestCredentialMode;
478
467
  /** Present only for the value-free broker summary event. */
479
468
  brokeredSecretCount?: number;
480
469
  /** Present only for the host-origins summary event. */
@@ -482,6 +471,13 @@ export declare interface VmDiagnostic {
482
471
  /** Present only for the guest-projection summary event. */
483
472
  projectedFileCount?: number;
484
473
  projectedServiceCount?: number;
474
+ /** Present only for an exact-origin denial event. */
475
+ origin?: {
476
+ hostname: string;
477
+ protocol: string;
478
+ port: number;
479
+ phase: 'request' | 'ip';
480
+ };
485
481
  }
486
482
 
487
483
  export { }
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import path, { isAbsolute, relative } from "node:path";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
5
5
  import { MemoryProvider, RealFSProvider, ShadowProvider, VM, VmCheckpoint, createHttpHooks, createShadowPathPredicate, ensureImageSelector, isWriteFlag, loadGuestAssets } from "@earendil-works/gondolin";
6
- import { parseEnv } from "node:util";
6
+ import { isIP } from "node:net";
7
7
  //#region src/abort-utils.ts
8
8
  function throwIfAborted(signal, label) {
9
9
  if (!signal?.aborted) return;
@@ -61,6 +61,138 @@ async function delay(ms, signal, label) {
61
61
  });
62
62
  }
63
63
  //#endregion
64
+ //#region src/managed-exec.ts
65
+ function chunkBuffer(data) {
66
+ if (Buffer.isBuffer(data)) return data;
67
+ return typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
68
+ }
69
+ async function retireBackend(vm, trigger, onDiagnostic) {
70
+ onDiagnostic?.({
71
+ event: "managed_exec.backend_retirement",
72
+ trigger,
73
+ outcome: "started",
74
+ message: "retiring Gondolin VM after interrupted guest execution"
75
+ });
76
+ try {
77
+ await vm.close();
78
+ onDiagnostic?.({
79
+ event: "managed_exec.backend_retirement",
80
+ trigger,
81
+ outcome: "succeeded",
82
+ message: "Gondolin VM retirement completed"
83
+ });
84
+ return {
85
+ status: "backend-retired",
86
+ mode: "vm-close"
87
+ };
88
+ } catch (error) {
89
+ onDiagnostic?.({
90
+ event: "managed_exec.backend_retirement",
91
+ trigger,
92
+ outcome: "failed",
93
+ message: error instanceof Error ? `Gondolin VM retirement failed: ${error.message}` : "Gondolin VM retirement failed"
94
+ });
95
+ return {
96
+ status: "recovery-required",
97
+ reason: "backend-retirement-failed"
98
+ };
99
+ }
100
+ }
101
+ /**
102
+ * Execute one login-shell command in a Gondolin VM. Successful commands may
103
+ * reuse the VM. Timeout or cancellation retires the complete VM through the
104
+ * host API before returning, because no guest process/session boundary can
105
+ * prove containment against daemonization or a guest-mutated root filesystem.
106
+ */
107
+ async function execManagedCommand(vm, command, options = {}) {
108
+ if (options.signal?.aborted) return {
109
+ exitCode: 130,
110
+ timedOut: false,
111
+ cancelled: true,
112
+ termination: { status: "not-started" }
113
+ };
114
+ const controller = new AbortController();
115
+ let timedOut = false;
116
+ let cancelled = false;
117
+ let executionSettled = false;
118
+ let acceptOutput = true;
119
+ let resolveInterrupted;
120
+ let retirementPromise;
121
+ const interrupted = new Promise((resolve) => {
122
+ resolveInterrupted = resolve;
123
+ });
124
+ const interrupt = (trigger) => {
125
+ if (executionSettled || retirementPromise) return;
126
+ acceptOutput = false;
127
+ controller.abort(/* @__PURE__ */ new Error(`guest command interrupted by ${trigger}`));
128
+ retirementPromise = retireBackend(vm, trigger, options.onDiagnostic);
129
+ resolveInterrupted?.();
130
+ };
131
+ const onAbort = () => {
132
+ cancelled = true;
133
+ interrupt("cancellation");
134
+ };
135
+ options.signal?.addEventListener("abort", onAbort, { once: true });
136
+ const timer = options.timeoutMs !== void 0 && options.timeoutMs > 0 ? setTimeout(() => {
137
+ timedOut = true;
138
+ interrupt("timeout");
139
+ }, options.timeoutMs) : void 0;
140
+ timer?.unref();
141
+ try {
142
+ const process = vm.exec([
143
+ "/bin/sh",
144
+ "-lc",
145
+ command
146
+ ], {
147
+ cwd: options.cwd,
148
+ env: options.env,
149
+ signal: controller.signal,
150
+ stdout: "pipe",
151
+ stderr: "pipe"
152
+ });
153
+ options.onStarted?.();
154
+ const execution = (async () => {
155
+ for await (const chunk of process.output()) {
156
+ if (!acceptOutput) continue;
157
+ options.onData?.(chunkBuffer(chunk.data), chunk.stream);
158
+ }
159
+ return process;
160
+ })();
161
+ const observedExecution = execution.then((result) => {
162
+ executionSettled = true;
163
+ return {
164
+ kind: "result",
165
+ result
166
+ };
167
+ }, (error) => {
168
+ executionSettled = true;
169
+ return {
170
+ kind: "error",
171
+ error
172
+ };
173
+ });
174
+ const first = await Promise.race([observedExecution, interrupted.then(() => ({ kind: "interrupted" }))]);
175
+ if (first.kind === "result" && !timedOut && !cancelled) return {
176
+ exitCode: first.result.exitCode,
177
+ timedOut: false,
178
+ cancelled: false,
179
+ termination: { status: "not-required" }
180
+ };
181
+ if (first.kind === "error" && !timedOut && !cancelled) throw first.error;
182
+ execution.catch(() => void 0);
183
+ const termination = await (retirementPromise ??= retireBackend(vm, timedOut ? "timeout" : "cancellation", options.onDiagnostic));
184
+ return {
185
+ exitCode: timedOut ? 124 : 130,
186
+ timedOut,
187
+ cancelled: cancelled && !timedOut,
188
+ termination
189
+ };
190
+ } finally {
191
+ if (timer) clearTimeout(timer);
192
+ options.signal?.removeEventListener("abort", onAbort);
193
+ }
194
+ }
195
+ //#endregion
64
196
  //#region src/path-containment.ts
65
197
  /**
66
198
  * Check containment for already-resolved lexical or real paths.
@@ -432,59 +564,8 @@ function resolveVmAgentDir(config) {
432
564
  const rootDir = config.agentRootDir ?? findMainWorktree();
433
565
  return path.join(rootDir, ".moltnet", config.agentName);
434
566
  }
435
- function loadCredentials(agentDir, mode = "guest-config", onDiagnostic, providerAuth) {
436
- const moltnetPath = path.join(agentDir, "moltnet.json");
437
- const agentEnvPath = path.join(agentDir, "env");
438
- const providerAuthJson = providerAuth?.load() ?? null;
439
- if (mode === "host-authenticated") return {
440
- moltnetJson: "",
441
- agentEnvRaw: "",
442
- providerAuthJson,
443
- agentEnv: {},
444
- gitconfig: null,
445
- sshPrivateKey: null,
446
- sshPublicKey: null,
447
- allowedSigners: null,
448
- githubAppPem: null,
449
- githubAppPemFilename: null
450
- };
451
- const hasMoltnetJson = existsSync(moltnetPath);
452
- const hasAgentEnv = existsSync(agentEnvPath);
453
- if (!hasMoltnetJson || !hasAgentEnv) throw new Error(`Guest credential mode requires both ${moltnetPath} and ${agentEnvPath}`);
454
- const moltnetJson = readFileSync(moltnetPath, "utf8");
455
- const agentEnvRaw = readFileSync(agentEnvPath, "utf8");
456
- if (moltnetJson.trim() === "") throw new Error(`Agent configuration is empty: ${moltnetPath}`);
457
- const gitconfigPath = path.join(agentDir, "gitconfig");
458
- const gitconfig = existsSync(gitconfigPath) ? readFileSync(gitconfigPath, "utf8") : null;
459
- const sshDir = path.join(agentDir, "ssh");
460
- const sshPrivateKey = existsSync(path.join(sshDir, "id_ed25519")) ? readFileSync(path.join(sshDir, "id_ed25519"), "utf8") : null;
461
- const sshPublicKey = existsSync(path.join(sshDir, "id_ed25519.pub")) ? readFileSync(path.join(sshDir, "id_ed25519.pub"), "utf8") : null;
462
- const allowedSigners = existsSync(path.join(sshDir, "allowed_signers")) ? readFileSync(path.join(sshDir, "allowed_signers"), "utf8") : null;
463
- let githubAppPem = null;
464
- let githubAppPemFilename = null;
465
- const pemPath = (moltnetJson ? JSON.parse(moltnetJson) : null)?.github?.private_key_path;
466
- if (pemPath) if (!existsSync(pemPath)) onDiagnostic?.({
467
- event: "vm.credentials.github_key_missing",
468
- level: "warning",
469
- credentialMode: mode,
470
- message: `github.private_key_path not found at ${pemPath}; moltnet github token will fail inside the guest`
471
- });
472
- else {
473
- githubAppPem = readFileSync(pemPath, "utf8");
474
- githubAppPemFilename = path.basename(pemPath);
475
- }
476
- return {
477
- moltnetJson,
478
- agentEnvRaw,
479
- providerAuthJson,
480
- agentEnv: parseEnv(agentEnvRaw),
481
- gitconfig,
482
- sshPrivateKey,
483
- sshPublicKey,
484
- allowedSigners,
485
- githubAppPem,
486
- githubAppPemFilename
487
- };
567
+ function loadCredentials() {
568
+ return { agentEnv: {} };
488
569
  }
489
570
  /**
490
571
  * Apply agent env vars to the host process, mirroring `moltnet start`.
@@ -580,9 +661,13 @@ var OBJECT_META_PROPERTY_NAMES = new Set([
580
661
  "prototype"
581
662
  ]);
582
663
  function normalizeHostPattern(pattern) {
583
- return pattern.trim().toLowerCase();
664
+ let normalized = pattern.trim().toLowerCase();
665
+ if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
666
+ if (normalized.endsWith(".")) normalized = normalized.slice(0, -1);
667
+ return normalized;
584
668
  }
585
669
  function isValidHostPattern(pattern) {
670
+ if (isIP(pattern) !== 0) return true;
586
671
  return pattern !== "" && !pattern.includes("://") && !pattern.includes("/") && !pattern.includes(":") && !/\s/.test(pattern);
587
672
  }
588
673
  /**
@@ -617,8 +702,8 @@ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
617
702
  });
618
703
  }
619
704
  function hostMatchesPattern(hostname, pattern) {
620
- const expression = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
621
- return new RegExp(`^${expression}$`, "i").test(hostname);
705
+ const expression = normalizeHostPattern(pattern).split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
706
+ return new RegExp(`^${expression}$`, "i").test(normalizeHostPattern(hostname));
622
707
  }
623
708
  /**
624
709
  * Conservative subset check for Gondolin hostname globs. Exact secret hosts
@@ -690,6 +775,19 @@ function headersContainSecretValue(headers, value) {
690
775
  }
691
776
  return false;
692
777
  }
778
+ function parseRequestOrigin(url) {
779
+ try {
780
+ const parsed = new URL(url);
781
+ const protocol = parsed.protocol.slice(0, -1);
782
+ return {
783
+ hostname: normalizeHostPattern(parsed.hostname),
784
+ protocol,
785
+ port: parsed.port ? Number(parsed.port) : protocol === "https" ? 443 : 80
786
+ };
787
+ } catch {
788
+ return null;
789
+ }
790
+ }
693
791
  function createBrokeredHttpSecretOriginPolicy(bindings) {
694
792
  const entries = /* @__PURE__ */ new Map();
695
793
  for (const binding of bindings) {
@@ -706,17 +804,11 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
706
804
  }
707
805
  return {
708
806
  isRequestAllowed(request) {
709
- let url;
710
- try {
711
- url = new URL(request.url);
712
- } catch {
713
- return false;
714
- }
715
- const protocol = url.protocol.slice(0, -1);
716
- const port = url.port ? Number(url.port) : protocol === "https" ? 443 : 80;
807
+ const requestOrigin = parseRequestOrigin(request.url);
808
+ if (!requestOrigin) return false;
717
809
  for (const entry of entries.values()) {
718
810
  if (entry.deleted || !headersContainSecretValue(request.headers, entry.value)) continue;
719
- if (protocol !== entry.protocol || !entry.ports.includes(port) || !entry.hosts.some((host) => hostMatchesPattern(url.hostname, host))) return false;
811
+ if (requestOrigin.protocol !== entry.protocol || !entry.ports.includes(requestOrigin.port) || !entry.hosts.some((host) => hostMatchesPattern(requestOrigin.hostname, host))) return false;
720
812
  }
721
813
  return true;
722
814
  },
@@ -733,18 +825,51 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
733
825
  }
734
826
  };
735
827
  }
828
+ /**
829
+ * Refine Gondolin's hostname allowlist for hosts carrying brokered
830
+ * credentials. The built-in allowlist is hostname-granular; these callbacks
831
+ * enforce the descriptor protocol and port before request or IP dispatch.
832
+ */
833
+ function createBrokeredHttpNetworkOriginPolicy(bindings, options = {}) {
834
+ const origins = bindings.map(canonicalizeBrokeredHttpSecretDescriptor);
835
+ const isAllowed = (input) => {
836
+ const hostname = normalizeHostPattern(input.hostname);
837
+ const matching = origins.filter((origin) => origin.hosts.some((host) => hostMatchesPattern(hostname, host)));
838
+ if (matching.length === 0) return true;
839
+ const allowed = matching.some((origin) => origin.protocol === input.protocol && origin.ports.includes(input.port));
840
+ if (!allowed) options.onDenied?.({
841
+ hostname,
842
+ protocol: input.protocol,
843
+ port: input.port,
844
+ phase: input.phase
845
+ });
846
+ return allowed;
847
+ };
848
+ return {
849
+ isRequestAllowed(request) {
850
+ const requestOrigin = parseRequestOrigin(request.url);
851
+ return requestOrigin ? isAllowed({
852
+ ...requestOrigin,
853
+ phase: "request"
854
+ }) : false;
855
+ },
856
+ isIpAllowed(info) {
857
+ return isAllowed({
858
+ ...info,
859
+ phase: "ip"
860
+ });
861
+ }
862
+ };
863
+ }
736
864
  function assertGuestEnvironmentBoundary(options) {
737
- const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) || options.guestCredentialMode === "host-authenticated" && !HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST.has(name));
865
+ const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) || !HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST.has(name));
738
866
  const refusedSandboxEnv = Object.keys(options.sandboxEnv ?? {}).filter(isReservedGuestEnvironmentName);
739
867
  const refused = [...new Set([...refusedForwardEnv, ...refusedSandboxEnv])].sort();
740
868
  if (refused.length > 0) throw new GuestEnvironmentBoundaryError(refused);
741
869
  }
742
- /** @deprecated Prefer assertGuestEnvironmentBoundary for mode-aware checks. */
870
+ /** @deprecated Alias of assertGuestEnvironmentBoundary (a single boundary). */
743
871
  function assertHostAuthenticatedGuestEnvironment(options) {
744
- assertGuestEnvironmentBoundary({
745
- guestCredentialMode: "host-authenticated",
746
- ...options
747
- });
872
+ assertGuestEnvironmentBoundary(options);
748
873
  }
749
874
  /**
750
875
  * Return whether two Gondolin hostname globs can match at least one common
@@ -821,22 +946,17 @@ async function resumeVm(config) {
821
946
  throwIfAborted(config.signal, "VM resume");
822
947
  const agentDir = resolveVmAgentDir(config);
823
948
  const guestWorkspace = path.resolve(config.mountPath);
824
- const guestCredentialMode = config.guestCredentialMode ?? "guest-config";
825
- if (guestCredentialMode === "guest-config" && !existsSync(agentDir)) throw new Error(`Agent directory not found: ${agentDir}. Run: moltnet register --name ${config.agentName}`);
826
949
  assertGuestEnvironmentBoundary({
827
- guestCredentialMode,
828
950
  forwardEnv: config.forwardEnv,
829
951
  sandboxEnv: config.sandboxConfig?.env
830
952
  });
831
953
  config.onDiagnostic?.({
832
954
  event: "vm.credentials.mode",
833
955
  level: "info",
834
- credentialMode: guestCredentialMode,
835
- message: guestCredentialMode === "host-authenticated" ? "MoltNet agent files and non-allowlisted host environment variables are withheld from the guest" : "The complete MoltNet agent configuration is available to the guest"
956
+ message: "MoltNet agent files and non-allowlisted host environment variables are withheld from the guest"
836
957
  });
837
- const creds = loadCredentials(agentDir, guestCredentialMode, config.onDiagnostic, config.providerAuth);
838
- const configuredApiUrl = creds.moltnetJson ? JSON.parse(creds.moltnetJson).endpoints.api : void 0;
839
- const apiHost = new URL(configuredApiUrl ?? process.env.MOLTNET_API_URL ?? DEFAULT_MOLTNET_API_URL).hostname;
958
+ const creds = loadCredentials();
959
+ const apiHost = new URL(process.env.MOLTNET_API_URL ?? DEFAULT_MOLTNET_API_URL).hostname;
840
960
  const runtimeAllowedHosts = config.sandboxConfig?.network?.allowedHosts ?? [];
841
961
  const runtimeAllowedInternalHosts = config.sandboxConfig?.network?.allowedInternalHosts ?? [];
842
962
  const protectedExternalHosts = [...new Set([
@@ -867,12 +987,21 @@ async function resumeVm(config) {
867
987
  ]
868
988
  });
869
989
  const brokeredSecretOriginPolicy = createBrokeredHttpSecretOriginPolicy(config.brokeredSecrets ?? []);
990
+ const brokeredNetworkOriginPolicy = createBrokeredHttpNetworkOriginPolicy(config.brokeredSecrets ?? [], { onDenied(origin) {
991
+ config.onDiagnostic?.({
992
+ event: "vm.network.origin_denied",
993
+ level: "warning",
994
+ message: "denied request outside a brokered credential origin",
995
+ origin
996
+ });
997
+ } });
870
998
  const { httpHooks, env: secretEnv, secretManager: gondolinSecretManager } = createHttpHooks({
871
999
  allowedHosts,
872
1000
  allowedInternalHosts,
873
1001
  ...Object.keys(brokeredSecrets).length > 0 && {
874
1002
  secrets: brokeredSecrets,
875
- isRequestAllowed: brokeredSecretOriginPolicy.isRequestAllowed
1003
+ isRequestAllowed: (request) => brokeredNetworkOriginPolicy.isRequestAllowed(request) && brokeredSecretOriginPolicy.isRequestAllowed(request),
1004
+ isIpAllowed: brokeredNetworkOriginPolicy.isIpAllowed
876
1005
  },
877
1006
  ...hostOriginHosts.length > 0 && { onRequest: createHostOriginsOnRequest(hostOrigins) }
878
1007
  });
@@ -890,26 +1019,15 @@ async function resumeVm(config) {
890
1019
  if (brokeredSecretCount > 0) config.onDiagnostic?.({
891
1020
  event: "vm.http_secrets.bound",
892
1021
  level: "info",
893
- credentialMode: guestCredentialMode,
894
1022
  brokeredSecretCount,
895
1023
  message: `Bound ${brokeredSecretCount} HTTP secret placeholder${brokeredSecretCount === 1 ? "" : "s"} to the host proxy`
896
1024
  });
897
1025
  if (hostOriginHosts.length > 0) config.onDiagnostic?.({
898
1026
  event: "vm.host_origins.bound",
899
1027
  level: "info",
900
- credentialMode: guestCredentialMode,
901
1028
  hostOriginCount: hostOriginHosts.length,
902
1029
  message: `Bound ${hostOriginHosts.length} host origin${hostOriginHosts.length === 1 ? "" : "s"} to the host proxy`
903
1030
  });
904
- const vmAgentDir = `/home/agent/.moltnet/${config.agentName}`;
905
- const vmAgentEnv = {};
906
- for (const [k, v] of Object.entries(creds.agentEnv)) {
907
- if (v === void 0 || v === "") continue;
908
- if (k === "GIT_CONFIG_GLOBAL") vmAgentEnv[k] = `${vmAgentDir}/gitconfig`;
909
- else if (k.endsWith("_PRIVATE_KEY_PATH")) vmAgentEnv[k] = `${vmAgentDir}/${path.basename(v)}`;
910
- else vmAgentEnv[k] = v;
911
- }
912
- if (creds.moltnetJson) vmAgentEnv.MOLTNET_CREDENTIALS_PATH = `${vmAgentDir}/moltnet.json`;
913
1031
  const vfsConfig = resolveVfsShadowConfig(config.sandboxConfig);
914
1032
  let workspaceProvider = new RealFSProvider(config.mountPath);
915
1033
  workspaceProvider = new ShadowProvider(workspaceProvider, {
@@ -925,7 +1043,7 @@ async function resumeVm(config) {
925
1043
  writeMode: vfsConfig.mode
926
1044
  });
927
1045
  }
928
- if (guestCredentialMode === "host-authenticated") workspaceProvider = new ShadowProvider(workspaceProvider, {
1046
+ workspaceProvider = new ShadowProvider(workspaceProvider, {
929
1047
  shouldShadow: ({ path: shadowPath }) => shadowPath.split("/").includes(".moltnet"),
930
1048
  denySymlinkBypass: true,
931
1049
  writeMode: "deny"
@@ -938,7 +1056,6 @@ async function resumeVm(config) {
938
1056
  }
939
1057
  const envOverrides = config.sandboxConfig?.env ?? {};
940
1058
  const vmEnv = {
941
- ...vmAgentEnv,
942
1059
  ...forwardedEnv,
943
1060
  PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/go/bin",
944
1061
  HOME: "/home/agent",
@@ -1023,55 +1140,10 @@ async function resumeVm(config) {
1023
1140
  }
1024
1141
  if (lastErr) throw lastErr instanceof Error ? lastErr : new Error(nonErrorMessage(lastErr));
1025
1142
  }
1026
- const vmSshDir = `${vmAgentDir}/ssh`;
1027
- const hasAgentFiles = guestCredentialMode === "guest-config";
1028
- const providerAuthDir = config.providerAuth ? path.posix.dirname(config.providerAuth.guestPath) : null;
1029
1143
  const projectedFiles = config.guestProjection?.files ?? [];
1030
1144
  const projectedDirs = [...new Set(projectedFiles.map((file) => path.posix.dirname(file.path)))];
1031
- const guestDirs = [
1032
- ...hasAgentFiles ? [`${vmAgentDir}/ssh`] : [],
1033
- ...providerAuthDir ? [providerAuthDir] : [],
1034
- ...projectedDirs
1035
- ];
1145
+ const guestDirs = [...projectedDirs];
1036
1146
  if (guestDirs.length > 0) await vmRun(vm, "create guest directories", `mkdir -p ${guestDirs.map(shellQuote).join(" ")}`, config.signal);
1037
- if (creds.providerAuthJson !== null && config.providerAuth) await vm.fs.writeFile(config.providerAuth.guestPath, creds.providerAuthJson, {
1038
- mode: 384,
1039
- signal: config.signal
1040
- });
1041
- if (hasAgentFiles) {
1042
- const vmMoltnetJson = rewriteMoltnetJsonPaths(creds.moltnetJson, vmAgentDir, vmSshDir, creds.githubAppPemFilename);
1043
- await vm.fs.writeFile(`${vmAgentDir}/moltnet.json`, vmMoltnetJson, {
1044
- mode: 384,
1045
- signal: config.signal
1046
- });
1047
- await vm.fs.writeFile(`${vmAgentDir}/env`, creds.agentEnvRaw, {
1048
- mode: 384,
1049
- signal: config.signal
1050
- });
1051
- if (creds.gitconfig) {
1052
- const vmGitconfig = rewriteGitconfigPaths(creds.gitconfig, vmSshDir, vmAgentDir);
1053
- await vm.fs.writeFile(`${vmAgentDir}/gitconfig`, vmGitconfig, {
1054
- mode: 420,
1055
- signal: config.signal
1056
- });
1057
- }
1058
- if (creds.sshPrivateKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519`, creds.sshPrivateKey, {
1059
- mode: 384,
1060
- signal: config.signal
1061
- });
1062
- if (creds.sshPublicKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519.pub`, creds.sshPublicKey, {
1063
- mode: 420,
1064
- signal: config.signal
1065
- });
1066
- if (creds.allowedSigners) await vm.fs.writeFile(`${vmSshDir}/allowed_signers`, creds.allowedSigners, {
1067
- mode: 420,
1068
- signal: config.signal
1069
- });
1070
- if (creds.githubAppPem && creds.githubAppPemFilename) await vm.fs.writeFile(`${vmAgentDir}/${creds.githubAppPemFilename}`, creds.githubAppPem, {
1071
- mode: 384,
1072
- signal: config.signal
1073
- });
1074
- }
1075
1147
  for (const file of projectedFiles) {
1076
1148
  await vm.fs.writeFile(file.path, file.content, {
1077
1149
  mode: file.mode ?? 420,
@@ -1079,11 +1151,7 @@ async function resumeVm(config) {
1079
1151
  });
1080
1152
  if (file.mode !== void 0) await vmRun(vm, `chmod projected file ${file.path}`, `chmod ${file.mode.toString(8)} ${shellQuote(file.path)}`, config.signal);
1081
1153
  }
1082
- await vmRun(vm, "chown guest directories", `set -e; for d in ${[
1083
- "/home/agent/.pi",
1084
- ...hasAgentFiles ? ["/home/agent/.moltnet"] : [],
1085
- ...projectedDirs.filter((dir) => dir.startsWith("/home/agent/"))
1086
- ].map(shellQuote).join(" ")}; do if [ -e "$d" ]; then chown -R agent:agent "$d"; fi; done`, config.signal);
1154
+ await vmRun(vm, "chown guest directories", `set -e; for d in ${projectedDirs.filter((dir) => dir.startsWith("/home/agent/")).map(shellQuote).join(" ")}; do if [ -e "$d" ]; then chown -R agent:agent "$d"; fi; done`, config.signal);
1087
1155
  const projectedServices = config.guestProjection?.services ?? [];
1088
1156
  if (projectedServices.length > 0) await vmRun(vm, "create service pid directory", `mkdir -p ${GUEST_SERVICE_PID_DIR}`, config.signal);
1089
1157
  for (const service of projectedServices) {
@@ -1135,7 +1203,6 @@ async function resumeVm(config) {
1135
1203
  config.onDiagnostic?.({
1136
1204
  event: "vm.guest_service.not_ready",
1137
1205
  level: "warning",
1138
- credentialMode: guestCredentialMode,
1139
1206
  message: `Projected guest service "${service.id}" did not become ready (${service.readiness.path} absent or its process exited); continuing without it`
1140
1207
  });
1141
1208
  }
@@ -1144,7 +1211,6 @@ async function resumeVm(config) {
1144
1211
  if (projectedFiles.length > 0 || projectedServices.length > 0) config.onDiagnostic?.({
1145
1212
  event: "vm.guest_projection.applied",
1146
1213
  level: "info",
1147
- credentialMode: guestCredentialMode,
1148
1214
  projectedFileCount: projectedFiles.length,
1149
1215
  projectedServiceCount: projectedServices.length,
1150
1216
  message: `Applied guest projection: ${projectedFiles.length} file(s), ${projectedServices.length} service(s)`
@@ -1169,66 +1235,5 @@ async function resumeVm(config) {
1169
1235
  throw err;
1170
1236
  }
1171
1237
  }
1172
- /**
1173
- * Rewrite host-absolute paths inside an agent gitconfig to VM-local
1174
- * equivalents before injecting it into the guest.
1175
- *
1176
- * Two rewrites:
1177
- * - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
1178
- * - `... credential-helper --credentials <host moltnet.json>`
1179
- * → `<vmAgentDir>/moltnet.json`
1180
- *
1181
- * The credential-helper line is generated host-side by `moltnet github setup`
1182
- * with a host-absolute `--credentials` path; inside the guest that path is
1183
- * invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
1184
- * rewrite rule and every other line are workspace-independent and pass through
1185
- * unchanged. A gitconfig without a credential helper is rewritten only for
1186
- * `signingKey`.
1187
- *
1188
- * This is the single source of truth for git push auth in the guest: the
1189
- * injected gitconfig carries the tokenless mint-on-demand helper, so the VM
1190
- * no longer hand-rolls a credential-helper script or runs an imperative
1191
- * `git config --global ... insteadOf` against the guest $HOME.
1192
- */
1193
- function rewriteGitconfigPaths(gitconfig, vmSshDir, vmAgentDir) {
1194
- return gitconfig.replace(/signingKey\s*=\s*.+/g, `signingKey = ${vmSshDir}/id_ed25519`).replace(/(moltnet github credential-helper --credentials )\S+/g, `$1${vmAgentDir}/moltnet.json`);
1195
- }
1196
- /**
1197
- * Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
1198
- *
1199
- * Fields rewritten:
1200
- * ssh.private_key_path → <vmSshDir>/<basename of original>
1201
- * ssh.public_key_path → <vmSshDir>/<basename of original>
1202
- * git.config_path → <vmAgentDir>/gitconfig
1203
- * github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
1204
- *
1205
- * All other fields are passed through unchanged.
1206
- * Throws if moltnetJson is not valid JSON — callers must not inject a broken
1207
- * moltnet.json into the guest.
1208
- */
1209
- function rewriteMoltnetJsonPaths(moltnetJson, vmAgentDir, vmSshDir, githubAppPemFilename) {
1210
- const config = JSON.parse(moltnetJson);
1211
- if (config.ssh && typeof config.ssh === "object") {
1212
- const ssh = config.ssh;
1213
- const origPrivate = typeof ssh.private_key_path === "string" ? ssh.private_key_path : null;
1214
- const origPublic = typeof ssh.public_key_path === "string" ? ssh.public_key_path : null;
1215
- config.ssh = {
1216
- ...ssh,
1217
- ...origPrivate !== null && { private_key_path: `${vmSshDir}/${path.basename(origPrivate)}` },
1218
- ...origPublic !== null && { public_key_path: `${vmSshDir}/${path.basename(origPublic)}` }
1219
- };
1220
- }
1221
- if (config.git && typeof config.git === "object") {
1222
- const git = { ...config.git };
1223
- git.config_path = `${vmAgentDir}/gitconfig`;
1224
- config.git = git;
1225
- }
1226
- if (githubAppPemFilename && config.github && typeof config.github === "object") {
1227
- const github = { ...config.github };
1228
- github.private_key_path = `${vmAgentDir}/${githubAppPemFilename}`;
1229
- config.github = github;
1230
- }
1231
- return JSON.stringify(config);
1232
- }
1233
1238
  //#endregion
1234
- export { AutoParentMemoryProvider, BrokeredHttpSecretBoundaryError, GONDOLIN_BASE_EXECUTABLES, GUEST_SERVICE_PID_DIR, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, canonicalizeBrokeredHttpSecretDescriptor, delay, ensureSnapshot, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, prepareBrokeredHttpSecrets, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm, rewriteGitconfigPaths, rewriteMoltnetJsonPaths, shouldRunResumeCommand, shouldShadowNodeModulesPath, throwIfAborted };
1239
+ export { AutoParentMemoryProvider, BrokeredHttpSecretBoundaryError, GONDOLIN_BASE_EXECUTABLES, GUEST_SERVICE_PID_DIR, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, canonicalizeBrokeredHttpSecretDescriptor, createBrokeredHttpNetworkOriginPolicy, delay, ensureSnapshot, execManagedCommand, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, prepareBrokeredHttpSecrets, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm, shouldRunResumeCommand, shouldShadowNodeModulesPath, throwIfAborted };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/sandbox-gondolin",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Gondolin microVM sandbox lifecycle for MoltNet runtimes: checkpoint resume, egress policy, VFS shadowing, guest credential boundary, host-brokered secrets",
5
5
  "keywords": [
6
6
  "moltnet",
@@ -47,7 +47,7 @@
47
47
  }
48
48
  },
49
49
  "dependencies": {
50
- "@earendil-works/gondolin": "^0.9.1"
50
+ "@earendil-works/gondolin": "^0.12.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.19.0",