v2r1c2_harden_provenance_caches

This commit is contained in:
ChatGPT Release Governance
2026-07-22 03:53:22 +08:00
parent 80e3f65cd6
commit f3c00d7294
4 changed files with 152 additions and 34 deletions
+26 -6
View File
@@ -17,20 +17,40 @@ import { createRuntimeApi } from "./runtime.js";
const activeRuns = new Map();
const permits = new Map();
const ownerPermits = new Map();
const ownerUses = new Set();
const ownerUses = new Map();
const dispatchReceipts = new Map();
const dispatchAttempts = new Map();
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
const MAX_DISPATCH_CACHE_AGE_MS = 60 * 60 * 1000;
const MAX_SECURITY_CACHE_ENTRIES = 4096;
const DEFAULT_PERMIT_TTL_MS = 2 * 60 * 1000;
function pluginConfig(event, api) {
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
}
function prune(now = Date.now()) {
function trimToLimit(map, limit = MAX_SECURITY_CACHE_ENTRIES) {
while (map.size > limit) map.delete(map.keys().next().value);
}
function setBounded(map, key, value) {
if (map.has(key)) map.delete(key);
map.set(key, value);
trimToLimit(map);
}
export function prune(now = Date.now()) {
for (const [key, value] of activeRuns) if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
for (const [key, value] of permits) if (now - value.recordedAt > value.ttlMs) permits.delete(key);
for (const [key, value] of ownerPermits) if (now - value.recordedAt > value.ttlMs) ownerPermits.delete(key);
for (const [key, expiresAt] of ownerUses) if (now >= expiresAt) ownerUses.delete(key);
for (const [key, value] of dispatchReceipts) {
if (now - value.recordedAt > MAX_DISPATCH_CACHE_AGE_MS) dispatchReceipts.delete(key);
}
for (const [key, value] of dispatchAttempts) {
if (now - value.recordedAt > MAX_DISPATCH_CACHE_AGE_MS) dispatchAttempts.delete(key);
}
for (const map of [activeRuns, permits, ownerPermits, ownerUses, dispatchReceipts, dispatchAttempts]) trimToLimit(map);
}
function toolCallId(event) {
@@ -208,7 +228,7 @@ export function consumeProtectedPermit(toolCallIdValue, params, now = Date.now()
if (!binding.ok) throw new Error(binding.code);
const provenanceId = permit.record.payload.provenance_id;
if (ownerUses.has(provenanceId)) throw new Error("owner_provenance_replay");
ownerUses.add(provenanceId);
setBounded(ownerUses, provenanceId, permit.record.payload.expires_at);
return permit;
}
@@ -282,8 +302,8 @@ function registerProtectedDispatchTool(api) {
if (prior.paramsDigest !== permit.paramsDigest) throw new Error("protected_dispatch_idempotency_conflict");
return toolResult({ ...prior.result, deduplicated: true });
}
const attempts = (dispatchAttempts.get(dedupeKey) || 0) + 1;
dispatchAttempts.set(dedupeKey, attempts);
const attempts = (dispatchAttempts.get(dedupeKey)?.attempts || 0) + 1;
setBounded(dispatchAttempts, dedupeKey, { attempts, recordedAt: Date.now() });
if (config.mockFailFirstDelivery === true && attempts === 1) throw new Error("simulated_delivery_failure");
const result = {
accepted: true,
@@ -296,7 +316,7 @@ function registerProtectedDispatchTool(api) {
attempts,
};
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
dispatchReceipts.set(dedupeKey, { paramsDigest: permit.paramsDigest, result });
setBounded(dispatchReceipts, dedupeKey, { paramsDigest: permit.paramsDigest, result, recordedAt: Date.now() });
return toolResult(result);
},
}), { name: "aios_protected_dispatch" });
+24 -14
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
import {
canonicalize,
privateKeyFromMaterial,
readPrivateKeyMaterial,
signRecord,
validateOwnerProvenancePayload,
validatePayload,
@@ -30,10 +31,21 @@ function atomicWrite(filePath, contents, mode) {
fs.chmodSync(filePath, mode);
}
function requireProtectedPrivateKey(privateKeyPath) {
const stat = fs.lstatSync(privateKeyPath);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsafe_private_key");
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("unsafe_private_key_mode");
function atomicCreate(filePath, contents, mode) {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o750 });
const parent = fs.realpathSync(path.dirname(filePath));
const target = path.join(parent, path.basename(filePath));
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
try {
fs.writeFileSync(temporary, contents, { mode, flag: "wx" });
fs.linkSync(temporary, target);
fs.chmodSync(target, mode);
} catch (error) {
if (error?.code === "EEXIST") throw new Error("provenance_id_already_issued");
throw error;
} finally {
try { fs.unlinkSync(temporary); } catch {}
}
}
export function issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId, now = Date.now() }) {
@@ -43,15 +55,14 @@ export function issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, sto
const payload = JSON.parse(fs.readFileSync(payloadPath, "utf8"));
const validated = validatePayload(payload, now);
if (!validated.ok) throw new Error(validated.code);
requireProtectedPrivateKey(privateKeyPath);
const recordPath = path.join(storeDir, `${payload.provenance_id}.json`);
if (fs.existsSync(recordPath)) throw new Error("provenance_id_already_issued");
const privateMaterial = fs.readFileSync(privateKeyPath);
const privateMaterial = readPrivateKeyMaterial(privateKeyPath);
fs.mkdirSync(storeDir, { recursive: true, mode: 0o750 });
const recordPath = path.join(fs.realpathSync(storeDir), `${payload.provenance_id}.json`);
const privateKey = privateKeyFromMaterial(privateMaterial);
const publicPem = crypto.createPublicKey(privateKey).export({ format: "pem", type: "spki" });
const record = signRecord(payload, privateMaterial, keyId);
atomicWrite(publicKeyPath, publicPem, 0o644);
atomicWrite(recordPath, `${canonicalize(record)}\n`, 0o640);
atomicCreate(recordPath, `${canonicalize(record)}\n`, 0o640);
return payload.provenance_id;
}
@@ -70,7 +81,7 @@ export function issueOwnerProvenance({
if (typeof value !== "string" || !value) throw new Error("missing_owner_issuer_argument");
}
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1000 || ttlMs > 300_000) throw new Error("invalid_owner_ttl");
requireProtectedPrivateKey(privateKeyPath);
const privateMaterial = readPrivateKeyMaterial(privateKeyPath);
const payload = {
schema_version: "aios.owner-provenance/v2",
provenance_id: provenanceId,
@@ -87,11 +98,10 @@ export function issueOwnerProvenance({
};
const validated = validateOwnerProvenancePayload(payload, now);
if (!validated.ok) throw new Error(validated.code);
const recordPath = path.join(storeDir, `${provenanceId}.json`);
if (fs.existsSync(recordPath)) throw new Error("provenance_id_already_issued");
const privateMaterial = fs.readFileSync(privateKeyPath);
fs.mkdirSync(storeDir, { recursive: true, mode: 0o750 });
const recordPath = path.join(fs.realpathSync(storeDir), `${provenanceId}.json`);
const record = signRecord(payload, privateMaterial, keyId);
atomicWrite(recordPath, `${canonicalize(record)}\n`, 0o640);
atomicCreate(recordPath, `${canonicalize(record)}\n`, 0o640);
return { provenanceId, recordPath, record };
}
+49 -13
View File
@@ -171,28 +171,64 @@ export function revokeOwnerProvenance(storeDir, provenanceId, now = Date.now())
return target;
}
function safeRegularFile(filePath) {
const stat = fs.lstatSync(filePath);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsafe_provenance_file");
if (process.platform !== "win32" && (stat.mode & 0o022) !== 0) throw new Error("unsafe_provenance_file");
function readRegularFile(filePath, {
unsafeCode,
unsafeModeCode = unsafeCode,
modeMask = 0o022,
encoding,
maxBytes,
expectedParent,
expectedBasename,
} = {}) {
let descriptor;
try {
descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) throw new Error(unsafeCode);
if (process.platform !== "win32" && (stat.mode & modeMask) !== 0) throw new Error(unsafeModeCode);
if (Number.isSafeInteger(maxBytes) && stat.size > maxBytes) throw new Error("provenance_record_too_large");
if (expectedParent || expectedBasename) {
const openedPath = process.platform === "linux"
? fs.realpathSync(`/proc/self/fd/${descriptor}`)
: fs.realpathSync(filePath);
if ((expectedParent && path.dirname(openedPath) !== expectedParent)
|| (expectedBasename && path.basename(openedPath) !== expectedBasename)) {
throw new Error(unsafeCode);
}
}
return fs.readFileSync(descriptor, encoding ? { encoding } : undefined);
} catch (error) {
if (error instanceof Error && [unsafeCode, unsafeModeCode, "provenance_record_too_large"].includes(error.message)) throw error;
throw new Error(unsafeCode);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function loadStoredRecord(storeDir, provenanceId) {
if (!path.isAbsolute(storeDir) || !ID_RE.test(provenanceId)) throw new Error("invalid_provenance_location");
const storeReal = fs.realpathSync(storeDir);
const target = path.join(storeReal, `${provenanceId}.json`);
safeRegularFile(target);
const targetReal = fs.realpathSync(target);
if (path.dirname(targetReal) !== storeReal || path.basename(targetReal) !== `${provenanceId}.json`) {
throw new Error("unsafe_provenance_file");
}
const raw = fs.readFileSync(target, "utf8");
if (Buffer.byteLength(raw) > 128 * 1024) throw new Error("provenance_record_too_large");
const raw = readRegularFile(target, {
unsafeCode: "unsafe_provenance_file",
encoding: "utf8",
maxBytes: 128 * 1024,
expectedParent: storeReal,
expectedBasename: `${provenanceId}.json`,
});
return JSON.parse(raw);
}
export function readPublicKey(publicKeyPath) {
if (!path.isAbsolute(publicKeyPath)) throw new Error("invalid_public_key_path");
safeRegularFile(publicKeyPath);
return fs.readFileSync(publicKeyPath, "utf8");
return readRegularFile(publicKeyPath, { unsafeCode: "unsafe_public_key", encoding: "utf8" });
}
export function readPrivateKeyMaterial(privateKeyPath) {
if (!path.isAbsolute(privateKeyPath)) throw new Error("invalid_private_key_path");
return readRegularFile(privateKeyPath, {
unsafeCode: "unsafe_private_key",
unsafeModeCode: "unsafe_private_key_mode",
modeMask: 0o077,
});
}
@@ -11,13 +11,14 @@ import plugin, {
authorizeProtectedDispatchCall,
consumePermit,
consumeProtectedPermit,
prune,
verifyOwnerBinding,
__state,
} from "../index.js";
import { decideToolCall, validateWriterParams } from "../policy.js";
import {
ALLOWED_OPERATIONS, ALLOWED_PATHS, canonicalize, revokeOwnerProvenance,
signRecord, validateOwnerProvenancePayload, validatePayload,
loadStoredRecord, readPublicKey, signRecord, validateOwnerProvenancePayload, validatePayload,
verifyOwnerProvenanceRecord, verifyRecord,
} from "../provenance.js";
import { buildPushArgs, executeEvidenceWrite } from "../writer.js";
@@ -111,6 +112,57 @@ test("issuer accepts protected raw 32-byte material and refuses duplicate ids",
assert.throws(() => issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), /already_issued/);
});
test("provenance and key readers reject symbolic links", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-provenance-link-"));
const store = path.join(root, "store");
fs.mkdirSync(store);
const target = path.join(root, "target.json");
fs.writeFileSync(target, "{}", { mode: 0o600 });
const recordLink = path.join(store, "prov-link.json");
const keyLink = path.join(root, "public-link.pem");
fs.symlinkSync(target, recordLink);
fs.symlinkSync(target, keyLink);
assert.throws(() => loadStoredRecord(store, "prov-link"), /unsafe_provenance_file/);
assert.throws(() => readPublicKey(keyLink), /unsafe_public_key/);
});
test("provenance reads stay pinned to the opened descriptor during path replacement", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-provenance-race-"));
const store = path.join(root, "store");
fs.mkdirSync(store);
const recordPath = path.join(store, "prov-race.json");
fs.writeFileSync(recordPath, '{"marker":"trusted"}', { mode: 0o600 });
const originalRead = fs.readFileSync;
fs.readFileSync = function interceptedRead(target, ...args) {
if (target === recordPath) {
fs.renameSync(recordPath, `${recordPath}.trusted`);
fs.writeFileSync(recordPath, '{"marker":"substituted"}', { mode: 0o600 });
}
return originalRead.call(this, target, ...args);
};
try {
assert.equal(loadStoredRecord(store, "prov-race").marker, "trusted");
} finally {
fs.readFileSync = originalRead;
}
});
test("security caches prune expired entries and enforce a hard bound", () => {
for (const value of Object.values(__state)) value.clear();
const now = Date.now();
__state.ownerUses.set("expired", now - 1);
__state.dispatchReceipts.set("expired", { recordedAt: now - 2 * 60 * 60 * 1000 });
__state.dispatchAttempts.set("expired", { attempts: 1, recordedAt: now - 2 * 60 * 60 * 1000 });
for (let index = 0; index < 5000; index += 1) {
__state.ownerUses.set(`live-${index}`, now + 60_000);
}
prune(now);
assert.equal(__state.ownerUses.has("expired"), false);
assert.equal(__state.dispatchReceipts.has("expired"), false);
assert.equal(__state.dispatchAttempts.has("expired"), false);
assert.equal(__state.ownerUses.size, 4096);
});
test("generic mutation tools stay blocked", () => {
for (const name of ["exec", "shell", "bash", "write", "edit", "apply_patch", "file_write"]) {
assert.deepEqual(decideToolCall(name, { trusted: true }, {}), { action: "block", code: "dedicated_writer_required" });