@loucompanion/forge-bridge 0.1.3-dev.b3bce6ba65a6 → 0.1.3-dev.ba7d36726eda

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.
Files changed (118) hide show
  1. package/README.md +78 -0
  2. package/dist/bridge/bridge.base.d.ts +5 -1
  3. package/dist/bridge/bridge.service.d.ts +52 -3
  4. package/dist/bridge/bridge.service.js +776 -19
  5. package/dist/bridge/bridge.types.d.ts +131 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.js +46 -0
  14. package/dist/cli/cli.service.d.ts +42 -2
  15. package/dist/cli/cli.service.js +350 -23
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +39 -0
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +15 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +50 -0
  20. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  22. package/dist/runtime/attempt-process-supervisor.d.ts +74 -0
  23. package/dist/runtime/attempt-process-supervisor.js +290 -0
  24. package/dist/runtime/host-listener-inspector.d.ts +28 -0
  25. package/dist/runtime/host-listener-inspector.js +185 -0
  26. package/dist/runtime/host-port-broker.d.ts +76 -0
  27. package/dist/runtime/host-port-broker.js +333 -0
  28. package/dist/runtime/host-runtime-contract.d.ts +39 -0
  29. package/dist/runtime/host-runtime-contract.js +295 -0
  30. package/dist/runtime/host-runtime-lease.d.ts +122 -0
  31. package/dist/runtime/host-runtime-lease.js +1241 -0
  32. package/dist/runtime/host-runtime-promotion.d.ts +21 -0
  33. package/dist/runtime/host-runtime-promotion.js +128 -0
  34. package/dist/runtime/node-host-runtime-adapter.d.ts +44 -0
  35. package/dist/runtime/node-host-runtime-adapter.js +205 -0
  36. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  37. package/dist/runtime/playwright-host-runtime-adapter.js +136 -0
  38. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  39. package/dist/runtime/runtime-isolation-lease.js +158 -0
  40. package/dist/sandbox/command-runner.d.ts +4 -0
  41. package/dist/sandbox/command-runner.js +47 -0
  42. package/dist/sandbox/contract.d.ts +5 -0
  43. package/dist/sandbox/contract.js +276 -0
  44. package/dist/sandbox/event-budget.d.ts +11 -0
  45. package/dist/sandbox/event-budget.js +27 -0
  46. package/dist/sandbox/image-policy.d.ts +35 -0
  47. package/dist/sandbox/image-policy.js +247 -0
  48. package/dist/sandbox/index.d.ts +5 -0
  49. package/dist/sandbox/index.js +5 -0
  50. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  51. package/dist/sandbox/lease-watchdog.js +67 -0
  52. package/dist/sandbox/preflight.d.ts +45 -0
  53. package/dist/sandbox/preflight.js +300 -0
  54. package/dist/sandbox/process.d.ts +20 -0
  55. package/dist/sandbox/process.js +209 -0
  56. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  57. package/dist/sandbox/provider-auth/codex.js +212 -0
  58. package/dist/sandbox/repository-lock.d.ts +5 -0
  59. package/dist/sandbox/repository-lock.js +119 -0
  60. package/dist/sandbox/runtime.d.ts +297 -0
  61. package/dist/sandbox/runtime.js +2338 -0
  62. package/dist/sandbox/sandbox.types.d.ts +86 -0
  63. package/dist/sandbox/sandbox.types.js +1 -0
  64. package/dist/sandbox/smoke.d.ts +76 -0
  65. package/dist/sandbox/smoke.js +380 -0
  66. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  67. package/dist/sandbox/worker-supervisor.js +36 -0
  68. package/dist/sandbox/worker.d.ts +1 -0
  69. package/dist/sandbox/worker.js +225 -0
  70. package/dist/sandbox/workspace.service.d.ts +13 -0
  71. package/dist/sandbox/workspace.service.js +67 -0
  72. package/dist/session/phase-completion.artifacts.js +23 -2
  73. package/dist/session/phase-completion.helpers.js +6 -1
  74. package/dist/session/phase-completion.runtime.js +3 -1
  75. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  76. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  77. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  78. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  79. package/dist/session/provider-cli/provider-cli.deterministic.js +20 -9
  80. package/dist/session/provider-cli/provider-cli.process.d.ts +3 -0
  81. package/dist/session/provider-cli/provider-cli.process.js +65 -14
  82. package/dist/session/provider-cli/provider-cli.prompts.d.ts +14 -2
  83. package/dist/session/provider-cli/provider-cli.prompts.js +99 -15
  84. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  85. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  86. package/dist/session/provider-cli/provider-cli.service.d.ts +4 -1
  87. package/dist/session/provider-cli/provider-cli.service.js +167 -28
  88. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  89. package/dist/session/session-redaction.d.ts +3 -0
  90. package/dist/session/session-redaction.js +23 -0
  91. package/dist/session/session.base.d.ts +4 -0
  92. package/dist/session/session.service.d.ts +6 -1
  93. package/dist/session/session.service.js +184 -38
  94. package/dist/session/session.types.d.ts +58 -1
  95. package/dist/session/session.types.js +172 -2
  96. package/dist/shared/path/path.js +3 -1
  97. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  98. package/dist/shared/process/host-process-recovery.js +78 -0
  99. package/dist/shared/process/host-process-supervisor.d.ts +40 -0
  100. package/dist/shared/process/host-process-supervisor.js +195 -0
  101. package/dist/shared/provider-capabilities.d.ts +1 -0
  102. package/dist/shared/provider-capabilities.js +3 -0
  103. package/dist/shared/redaction/redaction.js +6 -1
  104. package/dist/version.d.ts +1 -1
  105. package/dist/version.js +1 -1
  106. package/dist/worktree/index.d.ts +1 -0
  107. package/dist/worktree/index.js +1 -0
  108. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  109. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  110. package/dist/worktree/worktree-paths.d.ts +4 -0
  111. package/dist/worktree/worktree-paths.js +30 -0
  112. package/dist/worktree/worktree.base.d.ts +3 -0
  113. package/dist/worktree/worktree.service.d.ts +6 -2
  114. package/dist/worktree/worktree.service.js +60 -43
  115. package/dist/worktree/worktree.types.d.ts +3 -1
  116. package/package.json +6 -3
  117. package/runtime/windows-job-supervisor.ps1 +287 -0
  118. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -0,0 +1,158 @@
1
+ export const runtimeIsolationLeaseStates = [
2
+ "reserved",
3
+ "starting",
4
+ "active",
5
+ "releasing",
6
+ "released",
7
+ "degraded",
8
+ ];
9
+ const allowedTransitions = {
10
+ reserved: new Set(["starting", "releasing", "degraded"]),
11
+ starting: new Set(["active", "releasing", "degraded"]),
12
+ active: new Set(["releasing", "degraded"]),
13
+ releasing: new Set(["released", "degraded"]),
14
+ released: new Set(),
15
+ degraded: new Set(["releasing"]),
16
+ };
17
+ export function createRuntimeIsolationLease(input) {
18
+ const createdAt = input.createdAt ?? new Date().toISOString();
19
+ const lease = {
20
+ schemaVersion: 1,
21
+ leaseId: input.leaseId,
22
+ dispatchAttemptId: input.dispatchAttemptId,
23
+ executionLeaseId: input.executionLeaseId,
24
+ fencingToken: input.fencingToken,
25
+ runnerId: input.runnerId,
26
+ runnerInstanceId: input.runnerInstanceId,
27
+ worktreeKey: input.worktreeKey,
28
+ worktreePath: input.worktreePath,
29
+ mode: input.mode,
30
+ state: "reserved",
31
+ createdAt,
32
+ stateChangedAt: createdAt,
33
+ expiresAt: input.expiresAt,
34
+ releasedAt: null,
35
+ resources: input.resources,
36
+ };
37
+ if (!validRuntimeIsolationLease(lease)) {
38
+ throw new Error("runtime_lease_invalid: lease identity, lifetime, or resources are invalid.");
39
+ }
40
+ return lease;
41
+ }
42
+ export function transitionRuntimeIsolationLease(lease, nextState, occurredAt = new Date().toISOString()) {
43
+ if (lease.state === nextState)
44
+ return lease;
45
+ if (!allowedTransitions[lease.state].has(nextState) || !validTimestamp(occurredAt)) {
46
+ throw new Error(`runtime_lease_transition_invalid: cannot transition ${lease.state} to ${nextState}.`);
47
+ }
48
+ const transitioned = {
49
+ ...lease,
50
+ state: nextState,
51
+ stateChangedAt: occurredAt,
52
+ releasedAt: nextState === "released" ? occurredAt : null,
53
+ };
54
+ if (!validRuntimeIsolationLease(transitioned)) {
55
+ throw new Error("runtime_lease_invalid: transitioned lease is invalid.");
56
+ }
57
+ return transitioned;
58
+ }
59
+ export function renewRuntimeIsolationLease(lease, fencingToken, expiresAt) {
60
+ if (lease.fencingToken !== fencingToken || lease.state === "released" || !validTimestamp(expiresAt))
61
+ return null;
62
+ const renewed = { ...lease, expiresAt };
63
+ return validRuntimeIsolationLease(renewed) ? renewed : null;
64
+ }
65
+ export function withRuntimeIsolationResources(lease, resources) {
66
+ if (lease.state === "released") {
67
+ throw new Error("runtime_lease_transition_invalid: released leases cannot acquire resources.");
68
+ }
69
+ const updated = { ...lease, resources };
70
+ if (!validRuntimeIsolationLease(updated)) {
71
+ throw new Error("runtime_lease_invalid: resources are invalid.");
72
+ }
73
+ return updated;
74
+ }
75
+ export function validRuntimeIsolationLease(value) {
76
+ if (!isRecord(value)
77
+ || value.schemaVersion !== 1
78
+ || !isUuid(value.leaseId)
79
+ || !isUuid(value.dispatchAttemptId)
80
+ || !isUuid(value.executionLeaseId)
81
+ || !isUuid(value.fencingToken)
82
+ || !isUuid(value.runnerId)
83
+ || !isUuid(value.runnerInstanceId)
84
+ || !boundedText(value.worktreeKey, 255)
85
+ || !boundedText(value.worktreePath, 4096)
86
+ || (value.mode !== "docker" && value.mode !== "host")
87
+ || !runtimeIsolationLeaseStates.includes(value.state)
88
+ || !validTimestamp(value.createdAt)
89
+ || !validTimestamp(value.stateChangedAt)
90
+ || !validTimestamp(value.expiresAt)
91
+ || Date.parse(value.stateChangedAt) < Date.parse(value.createdAt)
92
+ || Date.parse(value.expiresAt) < Date.parse(value.createdAt)
93
+ || (value.state === "released") !== validTimestamp(value.releasedAt)) {
94
+ return false;
95
+ }
96
+ if (value.releasedAt !== null && Date.parse(value.releasedAt) < Date.parse(value.createdAt))
97
+ return false;
98
+ return validResources(value.resources, value.mode);
99
+ }
100
+ function validResources(value, mode) {
101
+ if (!isRecord(value)
102
+ || !nonNegativeInteger(value.memoryReservationBytes)
103
+ || !nonNegativeInteger(value.cpuMillis)
104
+ || !nonNegativeInteger(value.pids)
105
+ || !Array.isArray(value.ports) || value.ports.length > 64
106
+ || !Array.isArray(value.paths) || value.paths.length > 64
107
+ || !Array.isArray(value.dockerResources) || value.dockerResources.length > 128
108
+ || !Array.isArray(value.singletonKeys) || value.singletonKeys.length > 64
109
+ || (value.processSupervisorId !== null && !boundedText(value.processSupervisorId, 255))) {
110
+ return false;
111
+ }
112
+ if (value.ports.some(port => !validPort(port))
113
+ || value.paths.some(entry => !isRecord(entry) || !boundedText(entry.path, 4096) || !boundedText(entry.role, 128))
114
+ || value.singletonKeys.some(key => !boundedText(key, 255))
115
+ || value.dockerResources.some(resource => !validDockerResource(resource))) {
116
+ return false;
117
+ }
118
+ return mode === "docker" || value.dockerResources.length === 0;
119
+ }
120
+ function validPort(value) {
121
+ return isRecord(value)
122
+ && boundedText(value.name, 128)
123
+ && (value.protocol === "tcp" || value.protocol === "udp")
124
+ && (value.hostIp === "127.0.0.1" || value.hostIp === "::1")
125
+ && validPortNumber(value.hostPort)
126
+ && validPortNumber(value.targetPort);
127
+ }
128
+ function validDockerResource(value) {
129
+ return isRecord(value)
130
+ && (value.type === "container" || value.type === "network" || value.type === "volume")
131
+ && boundedText(value.name, 255)
132
+ && boundedText(value.role, 128)
133
+ && (value.id === undefined || value.id === null || boundedText(value.id, 255));
134
+ }
135
+ function validPortNumber(value) {
136
+ return Number.isSafeInteger(value) && Number(value) >= 1 && Number(value) <= 65535;
137
+ }
138
+ function nonNegativeInteger(value) {
139
+ return Number.isSafeInteger(value) && Number(value) >= 0;
140
+ }
141
+ function validTimestamp(value) {
142
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
143
+ }
144
+ function boundedText(value, maximumLength) {
145
+ return typeof value === "string"
146
+ && value.length > 0
147
+ && value.length <= maximumLength
148
+ && Array.from(value).every((character) => {
149
+ const codePoint = character.codePointAt(0) ?? 0;
150
+ return codePoint > 31 && codePoint !== 127;
151
+ });
152
+ }
153
+ function isUuid(value) {
154
+ return typeof value === "string" && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu.test(value);
155
+ }
156
+ function isRecord(value) {
157
+ return typeof value === "object" && value !== null && !Array.isArray(value);
158
+ }
@@ -0,0 +1,4 @@
1
+ import type { CommandOptions, CommandResult, CommandRunner } from "./sandbox.types.js";
2
+ export declare class BoundedCommandRunner implements CommandRunner {
3
+ run(command: string, args: readonly string[], options?: CommandOptions): Promise<CommandResult>;
4
+ }
@@ -0,0 +1,47 @@
1
+ import { spawn } from "node:child_process";
2
+ export class BoundedCommandRunner {
3
+ async run(command, args, options = {}) {
4
+ const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
5
+ return await new Promise((resolve, reject) => {
6
+ const controller = new AbortController();
7
+ const abort = () => controller.abort(options.signal?.reason ?? "command cancelled");
8
+ options.signal?.addEventListener("abort", abort, { once: true });
9
+ const timeout = options.timeoutMs
10
+ ? setTimeout(() => controller.abort(`command exceeded ${options.timeoutMs}ms`), options.timeoutMs)
11
+ : null;
12
+ timeout?.unref?.();
13
+ const child = spawn(command, [...args], {
14
+ cwd: options.cwd,
15
+ env: options.env ? { ...options.env } : undefined,
16
+ stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
17
+ signal: controller.signal,
18
+ windowsHide: true,
19
+ });
20
+ let stdout = Buffer.alloc(0);
21
+ let stderr = Buffer.alloc(0);
22
+ const append = (current, chunk) => {
23
+ if (current.length >= maxOutputBytes)
24
+ return current;
25
+ return Buffer.concat([current, chunk.subarray(0, maxOutputBytes - current.length)]);
26
+ };
27
+ child.stdout.on("data", (chunk) => { stdout = append(stdout, chunk); });
28
+ child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
29
+ child.on("error", (error) => {
30
+ cleanup();
31
+ reject(error);
32
+ });
33
+ child.on("close", (code) => {
34
+ cleanup();
35
+ resolve({ exitCode: code ?? 1, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") });
36
+ });
37
+ if (options.stdin !== undefined) {
38
+ child.stdin.end(options.stdin);
39
+ }
40
+ function cleanup() {
41
+ if (timeout)
42
+ clearTimeout(timeout);
43
+ options.signal?.removeEventListener("abort", abort);
44
+ }
45
+ });
46
+ }
47
+ }
@@ -0,0 +1,5 @@
1
+ import type { ValidatedSandboxContract } from "./sandbox.types.js";
2
+ export declare function validateSandboxContract(source: string): ValidatedSandboxContract;
3
+ export declare function validateDockerfilePolicy(source: string, requirePinnedBase: boolean): readonly string[];
4
+ export declare function dockerfileBaseImages(source: string): readonly string[];
5
+ export declare function dockerfileUpstreamImages(source: string): readonly string[];
@@ -0,0 +1,276 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAlias, parseDocument, visit } from "yaml";
3
+ const maxContractBytes = 64 * 1024;
4
+ const maxDepth = 16;
5
+ const topLevelKeys = new Set(["schemaVersion", "workingDirectory", "commands", "caches", "services", "environment", "artifacts", "logs", "browser"]);
6
+ const forbiddenKeys = new Set([
7
+ "privileged", "cap_add", "capAdd", "devices", "device", "network_mode", "networkMode", "pid", "ipc",
8
+ "userns_mode", "usernsMode", "security_opt", "securityOpt", "volumes", "mounts", "binds", "dockerSocket",
9
+ "hostNetwork", "extra_hosts", "extraHosts", "ports", "secrets", "buildSecrets", "ssh", "entitlements"
10
+ ]);
11
+ const allowedCaches = new Set(["npm", "pnpm", "yarn", "nuget", "pip", "cargo", "gradle", "maven"]);
12
+ const defaultPorts = { postgres: 5432, redis: 6379, browser: 9222 };
13
+ export function validateSandboxContract(source) {
14
+ const bytes = Buffer.byteLength(source, "utf8");
15
+ if (bytes === 0 || bytes > maxContractBytes)
16
+ throw new Error(`contract_invalid: forge.sandbox.yml must be 1-${maxContractBytes} bytes.`);
17
+ if (containsForbiddenControlCharacter(source)) {
18
+ throw new Error("contract_invalid: control characters are not allowed.");
19
+ }
20
+ const document = parseDocument(source, { uniqueKeys: true, prettyErrors: false, strict: true });
21
+ if (document.errors.length > 0)
22
+ throw new Error(`contract_invalid: ${document.errors[0]?.message ?? "invalid YAML"}`);
23
+ visit(document, {
24
+ Node(_key, node) {
25
+ if (isAlias(node) || node.anchor)
26
+ throw new Error("contract_invalid: YAML aliases and anchors are not allowed.");
27
+ }
28
+ });
29
+ const parsed = document.toJS({ maxAliasCount: 0 });
30
+ assertDepth(parsed, 0);
31
+ const root = object(parsed, "contract");
32
+ rejectUnknownKeys(root, topLevelKeys, "contract");
33
+ rejectForbiddenKeys(root, "contract");
34
+ if (root.schemaVersion !== 1)
35
+ throw new Error("contract_invalid: schemaVersion must be 1.");
36
+ const workingDirectory = root.workingDirectory ?? "/workspace";
37
+ if (workingDirectory !== "/workspace")
38
+ throw new Error("contract_invalid: workingDirectory must be /workspace.");
39
+ const contract = {
40
+ schemaVersion: 1,
41
+ workingDirectory: "/workspace",
42
+ commands: parseCommands(root.commands),
43
+ caches: parseCaches(root.caches),
44
+ services: parseServices(root.services),
45
+ environment: stringArray(root.environment, "environment", 64).map(validateEnvironmentName),
46
+ artifacts: stringArray(root.artifacts, "artifacts", 128).map((value) => validateRelativePath(value, "artifacts")),
47
+ logs: stringArray(root.logs, "logs", 128).map((value) => validateRelativePath(value, "logs")),
48
+ browser: optionalBoolean(root.browser, "browser") ?? false,
49
+ };
50
+ const normalizedJson = canonicalJson(contract);
51
+ const digest = `sha256:${createHash("sha256").update(normalizedJson).digest("hex")}`;
52
+ return { contract, normalizedJson, digest };
53
+ }
54
+ function containsForbiddenControlCharacter(value) {
55
+ return Array.from(value).some((character) => {
56
+ const codePoint = character.codePointAt(0) ?? 0;
57
+ return codePoint === 0x7f
58
+ || (codePoint < 0x20 && codePoint !== 0x09 && codePoint !== 0x0a && codePoint !== 0x0d);
59
+ });
60
+ }
61
+ export function validateDockerfilePolicy(source, requirePinnedBase) {
62
+ if (Buffer.byteLength(source, "utf8") > 1024 * 1024)
63
+ throw new Error("contract_invalid: Dockerfile exceeds 1 MiB.");
64
+ if (containsForbiddenControlCharacter(source))
65
+ throw new Error("contract_invalid: Dockerfile control characters are not allowed.");
66
+ const logicalSource = source.replace(/\\\r?\n/gu, " ");
67
+ if (/^\s*#\s*syntax\s*=/imu.test(source)) {
68
+ throw new Error("contract_invalid: custom Dockerfile syntax frontends are forbidden.");
69
+ }
70
+ if (/^\s*#\s*escape\s*=/imu.test(source)) {
71
+ throw new Error("contract_invalid: custom Dockerfile escape directives are forbidden.");
72
+ }
73
+ if (/^\s*(?:ONBUILD\s+)?RUN\b[^\r\n]*--mount\s*=\s*type\s*=\s*(?:secret|ssh|bind)\b/imu.test(logicalSource)) {
74
+ throw new Error("contract_invalid: Dockerfile secret, SSH, and bind build mounts are forbidden.");
75
+ }
76
+ if (/^\s*(?:ONBUILD\s+)?RUN\b[^\r\n]*--(?:network\s*=\s*host|security\s*=\s*insecure|device(?:\s|=))/imu.test(logicalSource)) {
77
+ throw new Error("contract_invalid: Dockerfile host-network, insecure-security, and device build options are forbidden.");
78
+ }
79
+ if (/^\s*(?:ONBUILD\s+)?VOLUME(?:\s|\[)/imu.test(source)) {
80
+ throw new Error("contract_invalid: Dockerfile VOLUME instructions are forbidden; Forge owns writable mounts.");
81
+ }
82
+ const warnings = [];
83
+ for (const image of dockerfileUpstreamImages(source)) {
84
+ if (!/@sha256:[a-f0-9]{64}$/iu.test(image)) {
85
+ const message = `Upstream image '${image}' is not pinned by sha256 digest.`;
86
+ if (requirePinnedBase)
87
+ throw new Error(`contract_invalid: ${message}`);
88
+ warnings.push(message);
89
+ }
90
+ }
91
+ return warnings;
92
+ }
93
+ export function dockerfileBaseImages(source) {
94
+ if (Buffer.byteLength(source, "utf8") > 1024 * 1024)
95
+ throw new Error("contract_invalid: Dockerfile exceeds 1 MiB.");
96
+ const fromLines = source.replace(/\\\r?\n/gu, " ").split(/\r?\n/u)
97
+ .map((line) => line.trim())
98
+ .filter((line) => /^FROM\s+/iu.test(line));
99
+ if (fromLines.length === 0)
100
+ throw new Error("contract_invalid: .forge/Dockerfile must contain FROM.");
101
+ const stageAliases = new Set();
102
+ const externalImages = [];
103
+ for (const line of fromLines) {
104
+ const tokens = line.split(/\s+/u).slice(1);
105
+ const imageIndex = tokens.findIndex((token) => !token.startsWith("--"));
106
+ const image = imageIndex >= 0 ? tokens[imageIndex] ?? "" : "";
107
+ if (!image || /[\r\n\0]/u.test(image))
108
+ throw new Error("contract_invalid: Dockerfile FROM image is invalid.");
109
+ if (!stageAliases.has(image.toLowerCase()))
110
+ externalImages.push(image);
111
+ const asIndex = tokens.findIndex((token, index) => index > imageIndex && token.toUpperCase() === "AS");
112
+ const alias = asIndex >= 0 ? tokens[asIndex + 1] : undefined;
113
+ if (alias) {
114
+ if (!/^[a-zA-Z0-9_.-]{1,128}$/u.test(alias))
115
+ throw new Error("contract_invalid: Dockerfile stage alias is invalid.");
116
+ stageAliases.add(alias.toLowerCase());
117
+ }
118
+ }
119
+ return [...new Set(externalImages)];
120
+ }
121
+ export function dockerfileUpstreamImages(source) {
122
+ const bases = dockerfileBaseImages(source);
123
+ const logicalLines = source.replace(/\\\r?\n/gu, " ").split(/\r?\n/u).map(line => line.trim());
124
+ const stageAliases = new Set();
125
+ for (const line of logicalLines.filter(line => /^FROM\s+/iu.test(line))) {
126
+ const tokens = line.split(/\s+/u).slice(1);
127
+ const imageIndex = tokens.findIndex(token => !token.startsWith("--"));
128
+ const asIndex = tokens.findIndex((token, index) => index > imageIndex && token.toUpperCase() === "AS");
129
+ const alias = asIndex >= 0 ? tokens[asIndex + 1] : undefined;
130
+ if (alias)
131
+ stageAliases.add(alias.toLowerCase());
132
+ }
133
+ const copyImages = logicalLines
134
+ .filter(line => /^\s*(?:ONBUILD\s+)?COPY\b/iu.test(line))
135
+ .map(line => /(?:^|\s)--from=([^\s]+)/iu.exec(line)?.[1] ?? "")
136
+ .filter(image => image && !/^\d+$/u.test(image) && !stageAliases.has(image.toLowerCase()));
137
+ return [...new Set([...bases, ...copyImages])];
138
+ }
139
+ function parseCommands(value) {
140
+ if (value === undefined || value === null)
141
+ return {};
142
+ const commands = object(value, "commands");
143
+ if (Object.keys(commands).length > 32)
144
+ throw new Error("contract_invalid: commands cannot contain more than 32 entries.");
145
+ const result = {};
146
+ for (const [name, command] of Object.entries(commands)) {
147
+ if (!/^[a-z][a-z0-9_-]{0,63}$/u.test(name) || typeof command !== "string" || !command.trim() || command.length > 4096) {
148
+ throw new Error("contract_invalid: commands must map safe names to non-empty strings.");
149
+ }
150
+ result[name] = command.trim();
151
+ }
152
+ return result;
153
+ }
154
+ function parseCaches(value) {
155
+ if (value === undefined || value === null)
156
+ return [];
157
+ if (!Array.isArray(value))
158
+ throw new Error("contract_invalid: caches must be an array.");
159
+ if (value.length > allowedCaches.size)
160
+ throw new Error(`contract_invalid: caches cannot contain more than ${allowedCaches.size} entries.`);
161
+ const seen = new Set();
162
+ return value.map((entry, index) => {
163
+ const cache = typeof entry === "string" ? { name: entry } : object(entry, `caches[${index}]`);
164
+ rejectUnknownKeys(cache, new Set(["name", "mode"]), `caches[${index}]`);
165
+ const name = requiredString(cache.name, `caches[${index}].name`).toLowerCase();
166
+ if (!allowedCaches.has(name))
167
+ throw new Error(`contract_invalid: cache '${name}' is not allowlisted.`);
168
+ if (seen.has(name))
169
+ throw new Error(`contract_invalid: duplicate cache '${name}'.`);
170
+ seen.add(name);
171
+ const mode = cache.mode ?? "readonly";
172
+ if (mode !== "readonly" && mode !== "per_attempt")
173
+ throw new Error("contract_invalid: cache mode must be readonly or per_attempt.");
174
+ return { name, mode };
175
+ });
176
+ }
177
+ function parseServices(value) {
178
+ if (value === undefined || value === null)
179
+ return [];
180
+ if (!Array.isArray(value))
181
+ throw new Error("contract_invalid: services must be an array.");
182
+ if (value.length > 3)
183
+ throw new Error("contract_invalid: services cannot contain more than 3 entries.");
184
+ const seen = new Set();
185
+ return value.map((entry, index) => {
186
+ const service = typeof entry === "string" ? { type: entry } : object(entry, `services[${index}]`);
187
+ rejectUnknownKeys(service, new Set(["type", "port"]), `services[${index}]`);
188
+ const type = requiredString(service.type, `services[${index}].type`).toLowerCase();
189
+ if (type !== "postgres" && type !== "redis" && type !== "browser")
190
+ throw new Error(`contract_invalid: service '${type}' is not in the Forge catalogue.`);
191
+ if (seen.has(type))
192
+ throw new Error(`contract_invalid: duplicate service '${type}'.`);
193
+ seen.add(type);
194
+ const port = service.port === undefined ? defaultPorts[type] : positivePort(service.port, `services[${index}].port`);
195
+ return { type, port };
196
+ });
197
+ }
198
+ function rejectForbiddenKeys(value, path) {
199
+ if (Array.isArray(value))
200
+ return value.forEach((entry, index) => rejectForbiddenKeys(entry, `${path}[${index}]`));
201
+ if (!value || typeof value !== "object")
202
+ return;
203
+ for (const [key, child] of Object.entries(value)) {
204
+ if (forbiddenKeys.has(key))
205
+ throw new Error(`contract_invalid: forbidden privilege key '${path}.${key}'.`);
206
+ rejectForbiddenKeys(child, `${path}.${key}`);
207
+ }
208
+ }
209
+ function assertDepth(value, depth) {
210
+ if (depth > maxDepth)
211
+ throw new Error(`contract_invalid: YAML nesting exceeds ${maxDepth}.`);
212
+ if (Array.isArray(value)) {
213
+ value.forEach((entry) => assertDepth(entry, depth + 1));
214
+ }
215
+ else if (value && typeof value === "object") {
216
+ Object.values(value).forEach((entry) => assertDepth(entry, depth + 1));
217
+ }
218
+ }
219
+ function validateRelativePath(value, field) {
220
+ const normalized = value.trim().replace(/\\/gu, "/");
221
+ if (!normalized || normalized.length > 512 || normalized.startsWith("/") || normalized.includes("\0") || normalized.split("/").some((part) => part === ".." || part === "." || !part)) {
222
+ throw new Error(`contract_invalid: ${field} paths must be normalized relative paths under /workspace.`);
223
+ }
224
+ return normalized;
225
+ }
226
+ function validateEnvironmentName(value) {
227
+ const name = value.trim();
228
+ if (!/^[A-Z_][A-Z0-9_]{0,127}$/u.test(name) || name.startsWith("FORGE_") || name === "HOME" || name === "PATH") {
229
+ throw new Error(`contract_invalid: environment variable '${name}' is reserved or invalid.`);
230
+ }
231
+ return name;
232
+ }
233
+ function canonicalJson(value) {
234
+ if (Array.isArray(value))
235
+ return `[${value.map(canonicalJson).join(",")}]`;
236
+ if (value && typeof value === "object") {
237
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`).join(",")}}`;
238
+ }
239
+ return JSON.stringify(value);
240
+ }
241
+ function object(value, label) {
242
+ if (!value || typeof value !== "object" || Array.isArray(value))
243
+ throw new Error(`contract_invalid: ${label} must be an object.`);
244
+ return value;
245
+ }
246
+ function rejectUnknownKeys(value, allowed, label) {
247
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
248
+ if (unknown.length > 0)
249
+ throw new Error(`contract_invalid: unknown ${label} key '${unknown[0]}'.`);
250
+ }
251
+ function stringArray(value, label, maximumCount) {
252
+ if (value === undefined || value === null)
253
+ return [];
254
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string"))
255
+ throw new Error(`contract_invalid: ${label} must be a string array.`);
256
+ if (value.length > maximumCount)
257
+ throw new Error(`contract_invalid: ${label} cannot contain more than ${maximumCount} entries.`);
258
+ return [...new Set(value.map((entry) => entry.trim()))];
259
+ }
260
+ function requiredString(value, label) {
261
+ if (typeof value !== "string" || !value.trim())
262
+ throw new Error(`contract_invalid: ${label} must be a non-empty string.`);
263
+ return value.trim();
264
+ }
265
+ function optionalBoolean(value, label) {
266
+ if (value === undefined || value === null)
267
+ return null;
268
+ if (typeof value !== "boolean")
269
+ throw new Error(`contract_invalid: ${label} must be boolean.`);
270
+ return value;
271
+ }
272
+ function positivePort(value, label) {
273
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535)
274
+ throw new Error(`contract_invalid: ${label} must be a valid internal port.`);
275
+ return value;
276
+ }
@@ -0,0 +1,11 @@
1
+ export declare class SandboxEventBudget {
2
+ private readonly maximumCount;
3
+ private readonly maximumBytes;
4
+ private readonly maximumPerSecond;
5
+ private totalCount;
6
+ private totalBytes;
7
+ private windowStartedMs;
8
+ private windowCount;
9
+ constructor(maximumCount?: number, maximumBytes?: number, maximumPerSecond?: number, nowMs?: number);
10
+ accept(line: string, nowMs?: number): void;
11
+ }
@@ -0,0 +1,27 @@
1
+ export class SandboxEventBudget {
2
+ maximumCount;
3
+ maximumBytes;
4
+ maximumPerSecond;
5
+ totalCount = 0;
6
+ totalBytes = 0;
7
+ windowStartedMs;
8
+ windowCount = 0;
9
+ constructor(maximumCount = 100_000, maximumBytes = 64 * 1024 * 1024, maximumPerSecond = 1_000, nowMs = performance.now()) {
10
+ this.maximumCount = maximumCount;
11
+ this.maximumBytes = maximumBytes;
12
+ this.maximumPerSecond = maximumPerSecond;
13
+ this.windowStartedMs = nowMs;
14
+ }
15
+ accept(line, nowMs = performance.now()) {
16
+ if (nowMs - this.windowStartedMs >= 1_000) {
17
+ this.windowStartedMs = nowMs;
18
+ this.windowCount = 0;
19
+ }
20
+ this.totalCount += 1;
21
+ this.windowCount += 1;
22
+ this.totalBytes += Buffer.byteLength(line, "utf8");
23
+ if (this.totalCount > this.maximumCount || this.totalBytes > this.maximumBytes || this.windowCount > this.maximumPerSecond) {
24
+ throw new Error("sandbox_event_flood: sandbox event rate or total volume exceeded its bounded budget.");
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,35 @@
1
+ import { type SandboxProcessRequest, type SandboxProcessResult } from "./process.js";
2
+ export type SandboxImageLayer = "project" | "tooling" | "agent";
3
+ export interface SandboxImagePolicyRequest {
4
+ readonly key: string;
5
+ readonly project: string;
6
+ readonly executionPolicy: string;
7
+ readonly stateRoot: string;
8
+ readonly builderVersion: string;
9
+ readonly contextDigest: string;
10
+ readonly contractDigest: string;
11
+ readonly toolingImage: string;
12
+ readonly upstreamImages: readonly string[];
13
+ readonly codexVersion: string;
14
+ readonly images: Readonly<Record<SandboxImageLayer, string>>;
15
+ readonly imageScanTargets?: Readonly<Partial<Record<SandboxImageLayer, string>>>;
16
+ readonly environment?: NodeJS.ProcessEnv;
17
+ readonly signal?: AbortSignal;
18
+ readonly runCommand?: (command: SandboxProcessRequest) => Promise<SandboxProcessResult>;
19
+ }
20
+ export interface SandboxImagePolicyResult {
21
+ readonly status: "passed" | "warning";
22
+ readonly policyDigest: string;
23
+ readonly root: string;
24
+ }
25
+ export declare function evaluateSandboxImagePolicy(request: SandboxImagePolicyRequest): Promise<SandboxImagePolicyResult>;
26
+ export interface SandboxImageSignatureRequest {
27
+ readonly image: string;
28
+ readonly executionPolicy: string;
29
+ readonly environment?: NodeJS.ProcessEnv;
30
+ readonly signal?: AbortSignal;
31
+ readonly runCommand?: (command: SandboxProcessRequest) => Promise<SandboxProcessResult>;
32
+ }
33
+ export declare function verifySandboxImageSignature(request: SandboxImageSignatureRequest): Promise<"passed" | "warning">;
34
+ export declare function hasSandboxImagePolicyRecord(stateRoot: string, key: string, expectedDigest: string, requirePassed?: boolean): Promise<boolean>;
35
+ export declare function sandboxImagePolicyConfigurationDigest(environment?: NodeJS.ProcessEnv): Promise<string>;