companion-runtime-manager-security.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Tier 7 — SECURITY: lib/companion-runtime-manager.mjs |
| 3 | * |
| 4 | * The security tier is the centerpiece of Phase 4 testing. It covers, at minimum: |
| 5 | * |
| 6 | * (a) SUPPLY-CHAIN: a wrong/missing integrity digest rejects the model — no execution on |
| 7 | * unverified bytes. An oversized or foreign-source download is rejected. HTTPS-only. |
| 8 | * (b) RESOURCE EXHAUSTION: backpressure trips at the configured bound. The lifecycle gate |
| 9 | * never serves inference in a non-ready state. |
| 10 | * (c) AMBIENT AUTHORITY: the module imports no vault, canister, keychain, or JWT handle. |
| 11 | * No secret, model path, URL, digest value, or access token appears in any reason |
| 12 | * string, return value, or thrown error. |
| 13 | * (d) CONSTANT-TIME: the integrity digest comparison is constant-time (no length/content |
| 14 | * timing oracle on the expected digest). |
| 15 | * |
| 16 | * Reference: docs/COMPANION-APP-PHASE-4-RUNTIME-MANAGER.md §2 (integrity), §3 (lifecycle), |
| 17 | * §4 (backpressure); docs/COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md §4.6 |
| 18 | * (no ambient authority). |
| 19 | */ |
| 20 | |
| 21 | import { describe, it } from 'node:test'; |
| 22 | import assert from 'node:assert/strict'; |
| 23 | import crypto from 'node:crypto'; |
| 24 | |
| 25 | import { |
| 26 | RUNTIME_MANAGER_REASONS, |
| 27 | LIFECYCLE_STATES, |
| 28 | LIFECYCLE_EVENTS, |
| 29 | createIntegrityAccumulator, |
| 30 | verifyModelBytes, |
| 31 | validateSourceUrl, |
| 32 | validateIntegritySpec, |
| 33 | createLifecycleState, |
| 34 | transitionLifecycle, |
| 35 | canServeInference, |
| 36 | createAdmissionState, |
| 37 | evaluateAdmission, |
| 38 | recordInFlight, |
| 39 | createResourceLimits, |
| 40 | evaluateResourceLimits, |
| 41 | evaluateRuntimeRequest, |
| 42 | } from '../lib/companion-runtime-manager.mjs'; |
| 43 | |
| 44 | function makeDigest(data) { |
| 45 | return crypto.createHash('sha256').update(data).digest('hex'); |
| 46 | } |
| 47 | |
| 48 | const ALLOWED_URLS = ['https://models.example.com/']; |
| 49 | const VALID_URL = 'https://models.example.com/model.bin'; |
| 50 | const VALID_DATA = Buffer.from('a legitimate model binary payload'); |
| 51 | const VALID_DIGEST = makeDigest(VALID_DATA); |
| 52 | const VALID_SIZE = VALID_DATA.length; |
| 53 | |
| 54 | const READY = { state: LIFECYCLE_STATES.READY }; |
| 55 | const VALID_LIMITS = createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 80 }); |
| 56 | const VALID_OBS = { ramBytes: 1e9, vramBytes: 0.5e9, cpuPercent: 10 }; |
| 57 | |
| 58 | // ── (a) SUPPLY-CHAIN INTEGRITY ──────────────────────────────────────────────── |
| 59 | |
| 60 | describe('security: supply-chain — wrong digest rejects model before execution', () => { |
| 61 | it('wrong digest (all zeros) is rejected — DIGEST_MISMATCH', () => { |
| 62 | const r = verifyModelBytes({ |
| 63 | fileData: VALID_DATA, expectedDigest: '0'.repeat(64), |
| 64 | expectedSizeBytes: VALID_SIZE, sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 65 | }); |
| 66 | assert.equal(r.ok, false); |
| 67 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.DIGEST_MISMATCH); |
| 68 | }); |
| 69 | |
| 70 | it('wrong digest (one hex char flipped) is rejected', () => { |
| 71 | const flipped = VALID_DIGEST.slice(0, -1) + (VALID_DIGEST.endsWith('0') ? '1' : '0'); |
| 72 | const r = verifyModelBytes({ |
| 73 | fileData: VALID_DATA, expectedDigest: flipped, |
| 74 | expectedSizeBytes: VALID_SIZE, sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 75 | }); |
| 76 | assert.equal(r.ok, false); |
| 77 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.DIGEST_MISMATCH); |
| 78 | }); |
| 79 | |
| 80 | it('missing digest (empty string spec) is rejected before download starts', () => { |
| 81 | const r = validateIntegritySpec('', VALID_SIZE); |
| 82 | assert.equal(r.ok, false); |
| 83 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.MALFORMED_SPEC); |
| 84 | }); |
| 85 | |
| 86 | it('missing digest (null) is rejected', () => { |
| 87 | const r = validateIntegritySpec(null, VALID_SIZE); |
| 88 | assert.equal(r.ok, false); |
| 89 | }); |
| 90 | |
| 91 | it('accumulator: digest mismatch from correct-length corrupted data → DIGEST_MISMATCH', () => { |
| 92 | const acc = createIntegrityAccumulator({ |
| 93 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 94 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 95 | }); |
| 96 | const corrupted = Buffer.from(VALID_DATA); |
| 97 | corrupted[0] ^= 0x01; // flip one bit |
| 98 | acc.update(corrupted); |
| 99 | const verdict = acc.finalize(); |
| 100 | assert.equal(verdict.ok, false); |
| 101 | assert.equal(verdict.reason, RUNTIME_MANAGER_REASONS.DIGEST_MISMATCH); |
| 102 | }); |
| 103 | |
| 104 | it('accumulator: size mismatch (one extra byte appended) → SIZE_MISMATCH', () => { |
| 105 | const acc = createIntegrityAccumulator({ |
| 106 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 107 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 108 | }); |
| 109 | acc.update(VALID_DATA); |
| 110 | acc.update(Buffer.from([0x00])); // one extra byte |
| 111 | const verdict = acc.finalize(); |
| 112 | assert.equal(verdict.ok, false); |
| 113 | assert.equal(verdict.reason, RUNTIME_MANAGER_REASONS.SIZE_MISMATCH); |
| 114 | }); |
| 115 | |
| 116 | it('accumulator: oversized download rejected when expectedSizeBytes is correct but more arrives', () => { |
| 117 | // An attacker delivers more bytes than declared — size mismatch |
| 118 | const declared = VALID_SIZE; |
| 119 | const acc = createIntegrityAccumulator({ |
| 120 | expectedDigest: VALID_DIGEST, expectedSizeBytes: declared, |
| 121 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 122 | }); |
| 123 | // Feed declared + 1000 extra bytes |
| 124 | acc.update(VALID_DATA); |
| 125 | acc.update(Buffer.alloc(1000, 0x42)); // extra bytes from attacker |
| 126 | const verdict = acc.finalize(); |
| 127 | assert.equal(verdict.ok, false); |
| 128 | assert.equal(verdict.reason, RUNTIME_MANAGER_REASONS.SIZE_MISMATCH); |
| 129 | }); |
| 130 | |
| 131 | it('HTTP source URL is rejected at spec-validation time (not download time)', () => { |
| 132 | const r = validateSourceUrl('http://models.example.com/model.bin', ALLOWED_URLS); |
| 133 | assert.equal(r.ok, false); |
| 134 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SCHEME_NOT_ALLOWED); |
| 135 | }); |
| 136 | |
| 137 | it('foreign source URL (not in allowlist) is rejected', () => { |
| 138 | const r = validateSourceUrl('https://evil-models.net/model.bin', ALLOWED_URLS); |
| 139 | assert.equal(r.ok, false); |
| 140 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SOURCE_NOT_ALLOWED); |
| 141 | }); |
| 142 | |
| 143 | it('empty allowlist rejects any URL — fail-closed', () => { |
| 144 | const r = validateSourceUrl(VALID_URL, []); |
| 145 | assert.equal(r.ok, false); |
| 146 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.SOURCE_NOT_ALLOWED); |
| 147 | }); |
| 148 | |
| 149 | it('accumulator throws when constructed with HTTP source URL', () => { |
| 150 | assert.throws( |
| 151 | () => createIntegrityAccumulator({ |
| 152 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 153 | sourceUrl: 'http://models.example.com/model.bin', allowedSourceUrls: ALLOWED_URLS, |
| 154 | }), |
| 155 | (err) => { |
| 156 | assert.ok(err instanceof TypeError); |
| 157 | // Error message must NOT contain the actual URL or digest (no secret leak) |
| 158 | assert.ok(!err.message.includes('http://models.example.com'), 'URL leaked in error'); |
| 159 | return true; |
| 160 | }, |
| 161 | ); |
| 162 | }); |
| 163 | |
| 164 | it('accumulator throws when constructed with foreign source URL not in allowlist', () => { |
| 165 | assert.throws( |
| 166 | () => createIntegrityAccumulator({ |
| 167 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 168 | sourceUrl: 'https://evil-models.net/model.bin', allowedSourceUrls: ALLOWED_URLS, |
| 169 | }), |
| 170 | (err) => { |
| 171 | assert.ok(err instanceof TypeError); |
| 172 | assert.ok(!err.message.includes('evil-models.net'), 'URL leaked in error'); |
| 173 | return true; |
| 174 | }, |
| 175 | ); |
| 176 | }); |
| 177 | }); |
| 178 | |
| 179 | // ── (a) SUPPLY-CHAIN — no execution on unverified bytes ────────────────────── |
| 180 | |
| 181 | describe('security: no execution path possible without integrity passing', () => { |
| 182 | it('lifecycle gate can only reach ready via health_ok after start', () => { |
| 183 | // There is no direct "stopped → ready" transition |
| 184 | const r = transitionLifecycle(createLifecycleState(), LIFECYCLE_EVENTS.HEALTH_OK); |
| 185 | assert.equal(r.ok, false); // invalid transition from stopped |
| 186 | |
| 187 | // Starting → ready requires health_ok, not any other event |
| 188 | const s = { state: LIFECYCLE_STATES.STARTING }; |
| 189 | const r2 = transitionLifecycle(s, LIFECYCLE_EVENTS.DRAIN); |
| 190 | assert.equal(r2.ok, false); |
| 191 | const r3 = transitionLifecycle(s, LIFECYCLE_EVENTS.STOPPED); |
| 192 | assert.equal(r3.ok, false); |
| 193 | const r4 = transitionLifecycle(s, LIFECYCLE_EVENTS.START); |
| 194 | assert.equal(r4.ok, false); |
| 195 | }); |
| 196 | |
| 197 | it('canServeInference is false in all non-ready states', () => { |
| 198 | assert.equal(canServeInference({ state: LIFECYCLE_STATES.STOPPED }), false); |
| 199 | assert.equal(canServeInference({ state: LIFECYCLE_STATES.STARTING }), false); |
| 200 | assert.equal(canServeInference({ state: LIFECYCLE_STATES.DRAINING }), false); |
| 201 | assert.equal(canServeInference({ state: LIFECYCLE_STATES.READY }), true); // only this |
| 202 | }); |
| 203 | }); |
| 204 | |
| 205 | // ── (b) RESOURCE EXHAUSTION — backpressure ──────────────────────────────────── |
| 206 | |
| 207 | describe('security: backpressure — flood of requests hits bound, never OOM overflow', () => { |
| 208 | it('backpressure trips at exact maxInFlight boundary (100 requests)', () => { |
| 209 | const MAX = 100; |
| 210 | let admission = createAdmissionState({ maxInFlight: MAX, queueBound: 50 }); |
| 211 | |
| 212 | for (let i = 0; i < MAX; i++) admission = recordInFlight(admission); |
| 213 | |
| 214 | // Exactly at the boundary — next request is AT_CAPACITY |
| 215 | const r = evaluateAdmission(admission); |
| 216 | assert.equal(r.ok, false); |
| 217 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.AT_CAPACITY); |
| 218 | assert.equal(admission.inFlight, MAX); // exact count, no overflow |
| 219 | }); |
| 220 | |
| 221 | it('queue_full trips at exact queueBound — third layer of defence', () => { |
| 222 | const MAX_F = 2, MAX_Q = 5; |
| 223 | let admission = createAdmissionState({ maxInFlight: MAX_F, queueBound: MAX_Q }); |
| 224 | for (let i = 0; i < MAX_F; i++) admission = recordInFlight(admission); |
| 225 | admission = { ...admission, queued: MAX_Q }; |
| 226 | |
| 227 | const r = evaluateAdmission(admission); |
| 228 | assert.equal(r.ok, false); |
| 229 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.QUEUE_FULL); |
| 230 | }); |
| 231 | |
| 232 | it('evaluateRuntimeRequest blocks when lifecycle is not ready (flood cannot bypass)', () => { |
| 233 | const floodAdmission = createAdmissionState({ maxInFlight: 1000, queueBound: 1000 }); |
| 234 | const stopped = createLifecycleState(); |
| 235 | // Even with unlimited admission capacity, lifecycle blocks all |
| 236 | for (let i = 0; i < 1000; i++) { |
| 237 | const r = evaluateRuntimeRequest({ |
| 238 | lifecycleState: stopped, admissionState: floodAdmission, |
| 239 | resourceObservation: VALID_OBS, resourceLimits: VALID_LIMITS, |
| 240 | }); |
| 241 | assert.equal(r.ok, false); |
| 242 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.NOT_READY); |
| 243 | } |
| 244 | }); |
| 245 | }); |
| 246 | |
| 247 | // ── (b) RESOURCE EXHAUSTION — over-limit rejection ─────────────────────────── |
| 248 | |
| 249 | describe('security: resource limits prevent OOM', () => { |
| 250 | it('RAM over limit → rejected before inference', () => { |
| 251 | const obs = { ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes + 1 }; |
| 252 | const r = evaluateRuntimeRequest({ |
| 253 | lifecycleState: READY, admissionState: createAdmissionState({ maxInFlight: 10, queueBound: 20 }), |
| 254 | resourceObservation: obs, resourceLimits: VALID_LIMITS, |
| 255 | }); |
| 256 | assert.equal(r.ok, false); |
| 257 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.RAM_OVER_LIMIT); |
| 258 | }); |
| 259 | |
| 260 | it('VRAM over limit → rejected before inference', () => { |
| 261 | const obs = { ...VALID_OBS, vramBytes: VALID_LIMITS.maxVramBytes + 1 }; |
| 262 | const r = evaluateRuntimeRequest({ |
| 263 | lifecycleState: READY, admissionState: createAdmissionState({ maxInFlight: 10, queueBound: 20 }), |
| 264 | resourceObservation: obs, resourceLimits: VALID_LIMITS, |
| 265 | }); |
| 266 | assert.equal(r.ok, false); |
| 267 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.VRAM_OVER_LIMIT); |
| 268 | }); |
| 269 | |
| 270 | it('CPU over limit → rejected before inference', () => { |
| 271 | const obs = { ...VALID_OBS, cpuPercent: VALID_LIMITS.maxCpuPercent + 1 }; |
| 272 | const r = evaluateRuntimeRequest({ |
| 273 | lifecycleState: READY, admissionState: createAdmissionState({ maxInFlight: 10, queueBound: 20 }), |
| 274 | resourceObservation: obs, resourceLimits: VALID_LIMITS, |
| 275 | }); |
| 276 | assert.equal(r.ok, false); |
| 277 | assert.equal(r.reason, RUNTIME_MANAGER_REASONS.CPU_OVER_LIMIT); |
| 278 | }); |
| 279 | }); |
| 280 | |
| 281 | // ── (c) AMBIENT AUTHORITY — no vault/JWT/canister in module exports ─────────── |
| 282 | |
| 283 | describe('security: no ambient authority in module exports', () => { |
| 284 | it('module exports do not include any vault, keychain, canister, or JWT accessor', async () => { |
| 285 | const mod = await import('../lib/companion-runtime-manager.mjs'); |
| 286 | const exports = Object.keys(mod); |
| 287 | const forbidden = ['vault', 'keychain', 'jwt', 'token', 'canister', 'session', 'auth', 'secret']; |
| 288 | for (const key of exports) { |
| 289 | for (const bad of forbidden) { |
| 290 | assert.ok( |
| 291 | !key.toLowerCase().includes(bad), |
| 292 | `Export "${key}" looks like it exposes a sensitive authority (contains "${bad}")`, |
| 293 | ); |
| 294 | } |
| 295 | } |
| 296 | }); |
| 297 | |
| 298 | it('evaluateRuntimeRequest returns a verdict with ok and reason only — no embedded secrets', () => { |
| 299 | const r = evaluateRuntimeRequest({ |
| 300 | lifecycleState: READY, |
| 301 | admissionState: createAdmissionState({ maxInFlight: 4, queueBound: 8 }), |
| 302 | resourceObservation: VALID_OBS, |
| 303 | resourceLimits: VALID_LIMITS, |
| 304 | }); |
| 305 | // Must have exactly ok and reason; no extra properties carrying data |
| 306 | const keys = Object.keys(r); |
| 307 | assert.deepEqual(keys.sort(), ['ok', 'reason'].sort()); |
| 308 | assert.equal(typeof r.ok, 'boolean'); |
| 309 | assert.equal(typeof r.reason, 'string'); |
| 310 | }); |
| 311 | }); |
| 312 | |
| 313 | // ── (c) NO SECRET IN REASON STRINGS ────────────────────────────────────────── |
| 314 | |
| 315 | describe('security: no secret/path/URL/digest in reason strings', () => { |
| 316 | const secretPatterns = [ |
| 317 | VALID_URL, |
| 318 | VALID_DIGEST, |
| 319 | 'models.example.com', |
| 320 | 'https://', |
| 321 | 'http://', |
| 322 | '/model.bin', |
| 323 | ]; |
| 324 | |
| 325 | function assertNoSecretInReason(r, label) { |
| 326 | for (const pattern of secretPatterns) { |
| 327 | assert.ok( |
| 328 | !r.reason.includes(pattern), |
| 329 | `Reason "${r.reason}" contains secret pattern "${pattern}" in test: ${label}`, |
| 330 | ); |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | it('validateSourceUrl failure reasons contain no URL', () => { |
| 335 | assertNoSecretInReason(validateSourceUrl('http://models.example.com/m', ALLOWED_URLS), 'http src'); |
| 336 | assertNoSecretInReason(validateSourceUrl('https://evil.net/m', ALLOWED_URLS), 'foreign src'); |
| 337 | assertNoSecretInReason(validateSourceUrl('bad url', ALLOWED_URLS), 'bad url'); |
| 338 | }); |
| 339 | |
| 340 | it('verifyModelBytes failure reasons contain no digest or URL', () => { |
| 341 | assertNoSecretInReason( |
| 342 | verifyModelBytes({ fileData: VALID_DATA, expectedDigest: '0'.repeat(64), expectedSizeBytes: VALID_SIZE, sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS }), |
| 343 | 'wrong digest', |
| 344 | ); |
| 345 | assertNoSecretInReason( |
| 346 | verifyModelBytes({ fileData: VALID_DATA, expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE + 1, sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS }), |
| 347 | 'size mismatch', |
| 348 | ); |
| 349 | }); |
| 350 | |
| 351 | it('evaluateRuntimeRequest failure reasons contain no observation values', () => { |
| 352 | const obs = { ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes + 1 }; |
| 353 | const r = evaluateRuntimeRequest({ |
| 354 | lifecycleState: READY, admissionState: createAdmissionState({ maxInFlight: 4, queueBound: 8 }), |
| 355 | resourceObservation: obs, resourceLimits: VALID_LIMITS, |
| 356 | }); |
| 357 | // Reason must not contain numeric values from the observation |
| 358 | assert.ok(!r.reason.includes(String(obs.ramBytes)), 'ramBytes leaked in reason'); |
| 359 | }); |
| 360 | |
| 361 | it('accumulator finalize failure reasons are fixed constants', () => { |
| 362 | const acc = createIntegrityAccumulator({ |
| 363 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 364 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 365 | }); |
| 366 | acc.update(Buffer.from('x'.repeat(VALID_SIZE))); // wrong content |
| 367 | const verdict = acc.finalize(); |
| 368 | assertNoSecretInReason(verdict, 'digest mismatch'); |
| 369 | }); |
| 370 | }); |
| 371 | |
| 372 | // ── (d) CONSTANT-TIME DIGEST COMPARISON ────────────────────────────────────── |
| 373 | |
| 374 | describe('security: constant-time digest comparison in integrity accumulator', () => { |
| 375 | it('timing is not correlated with prefix match length (statistical bound)', () => { |
| 376 | // We measure timing for: all-zeros wrong digest vs. correct-prefix-but-wrong-suffix digest. |
| 377 | // Both should produce DIGEST_MISMATCH in similar time. This is a statistical test; |
| 378 | // the hash-of-hash approach in the implementation makes the comparison truly constant-time. |
| 379 | const N = 100; |
| 380 | |
| 381 | // Prefix matches perfectly for many chars then diverges |
| 382 | const prefixWrong = VALID_DIGEST.slice(0, 60) + '0000'; |
| 383 | // Completely different digest |
| 384 | const allWrong = '0'.repeat(64); |
| 385 | |
| 386 | const timeWith = (digest) => { |
| 387 | const start = performance.now(); |
| 388 | for (let i = 0; i < N; i++) { |
| 389 | const acc = createIntegrityAccumulator({ |
| 390 | expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE, |
| 391 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 392 | }); |
| 393 | acc.update(VALID_DATA); |
| 394 | // Override internal state by creating a new acc with the attacker's digest as expected |
| 395 | // Actually test via verifyModelBytes which uses the same constant-time path |
| 396 | } |
| 397 | return performance.now() - start; |
| 398 | }; |
| 399 | |
| 400 | // Use verifyModelBytes which exposes the same comparison path |
| 401 | const timeVerify = (digest) => { |
| 402 | const start = performance.now(); |
| 403 | for (let i = 0; i < N; i++) { |
| 404 | verifyModelBytes({ |
| 405 | fileData: VALID_DATA, expectedDigest: digest, expectedSizeBytes: VALID_SIZE, |
| 406 | sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS, |
| 407 | }); |
| 408 | } |
| 409 | return performance.now() - start; |
| 410 | }; |
| 411 | |
| 412 | const tAll = timeVerify(allWrong); |
| 413 | const tPrefix = timeVerify(prefixWrong); |
| 414 | // Allow up to 5× difference — a true timing oracle would be 10-100×. |
| 415 | // This is a sanity check, not a rigorous timing test (which requires controlled hardware). |
| 416 | const ratio = Math.max(tAll, tPrefix) / Math.min(tAll, tPrefix); |
| 417 | assert.ok(ratio < 5, `Timing ratio ${ratio.toFixed(2)} suggests timing oracle. tAll=${tAll.toFixed(2)}ms tPrefix=${tPrefix.toFixed(2)}ms`); |
| 418 | }); |
| 419 | }); |
| 420 | |
| 421 | // ── (c) FAIL-CLOSED POSTURE — any ambiguous/null input denies ───────────────── |
| 422 | |
| 423 | describe('security: global fail-closed posture', () => { |
| 424 | const nullInputCases = [ |
| 425 | { fn: () => evaluateRuntimeRequest(null), label: 'null params' }, |
| 426 | { fn: () => evaluateRuntimeRequest({}), label: 'empty params' }, |
| 427 | { fn: () => evaluateRuntimeRequest({ lifecycleState: null, admissionState: null, resourceObservation: null, resourceLimits: null }), label: 'all null fields' }, |
| 428 | { fn: () => evaluateAdmission(null), label: 'null admission' }, |
| 429 | { fn: () => evaluateAdmission(undefined), label: 'undefined admission' }, |
| 430 | { fn: () => evaluateResourceLimits(null, VALID_LIMITS), label: 'null observation' }, |
| 431 | { fn: () => evaluateResourceLimits(VALID_OBS, null), label: 'null limits' }, |
| 432 | { fn: () => transitionLifecycle(null, LIFECYCLE_EVENTS.START), label: 'null lifecycle state' }, |
| 433 | ]; |
| 434 | |
| 435 | for (const { fn, label } of nullInputCases) { |
| 436 | it(`${label} → deny (ok:false)`, () => { |
| 437 | const r = fn(); |
| 438 | assert.equal(r.ok, false, `expected ok:false for: ${label}`); |
| 439 | }); |
| 440 | } |
| 441 | |
| 442 | it('evaluateRuntimeRequest never throws on any input', () => { |
| 443 | const throwingInputs = [null, undefined, 'string', 42, [], () => {}]; |
| 444 | for (const inp of throwingInputs) { |
| 445 | assert.doesNotThrow( |
| 446 | () => evaluateRuntimeRequest(inp), |
| 447 | `threw on input: ${JSON.stringify(inp)}`, |
| 448 | ); |
| 449 | } |
| 450 | }); |
| 451 | }); |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago