@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.3-dev.fbe15023cdfe
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 +78 -0
- package/dist/bridge/bridge.base.d.ts +6 -1
- package/dist/bridge/bridge.service.d.ts +60 -3
- package/dist/bridge/bridge.service.js +970 -24
- package/dist/bridge/bridge.types.d.ts +172 -1
- package/dist/cleanup/cleanup.service.d.ts +13 -2
- package/dist/cleanup/cleanup.service.js +435 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.d.ts +2 -0
- package/dist/cli/cli.help.js +217 -0
- package/dist/cli/cli.service.d.ts +42 -2
- package/dist/cli/cli.service.js +356 -205
- package/dist/cli/local-config.d.ts +6 -0
- package/dist/cli/local-config.js +47 -3
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
- package/dist/runtime/attempt-process-supervisor.js +350 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +44 -0
- package/dist/runtime/host-listener-inspector.js +324 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +133 -0
- package/dist/runtime/host-runtime-lease.js +1460 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
- package/dist/runtime/node-host-runtime-adapter.js +268 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +54 -0
- package/dist/sandbox/preflight.js +335 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +119 -0
- package/dist/sandbox/runtime.d.ts +297 -0
- package/dist/sandbox/runtime.js +2338 -0
- package/dist/sandbox/sandbox.types.d.ts +86 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +72 -0
- package/dist/session/managed-attempt-tools.d.ts +12 -0
- package/dist/session/managed-attempt-tools.js +561 -0
- package/dist/session/phase-completion.artifacts.d.ts +10 -0
- package/dist/session/phase-completion.artifacts.js +627 -0
- package/dist/session/phase-completion.d.ts +43 -42
- package/dist/session/phase-completion.helpers.d.ts +112 -0
- package/dist/session/phase-completion.helpers.js +684 -0
- package/dist/session/phase-completion.js +114 -3007
- package/dist/session/phase-completion.prompts.d.ts +14 -0
- package/dist/session/phase-completion.prompts.js +234 -0
- package/dist/session/phase-completion.runtime.d.ts +47 -0
- package/dist/session/phase-completion.runtime.js +731 -0
- package/dist/session/phase-completion.schemas.d.ts +3 -0
- package/dist/session/phase-completion.schemas.js +812 -0
- package/dist/session/phase-completion.validation.d.ts +37 -0
- package/dist/session/phase-completion.validation.js +828 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
- package/dist/session/provider-cli/provider-cli.artifacts.js +778 -0
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +375 -0
- package/dist/session/provider-cli/provider-cli.process.d.ts +48 -0
- package/dist/session/provider-cli/provider-cli.process.js +474 -0
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +47 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +595 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
- package/dist/session/provider-cli/provider-cli.runtime.js +41 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +22 -53
- package/dist/session/provider-cli/provider-cli.service.js +587 -2530
- package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +22 -0
- package/dist/session/provider-cli/provider-output-state.js +473 -0
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +23 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +463 -58
- package/dist/session/session.types.d.ts +177 -1
- package/dist/session/session.types.js +352 -3
- package/dist/shared/path/path.js +3 -1
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +57 -0
- package/dist/shared/process/host-process-supervisor.js +282 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/process/windows-launch.d.ts +8 -0
- package/dist/shared/process/windows-launch.js +41 -0
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +10 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +82 -10
- package/dist/worktree/dependency-warmer.d.ts +21 -1
- package/dist/worktree/dependency-warmer.js +138 -5
- package/dist/worktree/index.d.ts +1 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +9 -3
- package/dist/worktree/worktree.service.js +100 -52
- package/dist/worktree/worktree.types.d.ts +3 -1
- package/package.json +6 -3
- package/runtime/windows-job-supervisor.ps1 +356 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -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>;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runSandboxProcess } from "./process.js";
|
|
5
|
+
export async function evaluateSandboxImagePolicy(request) {
|
|
6
|
+
const environment = request.environment ?? process.env;
|
|
7
|
+
const required = request.executionPolicy === "sandbox_required";
|
|
8
|
+
const enforced = environment.FORGE_SANDBOX_IMAGE_POLICY_ENFORCED === "true";
|
|
9
|
+
const sbomExecutable = environment.FORGE_SANDBOX_SBOM_EXECUTABLE?.trim();
|
|
10
|
+
const vulnerabilityExecutable = environment.FORGE_SANDBOX_VULNERABILITY_EXECUTABLE?.trim();
|
|
11
|
+
const signingKeyPath = environment.FORGE_SANDBOX_PROVENANCE_SIGNING_KEY?.trim();
|
|
12
|
+
const signatureExecutable = environment.FORGE_SANDBOX_SIGNATURE_EXECUTABLE?.trim();
|
|
13
|
+
const signaturePublicKey = environment.FORGE_SANDBOX_SIGNATURE_PUBLIC_KEY?.trim();
|
|
14
|
+
if (required && (!enforced || !sbomExecutable || !vulnerabilityExecutable || !signingKeyPath || !signatureExecutable || !signaturePublicKey)) {
|
|
15
|
+
throw new Error("sandbox_build_policy_failed: sandbox_required needs enforced SBOM, vulnerability, provenance-signing, and upstream-signature configuration.");
|
|
16
|
+
}
|
|
17
|
+
const root = path.join(request.stateRoot, "attestations", request.key);
|
|
18
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
19
|
+
const artifacts = {};
|
|
20
|
+
let status = "passed";
|
|
21
|
+
const privateKey = signingKeyPath ? await readSigningKey(signingKeyPath) : null;
|
|
22
|
+
const runCommand = request.runCommand ?? runSandboxProcess;
|
|
23
|
+
const upstreamSignatures = {};
|
|
24
|
+
for (const image of [...new Set([request.toolingImage, ...request.upstreamImages])]) {
|
|
25
|
+
const signatureStatus = await verifySandboxImageSignature({
|
|
26
|
+
image,
|
|
27
|
+
executionPolicy: request.executionPolicy,
|
|
28
|
+
environment,
|
|
29
|
+
signal: request.signal,
|
|
30
|
+
runCommand
|
|
31
|
+
});
|
|
32
|
+
upstreamSignatures[image] = signatureStatus;
|
|
33
|
+
if (signatureStatus === "warning")
|
|
34
|
+
status = "warning";
|
|
35
|
+
}
|
|
36
|
+
for (const [layer, imageDigest] of Object.entries(request.images)) {
|
|
37
|
+
const scanTarget = request.imageScanTargets?.[layer] ?? imageDigest;
|
|
38
|
+
const provenance = {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
predicateType: "https://slsa.dev/provenance/v1",
|
|
41
|
+
subject: [{ name: `forge/${layer}`, digest: { sha256: imageDigest.slice("sha256:".length) } }],
|
|
42
|
+
builder: { id: `forge-bridge/${request.builderVersion}` },
|
|
43
|
+
buildType: "https://forge.dev/buildkit/project-agent-overlay/v1",
|
|
44
|
+
invocation: { parameters: { key: request.key, project: request.project, layer } },
|
|
45
|
+
materials: [
|
|
46
|
+
{ uri: "forge:tracked-context", digest: { sha256: request.contextDigest } },
|
|
47
|
+
{ uri: "forge:sandbox-contract", digest: { sha256: request.contractDigest.replace(/^sha256:/u, "") } },
|
|
48
|
+
{ uri: request.toolingImage },
|
|
49
|
+
{ uri: `pkg:npm/@openai/codex@${request.codexVersion}` }
|
|
50
|
+
]
|
|
51
|
+
};
|
|
52
|
+
const provenanceBytes = Buffer.from(`${canonicalJson(provenance)}\n`, "utf8");
|
|
53
|
+
const provenanceName = `${layer}.provenance.json`;
|
|
54
|
+
await writeRestricted(path.join(root, provenanceName), provenanceBytes);
|
|
55
|
+
let signatureDigest = null;
|
|
56
|
+
if (privateKey) {
|
|
57
|
+
const signature = sign("sha256", provenanceBytes, privateKey);
|
|
58
|
+
if (!verify("sha256", provenanceBytes, createPublicKey(privateKey), signature)) {
|
|
59
|
+
throw new Error("sandbox_build_policy_failed: generated provenance signature did not verify.");
|
|
60
|
+
}
|
|
61
|
+
await writeRestricted(path.join(root, `${provenanceName}.sig`), signature);
|
|
62
|
+
signatureDigest = sha256(signature);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
status = "warning";
|
|
66
|
+
}
|
|
67
|
+
let sbomDigest = null;
|
|
68
|
+
if (sbomExecutable) {
|
|
69
|
+
const sbom = await runCommand({
|
|
70
|
+
command: sbomExecutable,
|
|
71
|
+
args: [scanTarget, "-o", "spdx-json"],
|
|
72
|
+
timeoutMs: 10 * 60_000,
|
|
73
|
+
maxOutputBytes: 32 * 1024 * 1024,
|
|
74
|
+
signal: request.signal
|
|
75
|
+
});
|
|
76
|
+
if (sbom.exitCode !== 0 || sbom.truncated || !validJsonObject(sbom.stdout)) {
|
|
77
|
+
throw new Error(`sandbox_build_policy_failed: SPDX SBOM generation failed for ${layer}.`);
|
|
78
|
+
}
|
|
79
|
+
const bytes = Buffer.from(sbom.stdout, "utf8");
|
|
80
|
+
await writeRestricted(path.join(root, `${layer}.sbom.spdx.json`), bytes);
|
|
81
|
+
sbomDigest = sha256(bytes);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
status = "warning";
|
|
85
|
+
}
|
|
86
|
+
let vulnerabilityDigest = null;
|
|
87
|
+
if (vulnerabilityExecutable) {
|
|
88
|
+
const scan = await runCommand({
|
|
89
|
+
command: vulnerabilityExecutable,
|
|
90
|
+
args: ["image", "--scanners", "vuln", "--format", "json", "--severity", "HIGH,CRITICAL", "--exit-code", "1", "--no-progress", scanTarget],
|
|
91
|
+
timeoutMs: 15 * 60_000,
|
|
92
|
+
maxOutputBytes: 32 * 1024 * 1024,
|
|
93
|
+
signal: request.signal
|
|
94
|
+
});
|
|
95
|
+
const bytes = Buffer.from(scan.stdout || scan.stderr, "utf8");
|
|
96
|
+
await writeRestricted(path.join(root, `${layer}.vulnerabilities.json`), bytes);
|
|
97
|
+
vulnerabilityDigest = sha256(bytes);
|
|
98
|
+
if (scan.exitCode !== 0 || scan.truncated) {
|
|
99
|
+
throw new Error(`sandbox_build_policy_failed: ${layer} violates the HIGH/CRITICAL vulnerability policy.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
status = "warning";
|
|
104
|
+
}
|
|
105
|
+
artifacts[layer] = {
|
|
106
|
+
imageDigest,
|
|
107
|
+
scanTarget,
|
|
108
|
+
provenanceSha256: sha256(provenanceBytes),
|
|
109
|
+
provenanceSignatureSha256: signatureDigest,
|
|
110
|
+
sbomSha256: sbomDigest,
|
|
111
|
+
vulnerabilityReportSha256: vulnerabilityDigest
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const policyRecord = {
|
|
115
|
+
schemaVersion: 1,
|
|
116
|
+
key: request.key,
|
|
117
|
+
project: request.project,
|
|
118
|
+
status,
|
|
119
|
+
enforced,
|
|
120
|
+
generatedAt: new Date().toISOString(),
|
|
121
|
+
upstreamSignatures,
|
|
122
|
+
artifacts
|
|
123
|
+
};
|
|
124
|
+
const recordBytes = Buffer.from(`${canonicalJson(policyRecord)}\n`, "utf8");
|
|
125
|
+
const policyDigest = sha256(recordBytes);
|
|
126
|
+
await writeRestricted(path.join(root, "policy.json"), recordBytes);
|
|
127
|
+
return { status, policyDigest, root };
|
|
128
|
+
}
|
|
129
|
+
export async function verifySandboxImageSignature(request) {
|
|
130
|
+
const required = request.executionPolicy === "sandbox_required";
|
|
131
|
+
if (!/@sha256:[a-f0-9]{64}$/iu.test(request.image)) {
|
|
132
|
+
if (required)
|
|
133
|
+
throw new Error(`sandbox_image_signature_failed: image '${request.image}' is not digest-pinned.`);
|
|
134
|
+
return "warning";
|
|
135
|
+
}
|
|
136
|
+
const environment = request.environment ?? process.env;
|
|
137
|
+
const executable = environment.FORGE_SANDBOX_SIGNATURE_EXECUTABLE?.trim();
|
|
138
|
+
const publicKeyPath = environment.FORGE_SANDBOX_SIGNATURE_PUBLIC_KEY?.trim();
|
|
139
|
+
if (!executable || !publicKeyPath) {
|
|
140
|
+
if (required)
|
|
141
|
+
throw new Error("sandbox_image_signature_failed: sandbox_required needs an image signature verifier and public key.");
|
|
142
|
+
return "warning";
|
|
143
|
+
}
|
|
144
|
+
await readBoundedRegularFile(publicKeyPath, "image signature public key", "sandbox_image_signature_failed");
|
|
145
|
+
const runCommand = request.runCommand ?? runSandboxProcess;
|
|
146
|
+
const result = await runCommand({
|
|
147
|
+
command: executable,
|
|
148
|
+
args: ["verify", "--key", publicKeyPath, request.image],
|
|
149
|
+
timeoutMs: 5 * 60_000,
|
|
150
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
151
|
+
terminateOnOutputLimit: true,
|
|
152
|
+
signal: request.signal
|
|
153
|
+
});
|
|
154
|
+
if (result.exitCode !== 0 || result.truncated || !validSignatureResult(result.stdout)) {
|
|
155
|
+
throw new Error(`sandbox_image_signature_failed: signature verification failed for '${request.image}'.`);
|
|
156
|
+
}
|
|
157
|
+
return "passed";
|
|
158
|
+
}
|
|
159
|
+
export async function hasSandboxImagePolicyRecord(stateRoot, key, expectedDigest, requirePassed = false) {
|
|
160
|
+
const target = path.join(stateRoot, "attestations", key, "policy.json");
|
|
161
|
+
const stat = await fs.lstat(target).catch(() => null);
|
|
162
|
+
if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
|
|
163
|
+
return false;
|
|
164
|
+
const bytes = await fs.readFile(target);
|
|
165
|
+
if (sha256(bytes) !== expectedDigest)
|
|
166
|
+
return false;
|
|
167
|
+
if (!requirePassed)
|
|
168
|
+
return true;
|
|
169
|
+
try {
|
|
170
|
+
const record = JSON.parse(bytes.toString("utf8"));
|
|
171
|
+
return record.status === "passed" && record.enforced === true;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export async function sandboxImagePolicyConfigurationDigest(environment = process.env) {
|
|
178
|
+
const signingKeyPath = environment.FORGE_SANDBOX_PROVENANCE_SIGNING_KEY?.trim() ?? "";
|
|
179
|
+
const signaturePublicKeyPath = environment.FORGE_SANDBOX_SIGNATURE_PUBLIC_KEY?.trim() ?? "";
|
|
180
|
+
const fields = [
|
|
181
|
+
environment.FORGE_SANDBOX_IMAGE_POLICY_ENFORCED ?? "",
|
|
182
|
+
environment.FORGE_SANDBOX_SBOM_EXECUTABLE?.trim() ?? "",
|
|
183
|
+
environment.FORGE_SANDBOX_VULNERABILITY_EXECUTABLE?.trim() ?? "",
|
|
184
|
+
environment.FORGE_SANDBOX_SIGNATURE_EXECUTABLE?.trim() ?? "",
|
|
185
|
+
await policyFileFingerprint(signingKeyPath),
|
|
186
|
+
await policyFileFingerprint(signaturePublicKeyPath)
|
|
187
|
+
];
|
|
188
|
+
return sha256(Buffer.from(fields.join("\0"), "utf8"));
|
|
189
|
+
}
|
|
190
|
+
async function readSigningKey(target) {
|
|
191
|
+
const bytes = await readBoundedRegularFile(target, "provenance signing key");
|
|
192
|
+
try {
|
|
193
|
+
return createPrivateKey(bytes);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
throw new Error("sandbox_build_policy_failed: provenance signing key is invalid.", { cause: error });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function readBoundedRegularFile(target, label, failureCode = "sandbox_build_policy_failed") {
|
|
200
|
+
const stat = await fs.lstat(target).catch(() => null);
|
|
201
|
+
if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.size === 0 || stat.size > 64 * 1024) {
|
|
202
|
+
throw new Error(`${failureCode}: ${label} must be a bounded regular file.`);
|
|
203
|
+
}
|
|
204
|
+
return await fs.readFile(target);
|
|
205
|
+
}
|
|
206
|
+
async function policyFileFingerprint(target) {
|
|
207
|
+
if (!target)
|
|
208
|
+
return "not-configured";
|
|
209
|
+
const stat = await fs.lstat(target).catch(() => null);
|
|
210
|
+
if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.size === 0 || stat.size > 64 * 1024)
|
|
211
|
+
return "invalid";
|
|
212
|
+
return sha256(await fs.readFile(target));
|
|
213
|
+
}
|
|
214
|
+
async function writeRestricted(target, bytes) {
|
|
215
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
216
|
+
await fs.writeFile(temporary, bytes, { mode: 0o600, flag: "wx" });
|
|
217
|
+
await fs.rename(temporary, target);
|
|
218
|
+
}
|
|
219
|
+
function validJsonObject(value) {
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(value);
|
|
222
|
+
return Boolean(parsed && typeof parsed === "object" && !Array.isArray(parsed));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function validSignatureResult(value) {
|
|
229
|
+
try {
|
|
230
|
+
const parsed = JSON.parse(value);
|
|
231
|
+
return Array.isArray(parsed) && parsed.length > 0 && parsed.every(entry => Boolean(entry && typeof entry === "object" && !Array.isArray(entry)));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function sha256(value) {
|
|
238
|
+
return createHash("sha256").update(value).digest("hex");
|
|
239
|
+
}
|
|
240
|
+
function canonicalJson(value) {
|
|
241
|
+
if (Array.isArray(value))
|
|
242
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
243
|
+
if (value && typeof value === "object") {
|
|
244
|
+
return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`).join(",")}}`;
|
|
245
|
+
}
|
|
246
|
+
return JSON.stringify(value);
|
|
247
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface SignedLeaseUpdate {
|
|
2
|
+
readonly schemaVersion: 1;
|
|
3
|
+
readonly dispatchAttemptId: string;
|
|
4
|
+
readonly fencingToken: string;
|
|
5
|
+
readonly expiresAt: string;
|
|
6
|
+
readonly mac: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function createLeaseUpdate(dispatchAttemptId: string, fencingToken: string, expiresAt: string, controlKey: string): SignedLeaseUpdate;
|
|
9
|
+
export declare class MonotonicLeaseDeadline {
|
|
10
|
+
readonly dispatchAttemptId: string;
|
|
11
|
+
readonly fencingToken: string;
|
|
12
|
+
private readonly controlKey;
|
|
13
|
+
private expiresAtMs;
|
|
14
|
+
private deadlineMs;
|
|
15
|
+
constructor(dispatchAttemptId: string, fencingToken: string, initialExpiresAt: string, controlKey: string, wallNowMs?: number, monotonicNowMs?: number);
|
|
16
|
+
apply(update: unknown, wallNowMs?: number, monotonicNowMs?: number): boolean;
|
|
17
|
+
expired(monotonicNowMs?: number): boolean;
|
|
18
|
+
}
|