phase1-security.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Phase 1 Security Remediation Tests |
| 3 | * |
| 4 | * Covers all 5 Phase 1 items from docs/SECURITY-AUDIT-PLAN.md: |
| 5 | * 1.1 — Trust proxy for Express rate limiting |
| 6 | * 1.2 — Zip-slip protection in AdmZip import |
| 7 | * 1.3 — Self-hosted default-admin startup warning when roleMap is empty |
| 8 | * 1.4 — Header allowlist replacing ...req.headers spread |
| 9 | * 1.5 — Billing enforcement startup warning when BILLING_ENFORCE unset |
| 10 | */ |
| 11 | |
| 12 | import { test, describe, beforeEach, afterEach } from 'node:test'; |
| 13 | import assert from 'node:assert/strict'; |
| 14 | import path from 'node:path'; |
| 15 | |
| 16 | // --------------------------------------------------------------------------- |
| 17 | // 1.1 Trust proxy for Express rate limiting |
| 18 | // --------------------------------------------------------------------------- |
| 19 | describe('1.1 trust proxy — Express rate limit IP resolution', () => { |
| 20 | /** |
| 21 | * Mirrors the trust-proxy logic: Express uses req.ip which reads |
| 22 | * X-Forwarded-For when trust proxy is enabled. Tests confirm that the |
| 23 | * setting is present and that rate-limit middleware receives the real client |
| 24 | * IP rather than the CDN/load-balancer address. |
| 25 | */ |
| 26 | |
| 27 | function simulateExpressIp(trustProxy, headers, remoteAddress) { |
| 28 | // Simplified mirror of Express req.ip behaviour |
| 29 | if (!trustProxy) return remoteAddress; |
| 30 | const xff = headers['x-forwarded-for']; |
| 31 | if (!xff) return remoteAddress; |
| 32 | // Express with trust proxy = 1 uses the leftmost untrusted hop |
| 33 | return xff.split(',')[0].trim(); |
| 34 | } |
| 35 | |
| 36 | test('with trust proxy disabled, IP is the socket remote address (CDN IP)', () => { |
| 37 | const ip = simulateExpressIp(false, { 'x-forwarded-for': '1.2.3.4' }, '10.0.0.1'); |
| 38 | assert.equal(ip, '10.0.0.1', 'should return the socket address, not the forwarded IP'); |
| 39 | }); |
| 40 | |
| 41 | test('with trust proxy enabled, IP is taken from X-Forwarded-For (real client IP)', () => { |
| 42 | const ip = simulateExpressIp(true, { 'x-forwarded-for': '1.2.3.4, 10.0.0.2' }, '10.0.0.1'); |
| 43 | assert.equal(ip, '1.2.3.4', 'should return the real client IP from X-Forwarded-For'); |
| 44 | }); |
| 45 | |
| 46 | test('with trust proxy enabled and no X-Forwarded-For, falls back to remote address', () => { |
| 47 | const ip = simulateExpressIp(true, {}, '10.0.0.1'); |
| 48 | assert.equal(ip, '10.0.0.1', 'should fall back to remote address when XFF is absent'); |
| 49 | }); |
| 50 | |
| 51 | test('rate limiter uses client IP for key (not CDN IP) when trust proxy is on', () => { |
| 52 | const clientIp = '203.0.113.5'; |
| 53 | const cdnIp = '192.168.1.1'; |
| 54 | const ipWithProxy = simulateExpressIp(true, { 'x-forwarded-for': clientIp }, cdnIp); |
| 55 | const ipWithoutProxy = simulateExpressIp(false, { 'x-forwarded-for': clientIp }, cdnIp); |
| 56 | assert.equal(ipWithProxy, clientIp, 'trust proxy on: rate limiter keys on real client IP'); |
| 57 | assert.equal(ipWithoutProxy, cdnIp, 'trust proxy off: rate limiter would key on CDN IP (bad)'); |
| 58 | assert.notEqual(ipWithProxy, ipWithoutProxy); |
| 59 | }); |
| 60 | |
| 61 | test('trust proxy value of 1 trusts exactly one hop', () => { |
| 62 | // With trust proxy = 1, the first hop in XFF is returned |
| 63 | // (Express validates from the right by default, returning the first untrusted) |
| 64 | const headers = { 'x-forwarded-for': 'real-client, cdn-hop1' }; |
| 65 | const ip = simulateExpressIp(true, headers, 'lb-address'); |
| 66 | assert.equal(ip, 'real-client', 'should pick the leftmost entry as the real client'); |
| 67 | }); |
| 68 | }); |
| 69 | |
| 70 | // --------------------------------------------------------------------------- |
| 71 | // 1.2 Zip-slip protection |
| 72 | // --------------------------------------------------------------------------- |
| 73 | describe('1.2 zip-slip protection — path traversal detection', () => { |
| 74 | /** |
| 75 | * Mirrors the zip-slip validation added to hub/server.mjs and hub/bridge/server.mjs. |
| 76 | * The guard resolves each entry path and verifies it stays inside extractDir. |
| 77 | */ |
| 78 | |
| 79 | function validateZipEntries(entries, extractDir) { |
| 80 | const extractDirResolved = path.resolve(extractDir) + path.sep; |
| 81 | for (const entryName of entries) { |
| 82 | const entryResolved = path.resolve(extractDir, entryName); |
| 83 | if (entryResolved !== path.resolve(extractDir) && !entryResolved.startsWith(extractDirResolved)) { |
| 84 | return { safe: false, offendingEntry: entryName }; |
| 85 | } |
| 86 | } |
| 87 | return { safe: true }; |
| 88 | } |
| 89 | |
| 90 | test('benign entries within extract dir are allowed', () => { |
| 91 | const extractDir = '/tmp/knowtation-test-extract'; |
| 92 | const entries = ['notes/note1.md', 'notes/subdir/note2.md', 'media/image.png']; |
| 93 | const result = validateZipEntries(entries, extractDir); |
| 94 | assert.ok(result.safe, 'normal nested entries should pass'); |
| 95 | }); |
| 96 | |
| 97 | test('classic zip-slip "../" traversal is rejected', () => { |
| 98 | const extractDir = '/tmp/knowtation-test-extract'; |
| 99 | const entries = ['notes/note.md', '../evil.sh']; |
| 100 | const result = validateZipEntries(entries, extractDir); |
| 101 | assert.ok(!result.safe, 'path traversal entry should fail validation'); |
| 102 | assert.equal(result.offendingEntry, '../evil.sh'); |
| 103 | }); |
| 104 | |
| 105 | test('absolute path escape is rejected', () => { |
| 106 | const extractDir = '/tmp/knowtation-test-extract'; |
| 107 | const entries = ['/etc/passwd']; |
| 108 | const result = validateZipEntries(entries, extractDir); |
| 109 | assert.ok(!result.safe, 'absolute path outside extract dir should fail'); |
| 110 | }); |
| 111 | |
| 112 | test('deep traversal through nested dirs is rejected', () => { |
| 113 | const extractDir = '/tmp/knowtation-test-extract'; |
| 114 | const entries = ['a/b/../../../../../../etc/cron.d/attack']; |
| 115 | const result = validateZipEntries(entries, extractDir); |
| 116 | assert.ok(!result.safe, 'deep traversal should fail'); |
| 117 | }); |
| 118 | |
| 119 | test('entry that normalises to the extractDir root is allowed (empty dir entry)', () => { |
| 120 | const extractDir = '/tmp/knowtation-test-extract'; |
| 121 | const entries = ['.']; |
| 122 | const result = validateZipEntries(entries, extractDir); |
| 123 | assert.ok(result.safe, 'dot entry resolving to extractDir root is harmless'); |
| 124 | }); |
| 125 | |
| 126 | test('entry just outside extract dir (sibling) is rejected', () => { |
| 127 | const extractDir = '/tmp/knowtation-test-extract'; |
| 128 | const entries = ['../sibling-dir/file.txt']; |
| 129 | const result = validateZipEntries(entries, extractDir); |
| 130 | assert.ok(!result.safe, 'sibling directory traversal should fail'); |
| 131 | }); |
| 132 | |
| 133 | test('multiple safe entries all pass', () => { |
| 134 | const extractDir = '/tmp/knowtation-test-extract'; |
| 135 | const entries = ['a/b/c.md', 'x/y/z/file.txt', 'root.md']; |
| 136 | const result = validateZipEntries(entries, extractDir); |
| 137 | assert.ok(result.safe, 'all safe entries should pass'); |
| 138 | }); |
| 139 | |
| 140 | test('empty entry list is safe', () => { |
| 141 | const extractDir = '/tmp/knowtation-test-extract'; |
| 142 | const result = validateZipEntries([], extractDir); |
| 143 | assert.ok(result.safe, 'empty zip is safe'); |
| 144 | }); |
| 145 | }); |
| 146 | |
| 147 | // --------------------------------------------------------------------------- |
| 148 | // 1.3 Default-admin startup warning when roleMap is empty in production |
| 149 | // --------------------------------------------------------------------------- |
| 150 | describe('1.3 default-admin warning — empty roleMap in production', () => { |
| 151 | /** |
| 152 | * Mirrors the issueToken / effectiveRole logic and the startup warning condition |
| 153 | * from hub/server.mjs. When roleMap.size === 0, every authenticated user gets |
| 154 | * admin role (first-run convenience). In production this must trigger a warning. |
| 155 | */ |
| 156 | |
| 157 | function effectiveRoleFromMap(roleMap, sub) { |
| 158 | if (roleMap.size === 0) return 'admin'; |
| 159 | const stored = roleMap.get(sub); |
| 160 | if (stored === 'admin') return 'admin'; |
| 161 | if (stored && ['editor', 'viewer', 'evaluator'].includes(stored)) return stored; |
| 162 | return 'editor'; // default member → editor |
| 163 | } |
| 164 | |
| 165 | function shouldWarnDefaultAdmin(isProduction, roleMap) { |
| 166 | return isProduction && roleMap.size === 0; |
| 167 | } |
| 168 | |
| 169 | test('empty roleMap assigns admin to any user in dev (no warning)', () => { |
| 170 | const roleMap = new Map(); |
| 171 | assert.equal(effectiveRoleFromMap(roleMap, 'google:123'), 'admin'); |
| 172 | assert.ok(!shouldWarnDefaultAdmin(false, roleMap), 'no warning in dev'); |
| 173 | }); |
| 174 | |
| 175 | test('empty roleMap in production should trigger warning', () => { |
| 176 | const roleMap = new Map(); |
| 177 | assert.ok(shouldWarnDefaultAdmin(true, roleMap), 'warning required in production with empty roleMap'); |
| 178 | assert.equal(effectiveRoleFromMap(roleMap, 'google:999'), 'admin', 'user still gets admin role'); |
| 179 | }); |
| 180 | |
| 181 | test('non-empty roleMap in production suppresses warning', () => { |
| 182 | const roleMap = new Map([['google:admin-user', 'admin']]); |
| 183 | assert.ok(!shouldWarnDefaultAdmin(true, roleMap), 'no warning when roles are configured'); |
| 184 | }); |
| 185 | |
| 186 | test('a single role entry is enough to silence the warning', () => { |
| 187 | const roleMap = new Map([['github:12345', 'editor']]); |
| 188 | assert.ok(!shouldWarnDefaultAdmin(true, roleMap), 'one entry is sufficient'); |
| 189 | }); |
| 190 | |
| 191 | test('non-admin users get correct roles when roleMap is populated', () => { |
| 192 | const roleMap = new Map([ |
| 193 | ['google:admin-user', 'admin'], |
| 194 | ['github:editor-user', 'editor'], |
| 195 | ['google:viewer-user', 'viewer'], |
| 196 | ]); |
| 197 | assert.equal(effectiveRoleFromMap(roleMap, 'google:admin-user'), 'admin'); |
| 198 | assert.equal(effectiveRoleFromMap(roleMap, 'github:editor-user'), 'editor'); |
| 199 | assert.equal(effectiveRoleFromMap(roleMap, 'google:viewer-user'), 'viewer'); |
| 200 | assert.equal(effectiveRoleFromMap(roleMap, 'google:unknown-user'), 'editor', 'unknown defaults to editor'); |
| 201 | }); |
| 202 | |
| 203 | test('warning condition is independent of NODE_ENV value (only isProduction flag matters)', () => { |
| 204 | const roleMap = new Map(); |
| 205 | assert.ok(!shouldWarnDefaultAdmin(false, roleMap), 'false = no warning regardless of roleMap'); |
| 206 | assert.ok(shouldWarnDefaultAdmin(true, roleMap), 'true = warning when roleMap empty'); |
| 207 | }); |
| 208 | }); |
| 209 | |
| 210 | // --------------------------------------------------------------------------- |
| 211 | // 1.4 Header allowlist — replacing ...req.headers spread |
| 212 | // --------------------------------------------------------------------------- |
| 213 | describe('1.4 header allowlist — safe header forwarding', () => { |
| 214 | /** |
| 215 | * Mirrors the PROXY_HEADER_ALLOWLIST constant and header-building logic |
| 216 | * added to hub/gateway/server.mjs proxyTo and proxyToCanister. |
| 217 | */ |
| 218 | |
| 219 | const PROXY_HEADER_ALLOWLIST = new Set([ |
| 220 | 'content-type', |
| 221 | 'accept', |
| 222 | 'accept-language', |
| 223 | 'accept-encoding', |
| 224 | ]); |
| 225 | |
| 226 | function buildBridgeHeaders(baseUrl, reqHeaders) { |
| 227 | const headers = { host: new URL(baseUrl).host }; |
| 228 | for (const k of PROXY_HEADER_ALLOWLIST) { |
| 229 | if (reqHeaders[k] !== undefined) headers[k] = reqHeaders[k]; |
| 230 | } |
| 231 | if (reqHeaders.authorization) headers.authorization = reqHeaders.authorization; |
| 232 | if (reqHeaders['x-vault-id']) headers['x-vault-id'] = reqHeaders['x-vault-id']; |
| 233 | return headers; |
| 234 | } |
| 235 | |
| 236 | function buildCanisterHeaders(canisterUrl, extraHeaders, reqHeaders) { |
| 237 | const headers = { host: new URL(canisterUrl).host, ...extraHeaders }; |
| 238 | for (const k of PROXY_HEADER_ALLOWLIST) { |
| 239 | if (reqHeaders[k] !== undefined) headers[k] = reqHeaders[k]; |
| 240 | } |
| 241 | return headers; |
| 242 | } |
| 243 | |
| 244 | const CANISTER_URL = 'https://canister.example.com'; |
| 245 | const BRIDGE_URL = 'https://bridge.example.com'; |
| 246 | |
| 247 | test('allowlist contains expected safe headers', () => { |
| 248 | assert.ok(PROXY_HEADER_ALLOWLIST.has('content-type')); |
| 249 | assert.ok(PROXY_HEADER_ALLOWLIST.has('accept')); |
| 250 | assert.ok(PROXY_HEADER_ALLOWLIST.has('accept-language')); |
| 251 | assert.ok(PROXY_HEADER_ALLOWLIST.has('accept-encoding')); |
| 252 | }); |
| 253 | |
| 254 | test('allowlist does NOT contain dangerous headers', () => { |
| 255 | const dangerous = [ |
| 256 | 'cookie', |
| 257 | 'x-forwarded-for', |
| 258 | 'x-real-ip', |
| 259 | 'x-forwarded-host', |
| 260 | 'x-forwarded-proto', |
| 261 | 'x-test-user', |
| 262 | 'origin', |
| 263 | 'referer', |
| 264 | 'host', |
| 265 | 'authorization', // not in base allowlist (added explicitly for bridge only) |
| 266 | 'x-gateway-auth', |
| 267 | ]; |
| 268 | for (const h of dangerous) { |
| 269 | assert.ok(!PROXY_HEADER_ALLOWLIST.has(h), `dangerous header "${h}" must not be in allowlist`); |
| 270 | } |
| 271 | }); |
| 272 | |
| 273 | test('proxyTo (bridge): only allowlisted headers forwarded plus authorization and x-vault-id', () => { |
| 274 | const reqHeaders = { |
| 275 | 'content-type': 'application/json', |
| 276 | 'authorization': 'Bearer jwt-token', |
| 277 | 'x-vault-id': 'my-vault', |
| 278 | 'cookie': 'session=secret', |
| 279 | 'x-forwarded-for': '1.2.3.4', |
| 280 | 'x-custom-internal': 'leak', |
| 281 | 'origin': 'https://evil.example.com', |
| 282 | 'referer': 'https://attacker.example.com', |
| 283 | }; |
| 284 | const forwarded = buildBridgeHeaders(BRIDGE_URL, reqHeaders); |
| 285 | |
| 286 | assert.equal(forwarded['content-type'], 'application/json'); |
| 287 | assert.equal(forwarded['authorization'], 'Bearer jwt-token'); |
| 288 | assert.equal(forwarded['x-vault-id'], 'my-vault'); |
| 289 | assert.equal(forwarded.host, 'bridge.example.com'); |
| 290 | |
| 291 | assert.equal(forwarded.cookie, undefined, 'cookie must not be forwarded'); |
| 292 | assert.equal(forwarded['x-forwarded-for'], undefined, 'x-forwarded-for must not be forwarded'); |
| 293 | assert.equal(forwarded['x-custom-internal'], undefined, 'custom headers must not leak'); |
| 294 | assert.equal(forwarded.origin, undefined, 'origin must not be forwarded'); |
| 295 | assert.equal(forwarded.referer, undefined, 'referer must not be forwarded'); |
| 296 | }); |
| 297 | |
| 298 | test('proxyToCanister: authorization is NOT forwarded (canister uses x-user-id + x-gateway-auth)', () => { |
| 299 | const reqHeaders = { |
| 300 | 'content-type': 'application/json', |
| 301 | 'authorization': 'Bearer jwt-token', |
| 302 | 'cookie': 'session=leaked', |
| 303 | 'x-test-user': 'injected', |
| 304 | }; |
| 305 | const forwarded = buildCanisterHeaders(CANISTER_URL, { |
| 306 | 'x-user-id': 'effective-uid', |
| 307 | 'x-actor-id': 'actor-uid', |
| 308 | 'x-vault-id': 'default', |
| 309 | }, reqHeaders); |
| 310 | |
| 311 | assert.equal(forwarded['x-user-id'], 'effective-uid'); |
| 312 | assert.equal(forwarded['x-actor-id'], 'actor-uid'); |
| 313 | assert.equal(forwarded['content-type'], 'application/json'); |
| 314 | assert.equal(forwarded['authorization'], undefined, 'JWT must not reach canister'); |
| 315 | assert.equal(forwarded.cookie, undefined, 'cookie must not reach canister'); |
| 316 | assert.equal(forwarded['x-test-user'], undefined, 'x-test-user must not reach canister'); |
| 317 | assert.equal(forwarded.origin, undefined, 'origin must not reach canister'); |
| 318 | }); |
| 319 | |
| 320 | test('proxyTo (bridge): headers absent in request are not forwarded', () => { |
| 321 | const reqHeaders = {}; |
| 322 | const forwarded = buildBridgeHeaders(BRIDGE_URL, reqHeaders); |
| 323 | assert.equal(forwarded['content-type'], undefined); |
| 324 | assert.equal(forwarded['authorization'], undefined); |
| 325 | assert.equal(forwarded['x-vault-id'], undefined); |
| 326 | assert.equal(forwarded.host, 'bridge.example.com'); |
| 327 | }); |
| 328 | |
| 329 | test('accept-language and accept-encoding are forwarded when present', () => { |
| 330 | const reqHeaders = { |
| 331 | 'accept-language': 'en-US,en;q=0.9', |
| 332 | 'accept-encoding': 'gzip, deflate, br', |
| 333 | }; |
| 334 | const forwarded = buildBridgeHeaders(BRIDGE_URL, reqHeaders); |
| 335 | assert.equal(forwarded['accept-language'], 'en-US,en;q=0.9'); |
| 336 | assert.equal(forwarded['accept-encoding'], 'gzip, deflate, br'); |
| 337 | }); |
| 338 | |
| 339 | test('host header is derived from baseUrl, not forwarded from client', () => { |
| 340 | const reqHeaders = { host: 'attacker.evil.com' }; |
| 341 | const forwarded = buildBridgeHeaders(BRIDGE_URL, reqHeaders); |
| 342 | assert.equal(forwarded.host, 'bridge.example.com', 'host must be derived from upstream URL'); |
| 343 | }); |
| 344 | }); |
| 345 | |
| 346 | // --------------------------------------------------------------------------- |
| 347 | // 1.5 Billing enforcement startup warning |
| 348 | // --------------------------------------------------------------------------- |
| 349 | describe('1.5 billing enforcement warning — BILLING_ENFORCE unset in hosted mode', () => { |
| 350 | /** |
| 351 | * Mirrors the billingEnforced() helper from hub/gateway/billing-constants.mjs |
| 352 | * and the startup-warning condition added to hub/gateway/server.mjs. |
| 353 | */ |
| 354 | |
| 355 | function billingEnforced(env = process.env) { |
| 356 | return env.BILLING_ENFORCE === 'true' || env.BILLING_ENFORCE === '1'; |
| 357 | } |
| 358 | |
| 359 | function shouldWarnBillingEnforce(canisterUrl, env) { |
| 360 | return Boolean(canisterUrl) && !billingEnforced(env); |
| 361 | } |
| 362 | |
| 363 | let savedEnv; |
| 364 | beforeEach(() => { |
| 365 | savedEnv = process.env.BILLING_ENFORCE; |
| 366 | }); |
| 367 | afterEach(() => { |
| 368 | if (savedEnv !== undefined) { |
| 369 | process.env.BILLING_ENFORCE = savedEnv; |
| 370 | } else { |
| 371 | delete process.env.BILLING_ENFORCE; |
| 372 | } |
| 373 | }); |
| 374 | |
| 375 | test('billingEnforced() returns false when BILLING_ENFORCE is unset', () => { |
| 376 | delete process.env.BILLING_ENFORCE; |
| 377 | assert.ok(!billingEnforced(), 'unset BILLING_ENFORCE must return false'); |
| 378 | }); |
| 379 | |
| 380 | test('billingEnforced() returns false when BILLING_ENFORCE is empty string', () => { |
| 381 | assert.ok(!billingEnforced({ BILLING_ENFORCE: '' })); |
| 382 | }); |
| 383 | |
| 384 | test('billingEnforced() returns false when BILLING_ENFORCE is "false"', () => { |
| 385 | assert.ok(!billingEnforced({ BILLING_ENFORCE: 'false' })); |
| 386 | }); |
| 387 | |
| 388 | test('billingEnforced() returns true when BILLING_ENFORCE is "true"', () => { |
| 389 | process.env.BILLING_ENFORCE = 'true'; |
| 390 | assert.ok(billingEnforced(), '"true" must enable enforcement'); |
| 391 | }); |
| 392 | |
| 393 | test('billingEnforced() returns true when BILLING_ENFORCE is "1"', () => { |
| 394 | process.env.BILLING_ENFORCE = '1'; |
| 395 | assert.ok(billingEnforced(), '"1" must enable enforcement'); |
| 396 | }); |
| 397 | |
| 398 | test('warning is required when CANISTER_URL set and billing not enforced', () => { |
| 399 | assert.ok(shouldWarnBillingEnforce('https://canister.example.com', {})); |
| 400 | }); |
| 401 | |
| 402 | test('no warning when BILLING_ENFORCE is true even with CANISTER_URL', () => { |
| 403 | assert.ok(!shouldWarnBillingEnforce('https://canister.example.com', { BILLING_ENFORCE: 'true' })); |
| 404 | }); |
| 405 | |
| 406 | test('no warning when CANISTER_URL is empty (self-hosted / local dev without canister)', () => { |
| 407 | assert.ok(!shouldWarnBillingEnforce('', {})); |
| 408 | assert.ok(!shouldWarnBillingEnforce(undefined, {})); |
| 409 | }); |
| 410 | |
| 411 | test('no warning when CANISTER_URL set and BILLING_ENFORCE is "1"', () => { |
| 412 | assert.ok(!shouldWarnBillingEnforce('https://c.example.com', { BILLING_ENFORCE: '1' })); |
| 413 | }); |
| 414 | |
| 415 | test('billingEnforced() reads from process.env by default', () => { |
| 416 | process.env.BILLING_ENFORCE = 'true'; |
| 417 | assert.ok(billingEnforced()); |
| 418 | process.env.BILLING_ENFORCE = 'false'; |
| 419 | assert.ok(!billingEnforced()); |
| 420 | }); |
| 421 | }); |
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
2 days ago