derived-artifact-storage-security.test.mjs
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb
reconcile: import GitHub-direct RBAC/OAuth/companion and ho…
Human
minor
⚠ breaking
8 hours ago
| 1 | /** |
| 2 | * Tier 7 — SECURITY (centerpiece): Phase 6 derived-artifact storage layer. |
| 3 | * |
| 4 | * Covers (§10 Security obligations — all build-blocking): |
| 5 | * P6-a/P6-b: NO privacy_max artifact ever written host-readable or under server-held key |
| 6 | * P6-j: NO plaintext fallback when encryption unavailable |
| 7 | * P6-c: Delegated write fail-closed without owner opt-in |
| 8 | * P6-d: NO tier downgrade by a delegate |
| 9 | * P6-e: Provenance cannot be forged or omitted |
| 10 | * P6-f: NO secret in any artifact, provenance field, log, or error |
| 11 | * P6-h: Single-writer no-bypass architecture test (BUILD-BLOCKING) |
| 12 | * P6-i: Runtime group imports no writer/encryptor/vault module (extends D5.8) |
| 13 | * Global: Convenience never masquerades as privacy_max and vice-versa |
| 14 | * Fail-closed posture on every ambiguous input |
| 15 | */ |
| 16 | |
| 17 | import { describe, it } from 'node:test'; |
| 18 | import assert from 'node:assert/strict'; |
| 19 | import { readFileSync, existsSync } from 'node:fs'; |
| 20 | import { resolve, join } from 'node:path'; |
| 21 | import { createRequire } from 'node:module'; |
| 22 | import { fileURLToPath } from 'node:url'; |
| 23 | |
| 24 | import { |
| 25 | createDerivedArtifactWriter, |
| 26 | WRITER_REASONS, |
| 27 | } from '../lib/companion-artifact-writer.mjs'; |
| 28 | |
| 29 | import { |
| 30 | createClientEncryptor, |
| 31 | UNAVAILABLE_CLIENT_ENCRYPTOR, |
| 32 | ENCRYPTOR_REASONS, |
| 33 | } from '../lib/companion-client-encryptor.mjs'; |
| 34 | |
| 35 | import { |
| 36 | buildConvenienceProvenance, |
| 37 | validateProvenance, |
| 38 | PROVENANCE_REJECT_REASONS, |
| 39 | } from '../lib/companion-provenance-validator.mjs'; |
| 40 | |
| 41 | import { |
| 42 | TERMINAL_STATES, |
| 43 | resolveTier, |
| 44 | TIER_RESOLVE_REASONS, |
| 45 | } from '../lib/companion-tier-resolver.mjs'; |
| 46 | |
| 47 | const __dirname = fileURLToPath(new URL('.', import.meta.url)); |
| 48 | const REPO_ROOT = resolve(__dirname, '..'); |
| 49 | |
| 50 | // ── Helpers ─────────────────────────────────────────────────────────────────── |
| 51 | |
| 52 | function buildSecStores() { |
| 53 | const written = []; |
| 54 | const vectors = []; |
| 55 | const insights = []; |
| 56 | |
| 57 | const writeNoteFn = (_vp, notePath, opts) => { |
| 58 | written.push({ notePath, frontmatter: { ...opts.frontmatter } }); |
| 59 | }; |
| 60 | |
| 61 | const vs = { |
| 62 | upsert: async (points) => { vectors.push(...points); }, |
| 63 | deleteByPath: async () => {}, |
| 64 | }; |
| 65 | |
| 66 | const mm = { |
| 67 | store: (type, data) => { |
| 68 | if (type === 'insight') insights.push(data); |
| 69 | return { id: 'x', ts: '' }; |
| 70 | }, |
| 71 | }; |
| 72 | |
| 73 | return { written, vectors, insights, writeNoteFn, vs, mm }; |
| 74 | } |
| 75 | |
| 76 | function selfCtx(overrides = {}) { |
| 77 | return { |
| 78 | lane: 'local', containsPrivateData: false, isDelegate: false, |
| 79 | delegatedManagedAllowed: false, enrichesDelegatedPartition: false, |
| 80 | delegatedEnrichmentAllowed: false, |
| 81 | ...overrides, |
| 82 | }; |
| 83 | } |
| 84 | |
| 85 | function baseProv(overrides = {}) { |
| 86 | return buildConvenienceProvenance({ |
| 87 | generatedBy: 'sec-user', |
| 88 | source: 'companion', |
| 89 | model: 'sec-model', |
| 90 | modelVersion: '1.0', |
| 91 | lane: 'local', |
| 92 | artifactType: 'ai_summary', |
| 93 | sourceNotePath: 'sec/note.md', |
| 94 | sourceEventId: 'mem_sec_001', |
| 95 | ...overrides, |
| 96 | }); |
| 97 | } |
| 98 | |
| 99 | // ── P6-a / P6-b: Privacy_max NEVER host-readable or server-held-key ─────────── |
| 100 | |
| 101 | describe('security P6-a/P6-b — privacy_max artifact never host-readable', () => { |
| 102 | it('privacy_max + unavailable encryptor → no write occurs (fail closed)', async () => { |
| 103 | const { written, writeNoteFn } = buildSecStores(); |
| 104 | const writer = createDerivedArtifactWriter({ |
| 105 | writeNoteFn, |
| 106 | vaultPath: '/vault', |
| 107 | vaultRegistryAvailable: true, // registry present → tier resolves to client_encrypted |
| 108 | // No encryptor → UNAVAILABLE_CLIENT_ENCRYPTOR (default) |
| 109 | }); |
| 110 | |
| 111 | const privProv = { ...baseProv(), privacy_tier: 'privacy_max' }; |
| 112 | const r = await writer.write({ summary: 'private content' }, privProv, selfCtx()); |
| 113 | |
| 114 | assert.equal(r.ok, false); |
| 115 | assert.equal(r.reason, WRITER_REASONS.ENCRYPTION_UNAVAILABLE); |
| 116 | assert.equal(written.length, 0, 'NOTHING must be written when privacy_max encryption unavailable'); |
| 117 | }); |
| 118 | |
| 119 | it('privacy_max + vault registry absent → tier rejected before encryption check', async () => { |
| 120 | const { written, writeNoteFn } = buildSecStores(); |
| 121 | const writer = createDerivedArtifactWriter({ |
| 122 | writeNoteFn, |
| 123 | vaultPath: '/vault', |
| 124 | vaultRegistryAvailable: false, // D6.1.1 — no registry → fail closed |
| 125 | }); |
| 126 | |
| 127 | const privProv = { ...baseProv(), privacy_tier: 'privacy_max' }; |
| 128 | const r = await writer.write({ summary: 'private' }, privProv, selfCtx()); |
| 129 | |
| 130 | assert.equal(r.ok, false); |
| 131 | assert.equal(r.reason, WRITER_REASONS.TIER_UNRESOLVABLE); |
| 132 | assert.equal(written.length, 0); |
| 133 | }); |
| 134 | |
| 135 | it('resolveTier: server-held-key equivalent → host_readable (only convenience may use it)', () => { |
| 136 | // Server-held-key = host_readable classification (D6.1.2) |
| 137 | // Convenience → host_readable: this is the ONLY class that may use server-held key |
| 138 | const r = resolveTier('ai_summary', 'convenience'); |
| 139 | assert.equal(r.ok, true); |
| 140 | assert.equal(r.terminalState, TERMINAL_STATES.HOST_READABLE); |
| 141 | |
| 142 | // Privacy_max NEVER resolves to host_readable |
| 143 | const rp = resolveTier('ai_summary', 'privacy_max', { vaultRegistryAvailable: true }); |
| 144 | assert.equal(rp.terminalState, TERMINAL_STATES.CLIENT_ENCRYPTED); |
| 145 | assert.notEqual(rp.terminalState, TERMINAL_STATES.HOST_READABLE, 'privacy_max must never be host_readable'); |
| 146 | }); |
| 147 | |
| 148 | it('privacy_max artifact never has a plaintext ai_summary in frontmatter', async () => { |
| 149 | const { written, writeNoteFn } = buildSecStores(); |
| 150 | |
| 151 | const workingEncryptor = createClientEncryptor({ |
| 152 | isAvailable: () => true, |
| 153 | encrypt: (bytes) => ({ |
| 154 | ciphertext: new Uint8Array(bytes.length).fill(0xee), |
| 155 | wrappedDekRef: 'dek-sec-001', |
| 156 | alg: 'STUB-256', |
| 157 | }), |
| 158 | }); |
| 159 | |
| 160 | const writer = createDerivedArtifactWriter({ |
| 161 | writeNoteFn, |
| 162 | vaultPath: '/vault', |
| 163 | vaultRegistryAvailable: true, |
| 164 | encryptor: workingEncryptor, |
| 165 | }); |
| 166 | |
| 167 | const privProv = { ...baseProv(), privacy_tier: 'privacy_max' }; |
| 168 | await writer.write({ summary: 'very private' }, privProv, selfCtx()); |
| 169 | |
| 170 | for (const w of written) { |
| 171 | assert.equal(w.frontmatter.ai_summary, undefined, |
| 172 | 'plaintext ai_summary must NEVER appear in frontmatter at privacy_max'); |
| 173 | } |
| 174 | }); |
| 175 | }); |
| 176 | |
| 177 | // ── P6-j: No plaintext fallback, ever ───────────────────────────────────────── |
| 178 | |
| 179 | describe('security P6-j — no plaintext fallback when encryption unavailable', () => { |
| 180 | it('UNAVAILABLE_CLIENT_ENCRYPTOR.encrypt throws, never returns plaintext', () => { |
| 181 | assert.throws( |
| 182 | () => UNAVAILABLE_CLIENT_ENCRYPTOR.encrypt(new Uint8Array([1, 2, 3]), { scope: 'vault' }), |
| 183 | (err) => err.message === ENCRYPTOR_REASONS.UNAVAILABLE, |
| 184 | ); |
| 185 | }); |
| 186 | |
| 187 | it('writer never writes plaintext when encrypt() throws', async () => { |
| 188 | const { written, writeNoteFn } = buildSecStores(); |
| 189 | const throwingEncryptor = createClientEncryptor({ |
| 190 | isAvailable: () => true, |
| 191 | encrypt: () => { throw new Error('key locked'); }, |
| 192 | }); |
| 193 | |
| 194 | const writer = createDerivedArtifactWriter({ |
| 195 | writeNoteFn, |
| 196 | vaultPath: '/vault', |
| 197 | vaultRegistryAvailable: true, |
| 198 | encryptor: throwingEncryptor, |
| 199 | }); |
| 200 | |
| 201 | const privProv = { ...baseProv(), privacy_tier: 'privacy_max' }; |
| 202 | const r = await writer.write({ summary: 'sensitive' }, privProv, selfCtx()); |
| 203 | |
| 204 | assert.equal(r.ok, false); |
| 205 | assert.equal(r.reason, WRITER_REASONS.ENCRYPTION_FAILED); |
| 206 | assert.equal(written.length, 0, 'Nothing must be written on encryption failure — no plaintext fallback'); |
| 207 | }); |
| 208 | |
| 209 | it('writer returns ENCRYPTION_UNAVAILABLE before any store call (no partial write)', async () => { |
| 210 | let storeCallCount = 0; |
| 211 | const countingWriter = (_vp, _np, _opts) => { storeCallCount++; }; |
| 212 | |
| 213 | const writer = createDerivedArtifactWriter({ |
| 214 | writeNoteFn: countingWriter, |
| 215 | vaultPath: '/vault', |
| 216 | vaultRegistryAvailable: true, |
| 217 | }); |
| 218 | |
| 219 | const privProv = { ...baseProv(), privacy_tier: 'privacy_max' }; |
| 220 | await writer.write({ summary: 'secret' }, privProv, selfCtx()); |
| 221 | |
| 222 | assert.equal(storeCallCount, 0, 'writeNoteFn must not be called before encryption succeeds'); |
| 223 | }); |
| 224 | }); |
| 225 | |
| 226 | // ── P6-c: Delegated write fail-closed without owner opt-in ──────────────────── |
| 227 | |
| 228 | describe('security P6-c — delegated write fail-closed (D6.3.3)', () => { |
| 229 | it('enrichesDelegatedPartition=true always blocked (D6.3.6: tenancy gate absent)', async () => { |
| 230 | const { written, writeNoteFn } = buildSecStores(); |
| 231 | const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); |
| 232 | |
| 233 | for (const delegatedEnrichmentAllowed of [false, true]) { |
| 234 | const r = await writer.write({ summary: 'delegate' }, baseProv(), { |
| 235 | lane: 'local', containsPrivateData: false, |
| 236 | isDelegate: true, delegatedManagedAllowed: false, |
| 237 | enrichesDelegatedPartition: true, delegatedEnrichmentAllowed, |
| 238 | }); |
| 239 | assert.equal(r.ok, false); |
| 240 | assert.equal(r.reason, WRITER_REASONS.SELF_PARTITION_ONLY, |
| 241 | `enrichesDelegatedPartition=true must always be blocked, delegatedEnrichmentAllowed=${delegatedEnrichmentAllowed}`); |
| 242 | } |
| 243 | assert.equal(written.length, 0); |
| 244 | }); |
| 245 | |
| 246 | it('delegated managed-lane write denied without delegatedManagedAllowed', async () => { |
| 247 | const { written, writeNoteFn } = buildSecStores(); |
| 248 | const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); |
| 249 | |
| 250 | const managedProv = baseProv({ |
| 251 | source: 'managed', |
| 252 | lane: 'direct_provider', |
| 253 | artifactType: 'ai_summary', |
| 254 | }); |
| 255 | const r = await writer.write({ summary: 'managed delegate' }, managedProv, { |
| 256 | lane: 'direct_provider', containsPrivateData: false, |
| 257 | isDelegate: true, delegatedManagedAllowed: false, |
| 258 | enrichesDelegatedPartition: false, // self-partition managed |
| 259 | }); |
| 260 | assert.equal(r.ok, false); |
| 261 | assert.equal(r.reason, WRITER_REASONS.CONSENT_DENIED); |
| 262 | assert.equal(written.length, 0); |
| 263 | }); |
| 264 | }); |
| 265 | |
| 266 | // ── P6-d: No tier downgrade ──────────────────────────────────────────────────── |
| 267 | |
| 268 | describe('security P6-d — no tier downgrade (D6.3.4)', () => { |
| 269 | it('tier is resolved from provenance.privacy_tier (owner tier) — cannot be downgraded', () => { |
| 270 | // Tier resolver always uses the provenance.privacy_tier (the owner's tier). |
| 271 | // A delegate cannot pass a different tier — the tier is stamped by the writer |
| 272 | // from the owner's vault, not from the actor's capability. |
| 273 | // |
| 274 | // At Phase 6: cross-partition is fully blocked (D6.3.6), so tier downgrade |
| 275 | // by a delegate is structurally impossible — the write fails at SELF_PARTITION_ONLY |
| 276 | // before tier resolution even runs. |
| 277 | // |
| 278 | // Verify that the tier resolver never maps privacy_max → host_readable: |
| 279 | for (const at of ['ai_summary', 'embedding', 'insight', 'discovery_facet']) { |
| 280 | const r = resolveTier(at, 'privacy_max', { vaultRegistryAvailable: true }); |
| 281 | assert.ok(r.ok); |
| 282 | assert.notEqual(r.terminalState, TERMINAL_STATES.HOST_READABLE, |
| 283 | `privacy_max must never resolve to host_readable for ${at}`); |
| 284 | } |
| 285 | }); |
| 286 | |
| 287 | it('convenience tier never resolves to client_encrypted (no accidental upgrade)', () => { |
| 288 | for (const at of ['ai_summary', 'embedding', 'insight', 'discovery_facet']) { |
| 289 | const r = resolveTier(at, 'convenience'); |
| 290 | assert.equal(r.terminalState, TERMINAL_STATES.HOST_READABLE, |
| 291 | `convenience must always resolve to host_readable for ${at}`); |
| 292 | } |
| 293 | }); |
| 294 | }); |
| 295 | |
| 296 | // ── P6-e: Provenance cannot be forged or omitted ────────────────────────────── |
| 297 | |
| 298 | describe('security P6-e — provenance cannot be forged or omitted (D6.2, D6.6)', () => { |
| 299 | it('every missing required field causes rejection', async () => { |
| 300 | const { written, writeNoteFn } = buildSecStores(); |
| 301 | const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); |
| 302 | const base = baseProv(); |
| 303 | |
| 304 | const required = [ |
| 305 | 'generated_by', 'source', 'model', 'lane', 'privacy_tier', |
| 306 | 'source_note_path', 'source_event_id', 'created_at', 'artifact_type', 'schema_version', |
| 307 | ]; |
| 308 | |
| 309 | for (const field of required) { |
| 310 | const p = { ...base }; |
| 311 | delete p[field]; |
| 312 | const r = await writer.write({ summary: 'x' }, p, selfCtx()); |
| 313 | assert.equal(r.ok, false, `Missing ${field} must cause rejection`); |
| 314 | assert.equal(written.length, 0, `No write after missing ${field}`); |
| 315 | } |
| 316 | }); |
| 317 | |
| 318 | it('null provenance is always rejected', async () => { |
| 319 | const { writeNoteFn } = buildSecStores(); |
| 320 | const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); |
| 321 | const r = await writer.write({ summary: 'x' }, null, selfCtx()); |
| 322 | assert.equal(r.ok, false); |
| 323 | }); |
| 324 | |
| 325 | it('provenance with forged generated_by (empty) is rejected', async () => { |
| 326 | const { writeNoteFn } = buildSecStores(); |
| 327 | const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); |
| 328 | const p = { ...baseProv(), generated_by: '' }; |
| 329 | const r = await writer.write({ summary: 'x' }, p, selfCtx()); |
| 330 | assert.equal(r.ok, false); |
| 331 | }); |
| 332 | |
| 333 | it('provenance sidecar stored in frontmatter matches written provenance exactly', async () => { |
| 334 | const { written, writeNoteFn } = buildSecStores(); |
| 335 | const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); |
| 336 | const p = baseProv(); |
| 337 | await writer.write({ summary: 'honest summary' }, p, selfCtx()); |
| 338 | |
| 339 | const stored = written[0].frontmatter.ai_summary_provenance; |
| 340 | assert.equal(stored.generated_by, p.generated_by); |
| 341 | assert.equal(stored.model, p.model); |
| 342 | assert.equal(stored.lane, p.lane); |
| 343 | assert.equal(stored.created_at, p.created_at); |
| 344 | }); |
| 345 | }); |
| 346 | |
| 347 | // ── P6-f: No secret in artifact, provenance, log, or error ──────────────────── |
| 348 | |
| 349 | describe('security P6-f — no secret in any artifact, provenance, log, or error', () => { |
| 350 | it('validateProvenance rejects provenance with token field', () => { |
| 351 | const p = { ...baseProv(), access_token: 'bearer-xyz' }; |
| 352 | const r = validateProvenance(p); |
| 353 | assert.equal(r.ok, false); |
| 354 | assert.equal(r.reason, PROVENANCE_REJECT_REASONS.SENSITIVE_DATA); |
| 355 | }); |
| 356 | |
| 357 | it('validateProvenance rejects artifact with api_key', () => { |
| 358 | const r = validateProvenance(baseProv(), { summary: 'ok', api_key: 'sk-1234' }); |
| 359 | assert.equal(r.ok, false); |
| 360 | assert.equal(r.reason, PROVENANCE_REJECT_REASONS.SENSITIVE_DATA); |
| 361 | }); |
| 362 | |
| 363 | it('validateProvenance rejects artifact with nested credential', () => { |
| 364 | const r = validateProvenance(baseProv(), { data: { credentials: { password: 'abc' } } }); |
| 365 | assert.equal(r.ok, false); |
| 366 | assert.equal(r.reason, PROVENANCE_REJECT_REASONS.SENSITIVE_DATA); |
| 367 | }); |
| 368 | |
| 369 | it('writer reason codes do not include key material', async () => { |
| 370 | const { writeNoteFn } = buildSecStores(); |
| 371 | const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); |
| 372 | |
| 373 | // Test each failure path and ensure the reason code is a fixed string |
| 374 | const tests = [ |
| 375 | // Invalid provenance |
| 376 | writer.write({ summary: 'x' }, null, selfCtx()), |
| 377 | // Cross-partition |
| 378 | writer.write({ summary: 'x' }, baseProv(), { ...selfCtx(), enrichesDelegatedPartition: true }), |
| 379 | // Consent denied |
| 380 | writer.write({ summary: 'x' }, { ...baseProv(), lane: 'direct_provider', source: 'managed' }, { |
| 381 | lane: 'direct_provider', containsPrivateData: false, isDelegate: true, |
| 382 | delegatedManagedAllowed: false, |
| 383 | }), |
| 384 | ]; |
| 385 | |
| 386 | const results = await Promise.all(tests); |
| 387 | const SECRET_PATTERNS = /key|secret|token|password|credential|bearer/i; |
| 388 | |
| 389 | for (const r of results) { |
| 390 | assert.equal(r.ok, false); |
| 391 | assert.ok( |
| 392 | !SECRET_PATTERNS.test(r.reason), |
| 393 | `Reason code must not contain sensitive patterns: ${r.reason}`, |
| 394 | ); |
| 395 | if (r.detail) { |
| 396 | assert.ok( |
| 397 | !SECRET_PATTERNS.test(r.detail), |
| 398 | `Detail must not contain sensitive patterns: ${r.detail}`, |
| 399 | ); |
| 400 | } |
| 401 | } |
| 402 | }); |
| 403 | |
| 404 | it('ENCRYPTOR_REASONS codes do not contain key material (no hex blobs, JWTs, or base64 payloads)', () => { |
| 405 | // Reason codes are short fixed identifiers like 'encryptor_unavailable_no_user_held_key'. |
| 406 | // The check guards against actual key material (long hex/base64 strings, JWT patterns) |
| 407 | // leaking into codes — not against descriptive identifiers that use the word "key". |
| 408 | const ACTUAL_SECRET_PATTERNS = /[0-9a-f]{32,}|eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+|[A-Za-z0-9+/]{40,}={0,2}/; |
| 409 | for (const code of Object.values(ENCRYPTOR_REASONS)) { |
| 410 | assert.ok(!ACTUAL_SECRET_PATTERNS.test(code), `Encryptor reason must not contain key material: ${code}`); |
| 411 | // Also verify it's a reasonable short identifier (not an exfiltrated value) |
| 412 | assert.ok(code.length < 80, `Encryptor reason code suspiciously long: ${code}`); |
| 413 | } |
| 414 | }); |
| 415 | |
| 416 | it('provenance sidecar stored in frontmatter contains no secret-bearing fields', async () => { |
| 417 | const { written, writeNoteFn } = buildSecStores(); |
| 418 | const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); |
| 419 | await writer.write({ summary: 'fine' }, baseProv(), selfCtx()); |
| 420 | |
| 421 | const stored = written[0].frontmatter.ai_summary_provenance; |
| 422 | const secretPattern = /(api[_-]?key|secret|password|token|credential|authorization|bearer|private[_-]?key)/i; |
| 423 | for (const key of Object.keys(stored)) { |
| 424 | assert.ok(!secretPattern.test(key), `Provenance sidecar must not have sensitive key: ${key}`); |
| 425 | } |
| 426 | }); |
| 427 | }); |
| 428 | |
| 429 | // ── P6-h: Single-writer no-bypass architecture test (BUILD-BLOCKING) ────────── |
| 430 | |
| 431 | describe('security P6-h — single-writer no-bypass (D6.6.3/D6.6.4) [BUILD-BLOCKING]', () => { |
| 432 | it('index-enrich.mjs does not call writeNote directly for ai_summary after migration', () => { |
| 433 | const enrichPath = join(REPO_ROOT, 'mcp/tools/index-enrich.mjs'); |
| 434 | const src = readFileSync(enrichPath, 'utf8'); |
| 435 | |
| 436 | // The direct writeNote call for ai_summary frontmatter must be removed (D6.6.2) |
| 437 | // Pattern: writeNote(..., { frontmatter: { ai_summary: ... } }) without going through writer |
| 438 | const directWritePattern = /writeNote\s*\([^)]*frontmatter\s*:\s*\{[^}]*ai_summary\s*:/; |
| 439 | assert.ok( |
| 440 | !directWritePattern.test(src), |
| 441 | 'index-enrich.mjs must NOT contain direct writeNote calls for ai_summary frontmatter. ' + |
| 442 | 'All writes must go through DerivedArtifactWriter (D6.6.2).', |
| 443 | ); |
| 444 | }); |
| 445 | |
| 446 | it('memory-consolidate.mjs does not call mm.store("insight") directly after migration', () => { |
| 447 | const consolidatePath = join(REPO_ROOT, 'lib/memory-consolidate.mjs'); |
| 448 | const src = readFileSync(consolidatePath, 'utf8'); |
| 449 | |
| 450 | // Strip comment lines so we only check executable code, not comment references (D6.6.2) |
| 451 | const codeLines = src.split('\n') |
| 452 | .filter((line) => !line.trim().startsWith('//') && !line.trim().startsWith('*')) |
| 453 | .join('\n'); |
| 454 | |
| 455 | const directInsightPattern = /mm\.store\s*\(\s*['"]insight['"]/; |
| 456 | assert.ok( |
| 457 | !directInsightPattern.test(codeLines), |
| 458 | 'memory-consolidate.mjs must NOT contain direct mm.store("insight", ...) calls in code. ' + |
| 459 | 'All insight persistence must go through DerivedArtifactWriter (D6.6.2).', |
| 460 | ); |
| 461 | }); |
| 462 | |
| 463 | it('companion-artifact-writer.mjs is in the lib/ directory (authority group)', () => { |
| 464 | const writerPath = join(REPO_ROOT, 'lib/companion-artifact-writer.mjs'); |
| 465 | assert.ok(existsSync(writerPath), 'DerivedArtifactWriter must exist in lib/ (authority group)'); |
| 466 | }); |
| 467 | |
| 468 | it('companion-client-encryptor.mjs is in the lib/ directory (authority group)', () => { |
| 469 | const encPath = join(REPO_ROOT, 'lib/companion-client-encryptor.mjs'); |
| 470 | assert.ok(existsSync(encPath), 'ClientEncryptor must exist in lib/ (authority group)'); |
| 471 | }); |
| 472 | }); |
| 473 | |
| 474 | // ── P6-i: Runtime group imports no writer/encryptor/vault module ────────────── |
| 475 | |
| 476 | describe('security P6-i — runtime group imports no writer/encryptor module (extends D5.8) [BUILD-BLOCKING]', () => { |
| 477 | const FORBIDDEN_IMPORTS = [ |
| 478 | 'companion-artifact-writer', |
| 479 | 'companion-client-encryptor', |
| 480 | ]; |
| 481 | |
| 482 | const RUNTIME_GROUP_FILES = [ |
| 483 | 'lib/companion-runtime-manager.mjs', |
| 484 | 'lib/companion-spawn-adapter.mjs', |
| 485 | 'lib/companion-download-adapter.mjs', |
| 486 | 'lib/companion-inference-listener.mjs', |
| 487 | 'lib/companion-resource-probe.mjs', |
| 488 | ]; |
| 489 | |
| 490 | for (const file of RUNTIME_GROUP_FILES) { |
| 491 | for (const forbidden of FORBIDDEN_IMPORTS) { |
| 492 | it(`${file} does not import ${forbidden}`, () => { |
| 493 | const filePath = join(REPO_ROOT, file); |
| 494 | if (!existsSync(filePath)) return; // File may not exist in all branches — skip |
| 495 | |
| 496 | const src = readFileSync(filePath, 'utf8'); |
| 497 | const importPattern = new RegExp(`import[^'"]*['"].*${forbidden.replace('.', '\\.')}.*['"]`); |
| 498 | assert.ok( |
| 499 | !importPattern.test(src), |
| 500 | `SECURITY VIOLATION: ${file} must NOT import ${forbidden}. ` + |
| 501 | `The runtime/inference group must never hold the writer or encryptor capability (D6.6.3, D5.8).`, |
| 502 | ); |
| 503 | }); |
| 504 | } |
| 505 | } |
| 506 | }); |
| 507 | |
| 508 | // ── Convenience never masquerades as privacy_max / vice-versa ───────────────── |
| 509 | |
| 510 | describe('security — tier purity (no masquerade)', () => { |
| 511 | it('convenience write produces host_readable terminal state, never client_encrypted', async () => { |
| 512 | const { written, writeNoteFn } = buildSecStores(); |
| 513 | const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); |
| 514 | |
| 515 | const r = await writer.write({ summary: 'convenience' }, baseProv(), selfCtx()); |
| 516 | assert.equal(r.ok, true); |
| 517 | assert.equal(r.terminalState, TERMINAL_STATES.HOST_READABLE); |
| 518 | // ai_summary is plaintext — that is correct for convenience |
| 519 | assert.equal(written[0].frontmatter.ai_summary, 'convenience'); |
| 520 | assert.equal(written[0].frontmatter.ai_summary_ciphertext, undefined); |
| 521 | }); |
| 522 | |
| 523 | it('unknown tier input never resolves to convenience (fail-closed to most-restrictive)', () => { |
| 524 | // Unknown tier → TIER_RESOLVE_REASONS.UNKNOWN_TIER (not convenience) |
| 525 | const r = resolveTier('ai_summary', 'unknown_mystery_tier'); |
| 526 | assert.equal(r.ok, false); |
| 527 | assert.equal(r.reason, TIER_RESOLVE_REASONS.UNKNOWN_TIER); |
| 528 | // It must NOT silently fall back to convenience |
| 529 | }); |
| 530 | }); |
| 531 | |
| 532 | // ── Global fail-closed posture ──────────────────────────────────────────────── |
| 533 | |
| 534 | describe('security — global fail-closed posture', () => { |
| 535 | it('writer with every possible bad provenance field combination always returns ok:false', async () => { |
| 536 | const { writeNoteFn } = buildSecStores(); |
| 537 | const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); |
| 538 | |
| 539 | const badProvCases = [ |
| 540 | null, undefined, '', 0, [], {}, { generated_by: 'x' }, { schema_version: 1 }, |
| 541 | { ...baseProv(), privacy_tier: undefined }, |
| 542 | { ...baseProv(), artifact_type: 'invalid' }, |
| 543 | { ...baseProv(), created_at: 'not-a-date' }, |
| 544 | ]; |
| 545 | |
| 546 | for (const prov of badProvCases) { |
| 547 | const r = await writer.write({ summary: 'x' }, prov, selfCtx()); |
| 548 | assert.equal(r.ok, false, `Bad provenance must always fail: ${JSON.stringify(prov)}`); |
| 549 | } |
| 550 | }); |
| 551 | |
| 552 | it('resolveTier returns ok:false for every invalid input combination', () => { |
| 553 | const badCombos = [ |
| 554 | ['', ''], |
| 555 | ['ai_summary', ''], |
| 556 | ['', 'convenience'], |
| 557 | [null, null], |
| 558 | ['video', 'convenience'], |
| 559 | ['ai_summary', 'SECRET_TIER'], |
| 560 | ]; |
| 561 | for (const [at, pt] of badCombos) { |
| 562 | const r = resolveTier(at, pt); |
| 563 | assert.equal(r.ok, false, `resolveTier(${at}, ${pt}) must fail`); |
| 564 | } |
| 565 | }); |
| 566 | |
| 567 | it('UNAVAILABLE_CLIENT_ENCRYPTOR.isAvailable never throws, always false', () => { |
| 568 | const extremeInputs = [null, undefined, '', 0, {}, [], 'privacy_max', 'vault']; |
| 569 | for (let i = 0; i < extremeInputs.length - 1; i++) { |
| 570 | assert.doesNotThrow(() => { |
| 571 | const result = UNAVAILABLE_CLIENT_ENCRYPTOR.isAvailable(extremeInputs[i], extremeInputs[i + 1]); |
| 572 | assert.equal(result, false); |
| 573 | }); |
| 574 | } |
| 575 | }); |
| 576 | }); |
File History
1 commit
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb
reconcile: import GitHub-direct RBAC/OAuth/companion and ho…
Human
minor
⚠
8 hours ago