Pool host-approved restricted code with scoped and fair graph execution

This commit is contained in:
Timothy J. Aveni
2026-10-07 16:47:19 -07:00
parent c8c56d8ea7
commit 391f02f78a
5 changed files with 160 additions and 45 deletions
+69 -18
View File
@@ -24,28 +24,44 @@ export class CandidateMetadataBudget {
this.scopes++;
let bytes = 0,
closed = false;
const charge = (amount: number) => {
if (closed) throw Error("CANDIDATE_METADATA_CLOSED");
if (!Number.isSafeInteger(amount) || amount < 0) throw Error("INVALID_CANDIDATE_METADATA_CHARGE");
const owned = this.owners.get(owner) ?? 0;
if (this.bytes + amount > this.limits.bytes || owned + amount > this.limits.ownerBytes)
throw Error("CANDIDATE_METADATA_LIMIT");
bytes += amount;
this.bytes += amount;
this.owners.set(owner, owned + amount);
};
const subtract = (amount: number) => {
bytes -= amount;
this.bytes -= amount;
const remaining = (this.owners.get(owner) ?? 0) - amount;
if (remaining) this.owners.set(owner, remaining);
else this.owners.delete(owner);
};
return {
get bytes() {
return bytes;
},
charge: (amount: number) => {
if (closed) throw Error("CANDIDATE_METADATA_CLOSED");
if (!Number.isSafeInteger(amount) || amount < 0) throw Error("INVALID_CANDIDATE_METADATA_CHARGE");
const owned = this.owners.get(owner) ?? 0;
if (this.bytes + amount > this.limits.bytes || owned + amount > this.limits.ownerBytes)
throw Error("CANDIDATE_METADATA_LIMIT");
bytes += amount;
this.bytes += amount;
this.owners.set(owner, owned + amount);
charge,
/** Opaque, idempotent transient reservation; callers cannot subtract an
* arbitrary amount or release another candidate's charges. */
reserve: (amount: number) => {
charge(amount);
let released = false;
return () => {
if (closed || released) return;
released = true;
subtract(amount);
};
},
release: () => {
if (closed) return;
closed = true;
this.bytes -= bytes;
subtract(bytes);
this.scopes--;
const remaining = (this.owners.get(owner) ?? 0) - bytes;
if (remaining) this.owners.set(owner, remaining);
else this.owners.delete(owner);
},
};
}
@@ -53,21 +69,50 @@ export class CandidateMetadataBudget {
export type CandidateMetadataScope = ReturnType<CandidateMetadataBudget["acquire"]>;
export const candidateMetadataBudget = new CandidateMetadataBudget();
// Estimate JSON allocation without constructing its tree. Object/array headers,
// value slots and strings get explicit weights, with a multiplier for checked
// indexes/derived representations. Indentation must not multiply tree charges.
function jsonAllocation(bytes: Uint8Array): number {
let estimate = 0;
for (let i = 0; i < bytes.length; i++) {
const c = bytes[i]!;
if (c === 123) estimate += 64;
else if (c === 91) estimate += 32;
else if (c === 34) {
const start = ++i;
while (i < bytes.length && bytes[i] !== 34) {
if (bytes[i] === 92) i++;
i++;
}
estimate += 32 + 2 * (i - start);
} else if (c === 45 || (c >= 48 && c <= 57) || c === 116 || c === 102 || c === 110) {
estimate += 16;
while (i + 1 < bytes.length) {
const next = bytes[i + 1]!;
if (next === 9 || next === 10 || next === 13 || next === 32 || next === 44 || next === 93 || next === 125)
break;
i++;
}
}
}
return estimate * 2;
}
/** Immutable regular files only. Fixed allocation plus an extra byte catches
* growth; readFile() would allocate before the admission check can intervene. */
export async function readCandidateMetadata(
path: string,
scope: CandidateMetadataScope,
limit: number,
): Promise<Uint8Array> {
): Promise<{ bytes: Uint8Array; releaseInput: () => void }> {
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
let releaseInput: (() => void) | undefined;
try {
const stat = await handle.stat();
if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size > limit)
throw Error("CANDIDATE_METADATA_FILE_LIMIT");
// Conservative attribution includes strings, small JSON nodes, duplicate
// checked representations, digest serialization and installed indexes.
scope.charge((stat.size + 1) * 32);
// Reserve the input buffer and UTF-16 decoding before allocating either.
releaseInput = scope.reserve((stat.size + 1) * 3);
const bytes = Buffer.alloc(stat.size + 1);
let offset = 0;
while (offset < bytes.length) {
@@ -76,7 +121,13 @@ export async function readCandidateMetadata(
offset += read.bytesRead;
}
if (offset !== stat.size) throw Error("CANDIDATE_METADATA_CHANGED");
return bytes.subarray(0, offset);
const content = bytes.subarray(0, offset);
// No JSON.parse/decoded tree exists until its shape-based charge succeeds.
scope.charge(jsonAllocation(content));
return { bytes: content, releaseInput };
} catch (error) {
releaseInput?.();
throw error;
} finally {
await handle.close();
}