v2r1c2_harden_provenance_caches
This commit is contained in:
@@ -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" });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user