diff --git a/agents/ludwig/work/queue-258/BUILD.md b/agents/ludwig/work/queue-258/BUILD.md index 2e5cae4ef..b562d3cf4 100644 --- a/agents/ludwig/work/queue-258/BUILD.md +++ b/agents/ludwig/work/queue-258/BUILD.md @@ -15,7 +15,7 @@ and refusal tests apply. | `docs/prd/mail-addendum-draft.md` | REQ-MAIL-1 to 16, a draft. Each requirement names its rows. Two questions for Mos. | | `packages/mail/schemas/*.schema.json` | source, data, access, service, content-key | | `packages/mail/src/validate.mjs` | Closed-subset validator, binding, location, file and key-lifecycle checks | -| `packages/mail/tests/schema.test.mjs` | T1-T14 | +| `packages/mail/tests/schema.test.mjs` | T1-T14, with T10b | | `packages/mail/{package.json,README.md}` | Package manifest and notes | Not changed: `docs/prd/mosaic-stack.md`, its requirements JSON and tests, `packages/plugins/src/kinds.mjs`, @@ -66,11 +66,33 @@ Not changed: `docs/prd/mosaic-stack.md`, its requirements JSON and tests, `packa Messages name the path, never the value. This is a backstop, not a scanner. 8. **Content-key record location.** The plan names the key path. The lifecycle record's own file is left to E03. `checkKeyLocation` and `checkProtectedFile` apply to both. -9. **Key lifecycle.** active → retired → expired, or active → expired. No step backwards, so a restored - record can't revive a key. Past `expiresAt`, use is none whatever the state says. +9. **Key lifecycle.** active → retired → expired, or active → expired. `keyTransitionAllowed` is a pure + check on one state edge. It doesn't persist state, stop a restored backup rolling a record back, or + destroy key copies; E03 owns that proof (della-0023 N1). `keyUse` is none outside + [createdAt, expiresAt), whatever the state says. 10. **v1 compatibility.** T13 asserts that kinds.mjs names no mail, vocabulary.mjs has no `mail.access`, and the approved PRD has no REQ-MAIL. E05 and the approved addendum update T13 when they land. +11. **Use helpers fail closed (della-0023 F1).** `effectiveMailboxes` and `keyUse` validate their + records first and read the clock only from a valid `Date`. An invalid record or clock, or a time + outside [issuedAt, expiresAt) or [createdAt, expiresAt), gives no read and no key use. Tradeoff: a + caller with a slightly malformed but otherwise usable grant gets nothing, not a partial read. E02 + still owns principal, right, manual-mode and revoke enforcement. +12. **Placement compares resolved paths too (della-0023 F2).** `checkSlotLocation` and + `checkKeyLocation` check the paths as written first, so planned paths that don't exist yet still + work. Then they resolve the nearest existing ancestor of the secret path and of each forbidden root, + and refuse containment. That catches a configDir linked into dataRoot and a root named through an + alias. `checkProtectedFile` now needs the file lexically under configDir, refuses a symbolic link + on configDir itself or any directory below it, and takes the forbidden roots for the same resolved + comparison. Symbolic links above configDir (a linked `/home`, say) stay allowed: they don't move + the secret relative to the roots, and the resolved comparison still covers them. Tradeoff: one + `realpath` per root on each check. +13. **Draft wording (mos-1313).** W1: rulings are cited as "DECISIONS Q…", plan questions as "email + plan Q…", and "Q4" is replaced by what it stands for. W2: retention says expiry stops serving an + item and removal waits for `data.delete`. W3: an invalid mail setting is refused when written and + holds its source with an alert when found at load; no fallback to the default. W4: the questions + section is gone; Mos's two answers are recorded in the status lines. + ## Test plan (not run: Gander holds the slot, sage-2802) `cd packages/mail && node --test tests/schema.test.mjs`. It writes only under `os.tmpdir()`, which the gate @@ -87,13 +109,27 @@ Existing suites to run whole, because they scan every package's `src` or copy tr - `packages/plugins/tests/harness.test.mjs` (harness names outside plugins) - `packages/cli/tests/stack-copy.test.mjs`, `packages/cli/tests/stack-release.test.mjs` (fixture copies) -Suggested mutants (the reviewer names the final set): -- M1 `defaults`: `mailboxScope` default `["*"]` or a fixture mailbox → T1, T2 fail. -- M2 drop `const: "human"` from `changedBy.kind` → T3 fails. -- M3 drop the wildcard check → T5 fails on its message. -- M4 drop `looksSecret` → T6 fails. -- M5 `effectiveMailboxes` ignores the grant's mailboxes → T2 fails. -- M6 `inside()` returns false → T10 fails. -- M7 `checkProtectedFile` accepts mode 640 → T11 fails. -- M8 allow `retired → active` → T12 fails. -- M9 raise `pageSize` maximum to 101 → T9 fails. +Mutants, named by Della (della-0023). Each must die on a semantic assertion in the whole +`schema.test.mjs`, run on a CLEAR slot. The wildcard tests stay. + +| Mutant | Change | Must fail | +| --- | --- | --- | +| M1 | `mailboxScope` default nonempty | T1/T2 | +| M2 | allow an agent settings author | T3 | +| M3 | remove the source/business binding comparison | T8 | +| M4 | disable secret-shape rejection | T6 | +| M5 | omit the grant intersection | T2 | +| M6 | omit the forbidden-root exclusion | T10 (config under dataRoot) | +| M7 | allow mode 0640 | T11 | +| M8 | allow retired → active | T12 | +| M9 | bypass an enforced numeric lower or upper comparison in `visit` | T9 | +| M10 | omit the malformed-state/time guard | T2/T12 lifetime and malformed cases | +| M11 | omit the canonical or root-symlink guard | T10b/T11 | + +## Round 2 (della-0023, mos-1313) + +Round 1 was a8f0cf924, CHANGES (della-0023). Round 2 adds F1, F2 and N1 above, and Mos's W1-W4 on the +draft. New cases: T2 lifetime edges and malformed grant or clock, T12 lifetime edges and malformed key +or clock, T10b (configDir linked into dataRoot with a real 0600 key, a planned slot through the link, +a dataRoot alias), T11 (file outside configDir, unnormalized path, linked configDir, root alias). +Static checks only; Node not run (Gander holds the slot). diff --git a/docs/prd/mail-addendum-draft.md b/docs/prd/mail-addendum-draft.md index 2e1c1bdbd..74695120d 100644 --- a/docs/prd/mail-addendum-draft.md +++ b/docs/prd/mail-addendum-draft.md @@ -6,11 +6,16 @@ it lands there as a new PRD version, and only then does any requirement here bind. - Source: `docs/plans/2026-10-21_email-plan.md`, Della's revision - 179391055, approved by Ludwig (ludwig-0037). Q217 settles the mailbox - (one agent mailbox on woltje.com at InMotion, which Jason creates). - Q222 settles personal mail (none for now) and the settings rule: things - are tunable, not hardcoded. + 179391055, approved by Ludwig (ludwig-0037). DECISIONS Q217 settles + the mailbox (one agent mailbox on woltje.com at InMotion, which Jason + creates). DECISIONS Q222 settles personal mail (none for now) and the + settings rule: things are tunable, not hardcoded. +- Shape (mos-1313): once approved, this lands as one new PRD version with + a Mail section, in its own reviewed row. `mail.access` stays a hard + wait in that version. - Scope: post-v1. None of it is needed for 2026-10-27. +- Q numbers: "DECISIONS Q…" is a ruling. "Email plan Q…" is a question + still open in the email plan. ## How to read this draft @@ -20,8 +25,10 @@ The schemas in `packages/mail/schemas/` hold each default and the range a setting may take. Implementers and reviewers set that range. Widening it needs a reviewed schema and test change. Only an authenticated human changes a setting, through the audited CLI and later the Console. An -agent can't change one, not even to narrow it. An invalid setting fails -closed. +agent can't change one, not even to narrow it. For mail, an invalid +setting doesn't fall back to its default. A write with an invalid value +is refused. An invalid value found when the service loads holds that +source and raises an alert. Each requirement lists the build rows that deliver it. The rows are in the email plan's "Ordered build rows". @@ -43,7 +50,7 @@ the email plan's "Ordered build rows". fenced before in-flight content returns. A read is the intersection of the source scope, the provider's rights and the principal's current grant. Widening a scope grants no agent anything. No personal mailbox - is enrolled until a later consent and grant (Q222). + is enrolled until a later consent and grant (DECISIONS Q222). Rows: E01, E02, E13. - **REQ-MAIL-3.** A new plug-in kind, `mail-source`, carries providers (REQ-PLUG-1). Its modules export `descriptor`, `scan`, `fetch` and @@ -51,7 +58,8 @@ the email plan's "Ordered build rows". or marks mail read. Microsoft 365 (Graph) and IMAP are the first modules. POP3, Gmail API and JMAP wait for a named need. The kind enters `packages/plugins/src/kinds.mjs` with its first production - module, after this addendum is approved and Q4 is answered. + module. That waits for the approved PRD version, and for the source, + grant and route approvals in REQ-MAIL-4, 7 and 8. Rows: E05, E06. - **REQ-MAIL-4.** A source connects through its own service principal, `mail-service`. This is a new approved extension, not an exception to @@ -78,7 +86,7 @@ the email plan's "Ordered build rows". quarantined, never dropped. Active content never runs. Attachments start as metadata. An authorized fetch quarantines the bytes, by default up to 10 MiB per part and per message. Extracting attachment - content stays Jason's choice (Q8). If he chooses it, extraction runs + content stays Jason's choice (email plan Q8). If he chooses it, extraction runs isolated with no network and a scanner. Rows: E04, E16. - **REQ-MAIL-7.** A route table maps trusted delivery evidence to a Stack @@ -109,12 +117,13 @@ the email plan's "Ordered build rows". mint a grant or pose as the human. Mail never wakes an agent by default (`unattendedWake` is off, and its sender allowlist is empty). A human may turn it on for a route. Then a wake also needs aligned - DMARC, a current grant and admission by the system manager (Q198). + DMARC, a current grant and admission by the system manager + (DECISIONS Q198). The woken work runs in a fresh restricted profile, and anything it starts keeps `source: mail`. Rows: E10. - **REQ-MAIL-11.** A mail-triggered decision goes through the stack's one - decision module (Q207). Its input is metadata only, inside a closed + decision module (DECISIONS Q207). Its input is metadata only, inside a closed schema. The mail service never calls a model itself. Without a model, deterministic intake keeps working and model work is held. Rows: E11. @@ -122,7 +131,7 @@ the email plan's "Ordered build rows". cursor commit, lag, backlog and credential state. A live process alone is not healthy. Outages pause only the affected source. Recovery follows the system manager's protected mode and its recovery latch - (Q198). A health check sends no mail and calls no model. + (DECISIONS Q198). A health check sends no mail and calls no model. Rows: E12. - **REQ-MAIL-13.** Mail data lives under `/mail//` and never enters git or an image. By default the stack keeps metadata @@ -130,8 +139,9 @@ the email plan's "Ordered build rows". `/secrets/mosaic-stack/mail-content//`, outside dataRoot and its backups, mode 600, with no symbolic link. Retention defaults: bodies and drafts 30 days, attachments 7 days, metadata 365 - days. A retention setting never authorizes deletion. Deleting needs - `data.delete` or a retention authority Jason approves later. A + days. When an item's time runs out, the stack stops serving it. A + retention setting never authorizes deletion: removing the item waits + for `data.delete` or a retention authority Jason approves later. A restored backup must not bring back expired content or redeliver old work. Rows: E01, E03, E13. @@ -142,21 +152,13 @@ the email plan's "Ordered build rows". Rows: E05, E06, E09, E14. - **REQ-MAIL-15.** Every mail limit, scope and retention time is a setting. The schema holds its default and range. Only a human changes - it. A change is versioned and audited with who made it. Settings never + it. A change is versioned and audited with who made it. An invalid + value is refused when written, and holds its source with an alert when + found at load. Settings never override grants, hard waits or the system manager's contract. Rows: E01, E04, E13. - **REQ-MAIL-16.** A live pilot is separate. It needs Jason's mailbox, - the Q4 approvals and a reviewed re-pin. It sends no test mail, and it + the approved PRD version, the source, grant and route approvals in + REQ-MAIL-4, 7 and 8, and a reviewed re-pin. It sends no test mail, and it is judged on evidence for each message, not counts. Rows: E15. - -## Questions for Mos - -1. REQ-MAIL-8 keeps `mail.access` a hard wait. The plan leaves routing it - out to a later PRD version Jason approves. Recommendation: keep it a - hard wait in the first approved version, and decide routing after the - pilot. -2. This draft becomes one new PRD version, with REQ-MAIL-1 to 16 as a new - "Mail" section. The requirements test and its JSON would grow from 85 - to 101 then. Recommendation: land it that way, rather than as a - separate addendum file that the requirements test doesn't read. diff --git a/packages/mail/README.md b/packages/mail/README.md index b335a0c40..d34f76e26 100644 --- a/packages/mail/README.md +++ b/packages/mail/README.md @@ -3,7 +3,7 @@ Post-v1. This package holds the record schemas for mail intake and access, and a validator. Nothing here opens a connection, reads a secret or registers a plug-in kind. Core `mail-source` registration waits for the -approved PRD addendum and Q4. The addendum draft is +approved PRD version and the source, grant and route approvals. The addendum draft is `docs/prd/mail-addendum-draft.md`; it binds nothing until Jason approves it. | Schema | Record | @@ -14,7 +14,7 @@ approved PRD addendum and Q4. The addendum draft is | `schemas/service.schema.json` | A source's service principal and its slot path | | `schemas/content-key.schema.json` | A content key's location and lifecycle | -## Settings (Q222) +## Settings (DECISIONS Q222) Every limit, scope and retention time is a setting. Its default and its range sit in the schema, so they have one definition. `defaults(kind)` @@ -37,13 +37,23 @@ false until retention authority exists (E13). different business, source or provider, and a grant past the source's mailbox scope. - `effectiveMailboxes(...)` intersects the source scope, the provider's - mailboxes and the grant. The defaults read nothing. + mailboxes and the grant, from `issuedAt` up to `expiresAt`. The + defaults read nothing. An invalid record or clock reads nothing. + Principal, right, manual-mode and revoke checks belong to E02. - `checkSlotLocation` and `checkKeyLocation` keep provider slots and content keys under `/secrets/mosaic-stack/`, outside - dataRoot, the repository and any backup root. -- `checkProtectedFile` wants a regular file, owned, mode 600, with no - symbolic link between configDir and the file. -- `keyTransitionAllowed` and `keyUse` move a key forward only: active, - retired (decrypt only), expired (no use). + dataRoot, the repository and any backup root. They compare the paths + as written and again after resolving symbolic links, so a linked + configDir or an aliased root can't hide a secret inside a forbidden root. +- `checkProtectedFile` wants a regular file under configDir, owned, mode + 600, with no symbolic link from configDir itself down to the file. Given + the forbidden roots, it compares resolved paths against them too. +- `keyUse` gives active keys encrypt and decrypt, retired keys decrypt + only, and expired keys nothing. An invalid record or clock, or a time + outside `createdAt` to `expiresAt`, gives nothing. +- `keyTransitionAllowed` checks one state edge: active to retired or + expired, retired to expired. It is a pure predicate. It doesn't persist + state, stop a restored backup from rolling a record back, or destroy + key copies. E03 owns that proof. Tests: `node --test tests/*.test.mjs` from this directory. diff --git a/packages/mail/src/validate.mjs b/packages/mail/src/validate.mjs index aa8e6e9e9..eb35beed5 100644 --- a/packages/mail/src/validate.mjs +++ b/packages/mail/src/validate.mjs @@ -1,8 +1,9 @@ import { lstatSync, readFileSync, realpathSync } from 'node:fs'; -import { isAbsolute, join, normalize, relative, sep } from 'node:path'; +import { basename, dirname, isAbsolute, join, normalize, relative, sep } from 'node:path'; // Mail record schemas (email plan row E01). Nothing here reads a secret, opens a connection or -// registers a plug-in kind: core mail-source registration waits for the approved addendum and Q4. +// registers a plug-in kind: core mail-source registration waits for the approved PRD version and the +// source, grant and route approvals. export const KINDS = Object.freeze(['source', 'data', 'access', 'service', 'content-key']); const KEYWORDS = new Set(['$schema', '$id', 'type', 'const', 'enum', 'properties', 'required', 'additionalProperties', 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength', @@ -137,11 +138,16 @@ export function checkBinding({ source, service, access }) { return problems; } +const instant = now => (now instanceof Date && Number.isFinite(now.getTime()) ? now.getTime() : NaN); + // What a principal may read: the source scope, the provider's mailboxes and the grant, intersected. -// A disabled source, the default empty scope or a missing grant reads nothing. +// A disabled source, the default empty scope, a missing, invalid, not-yet-issued or expired grant, or an +// invalid clock reads nothing. Authenticated principal, right, manual-mode and revoke checks belong to E02. export function effectiveMailboxes({ source, providerMailboxes = [], access = null, now = new Date() }) { - if (!source.sourceEnabled || !access) return []; - if (Date.parse(access.expiresAt) <= now.getTime()) return []; + if (!access || validateRecord('source', source).length || validateRecord('access', access).length) return []; + if (!source.sourceEnabled || !Array.isArray(providerMailboxes)) return []; + const at = instant(now); + if (!(at >= Date.parse(access.issuedAt) && at < Date.parse(access.expiresAt))) return []; if (access.business !== source.business || access.source !== source.source) return []; const provider = new Set(providerMailboxes); const granted = new Set(access.mailboxes); @@ -152,14 +158,38 @@ function inside(root, path) { const rel = relative(root, path); return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel)); } -function placed(label, path, expectedDir, { dataRoot, repoRoot, backupRoots = [] }) { +// The path with every existing part resolved, so a symbolic link or alias can't hide where it lands. +// Parts that don't exist yet (a planned slot) are kept as written under their nearest existing ancestor. +function canonical(path) { + const rest = []; + for (let at = path; ; at = dirname(at)) { + try { return join(realpathSync(at), ...rest.reverse()); } catch (error) { + if (error.code !== 'ENOENT' || at === dirname(at)) return null; + rest.push(basename(at)); + } + } +} +// Lexical checks first, for planned paths. Then the same forbidden roots compared after resolution, so +// a configDir that links into dataRoot, the repository or a backup root is refused too. +function forbidden(label, path, { dataRoot, repoRoot, backupRoots = [] }) { const problems = []; - if (!isAbsolute(path) || normalize(path) !== path) return [`${label}: must be a normalized absolute path`]; - if (!inside(expectedDir, path) || path === expectedDir) problems.push(`${label}: must sit under ${expectedDir}`); - for (const [name, root] of [['dataRoot', dataRoot], ['the repository', repoRoot], ...backupRoots.map(root => ['a backup root', root])]) - if (root && inside(root, path)) problems.push(`${label}: inside ${name}; mail secrets live outside it`); + const roots = [['dataRoot', dataRoot], ['the repository', repoRoot], ...backupRoots.map(root => ['a backup root', root])] + .filter(([, root]) => root); + for (const [name, root] of roots) if (inside(root, path)) problems.push(`${label}: inside ${name}; mail secrets live outside it`); + if (problems.length) return problems; + const real = canonical(path); + if (real === null) return [`${label}: path can't be resolved`]; + for (const [name, root] of roots) { + const realRoot = canonical(root); + if (realRoot === null || inside(realRoot, real)) problems.push(`${label}: resolves inside ${name}; mail secrets live outside it`); + } return problems; } +function placed(label, path, expectedDir, roots) { + if (!isAbsolute(path) || normalize(path) !== path) return [`${label}: must be a normalized absolute path`]; + const problems = !inside(expectedDir, path) || path === expectedDir ? [`${label}: must sit under ${expectedDir}`] : []; + return [...problems, ...forbidden(label, path, roots)]; +} // A source service's provider credential slot (plan D9). export const slotDir = ({ configDir, business, source }) => join(configDir, 'secrets', 'mosaic-stack', 'mail', business, source); export const checkSlotLocation = (service, roots) => @@ -172,25 +202,36 @@ export function checkKeyLocation(record, roots) { return problems; } -// A protected file: owned, mode 600, regular, no symbolic link between configDir and the file. -export function checkProtectedFile(path, { configDir, uid = process.getuid() }) { +// A protected file: inside configDir, owned, mode 600, regular, with no symbolic link from configDir +// itself down to the file, and not resolving into a forbidden root. +export function checkProtectedFile(path, { configDir, uid = process.getuid(), ...roots }) { + if (!isAbsolute(path) || normalize(path) !== path || !isAbsolute(configDir) || normalize(configDir) !== configDir) + return [`${path}: must be a normalized absolute path`]; + if (!inside(configDir, path) || path === configDir) return [`${path}: must sit under ${configDir}`]; let stat; try { stat = lstatSync(path); } catch { return [`${path}: not found`]; } - const problems = []; if (stat.isSymbolicLink() || !stat.isFile()) return [`${path}: must be a regular file, not a symbolic link`]; + for (let at = dirname(path); ; at = dirname(at)) { + let dir; + try { dir = lstatSync(at); } catch { return [`${path}: path can't be resolved`]; } + if (dir.isSymbolicLink()) return [`${path}: a symbolic link sits on the path at ${at}`]; + if (at === configDir) break; + } + const problems = []; if (stat.uid !== uid) problems.push(`${path}: owned by uid ${stat.uid}, not ${uid}`); if ((stat.mode & 0o777) !== 0o600) problems.push(`${path}: mode ${(stat.mode & 0o777).toString(8)}; use 600`); - let real = null; - let realConfig = null; - try { real = realpathSync(path); realConfig = realpathSync(configDir); } catch { real = null; problems.push(`${path}: path can't be resolved`); } - if (real && real !== join(realConfig, relative(configDir, path))) problems.push(`${path}: a symbolic link sits on the path`); - return problems; + return [...problems, ...forbidden(path, path, roots)]; } -// Key lifecycle: active keys encrypt and decrypt, retired keys only decrypt, expired keys do neither and -// every copy is destroyed. No state moves backwards, so a restored backup can't revive a key. +// Key lifecycle states: active keys encrypt and decrypt, retired keys only decrypt, expired keys do +// neither. keyTransitionAllowed is a pure check on one state edge. It doesn't persist state, stop a +// restored backup from rolling a record back, or destroy key copies; E03 owns that proof. const KEY_STEPS = { active: ['retired', 'expired'], retired: ['expired'], expired: [] }; export const keyTransitionAllowed = (from, to) => Object.hasOwn(KEY_STEPS, from) && KEY_STEPS[from].includes(to); -export const keyUse = (record, now = new Date()) => - record.state === 'expired' || Date.parse(record.expiresAt) <= now.getTime() ? 'none' - : record.state === 'retired' ? 'decrypt' : 'encrypt-decrypt'; +// The use a record allows now. An invalid record or clock, or a time outside [createdAt, expiresAt), is none. +export function keyUse(record, now = new Date()) { + if (validateRecord('content-key', record).length) return 'none'; + const at = instant(now); + if (!(at >= Date.parse(record.createdAt) && at < Date.parse(record.expiresAt))) return 'none'; + return record.state === 'active' ? 'encrypt-decrypt' : record.state === 'retired' ? 'decrypt' : 'none'; +} diff --git a/packages/mail/tests/schema.test.mjs b/packages/mail/tests/schema.test.mjs index f48e579d0..60e4df2dd 100644 --- a/packages/mail/tests/schema.test.mjs +++ b/packages/mail/tests/schema.test.mjs @@ -77,6 +77,20 @@ test('T2 the default scope reads nothing, and a read is the intersection of scop assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date('2026-10-29T16:00:00.000Z') }), [], 'expired grant'); assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: access({ source: 'other' }), now: new Date(AT) }), [], 'grant for another source'); assert.deepEqual(effectiveMailboxes({ source: { ...scoped, sourceEnabled: false }, providerMailboxes, access: grant, now: new Date(AT) }), [], 'disabled'); + // Lifetime edges: the grant reads from issuedAt up to, not including, expiresAt. + const read = (over = {}) => effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date(AT), ...over }); + assert.deepEqual(read({ now: new Date('2026-10-22T15:59:59.999Z') }), [], 'before issuedAt'); + assert.deepEqual(read({ now: new Date('2026-10-29T15:59:59.999Z') }), ['agent@example.test'], 'the last instant before expiresAt'); + // Malformed records or clock fail closed. + assert.deepEqual(read({ now: new Date(Number.NaN) }), [], 'invalid clock'); + assert.deepEqual(read({ now: AT }), [], 'a clock that is not a Date'); + assert.deepEqual(read({ access: access({ expiresAt: 'not-a-date' }) }), [], 'invalid expiresAt'); + assert.deepEqual(read({ access: access({ issuedAt: 'not-a-date' }) }), [], 'invalid issuedAt'); + assert.deepEqual(read({ access: access({ issuedAt: '2026-10-23T16:00:00.000Z' }) }), [], 'issued in the future'); + const { mailboxes, ...noMailboxes } = grant; + assert.deepEqual(read({ access: noMailboxes }), [], 'grant without mailboxes'); + assert.deepEqual(read({ source: { ...scoped, mailboxScope: ['*'] } }), [], 'invalid source'); + assert.deepEqual(read({ providerMailboxes: 'agent@example.test' }), [], 'provider list that is not an array'); }); test('T3 only a human changes settings', () => { @@ -188,6 +202,41 @@ test('T10 slot and key locations stay outside dataRoot, the repository and backu ['content-key.path: must be a normalized absolute path']); }); +test('T10b a symbolic link or alias can\'t move a slot or key into a forbidden root', () => { + const root = mkdtempSync(join(tmpdir(), 'mail-place-')); + try { + const dataRoot = join(root, 'data'); + const roots = { dataRoot, repoRoot: join(root, 'repo'), backupRoots: [join(root, 'backup')] }; + // configDir is a symbolic link into dataRoot, with a real 0600 key behind it. + mkdirSync(join(dataRoot, 'cfg'), { recursive: true, mode: 0o700 }); + const configDir = join(root, 'config'); + symlinkSync(join(dataRoot, 'cfg'), configDir); + const dir = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'acme'); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const file = join(dir, 'k1'); + writeFileSync(file, 'synthetic', { mode: 0o600 }); + const key = contentKey({ path: file }); + assert.deepEqual(checkKeyLocation(key, { ...roots, configDir }), ['content-key.path: resolves inside dataRoot; mail secrets live outside it']); + // A planned slot that doesn't exist yet resolves through its nearest existing ancestor. + const slot = service({ slotPath: join(configDir, 'secrets', 'mosaic-stack', 'mail', 'acme', 'ingress', 'imap') }); + assert.deepEqual(checkSlotLocation(slot, { ...roots, configDir }), ['service.slotPath: resolves inside dataRoot; mail secrets live outside it']); + // A forbidden root named through an alias still covers the real configDir. + const realConfig = join(dataRoot, 'cfg'); + const alias = join(root, 'alias'); + symlinkSync(dataRoot, alias); + const realKey = contentKey({ path: join(realConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1') }); + assert.deepEqual(checkKeyLocation(realKey, { ...roots, dataRoot: alias, configDir: realConfig }), + ['content-key.path: resolves inside dataRoot; mail secrets live outside it']); + // The same layout outside every forbidden root passes. + const safeConfig = join(root, 'safe'); + mkdirSync(safeConfig, { mode: 0o700 }); + const safeKey = contentKey({ path: join(safeConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1') }); + assert.deepEqual(checkKeyLocation(safeKey, { ...roots, configDir: safeConfig }), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('T11 a protected file must be a regular owned 600 file with no symbolic link on its path', () => { const root = mkdtempSync(join(tmpdir(), 'mail-schema-')); try { @@ -211,10 +260,26 @@ test('T11 a protected file must be a regular owned 600 file with no symbolic lin writeFileSync(join(realDir, 'k3'), 'synthetic', { mode: 0o600 }); symlinkSync(realDir, join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta')); const viaLink = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta', 'k3'); - assert.deepEqual(checkProtectedFile(viaLink, { configDir }), [`${viaLink}: a symbolic link sits on the path`]); + assert.deepEqual(checkProtectedFile(viaLink, { configDir }), + [`${viaLink}: a symbolic link sits on the path at ${join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta')}`]); assert.deepEqual(checkProtectedFile(join(dir, 'missing'), { configDir }), [`${join(dir, 'missing')}: not found`]); mkdirSync(join(dir, 'k4')); assert.deepEqual(checkProtectedFile(join(dir, 'k4'), { configDir }), [`${join(dir, 'k4')}: must be a regular file, not a symbolic link`]); + // The file must sit under configDir, lexically. + const outside = join(realDir, 'k3'); + assert.deepEqual(checkProtectedFile(outside, { configDir }), [`${outside}: must sit under ${configDir}`]); + const dotted = `${configDir}/../elsewhere/k3`; + assert.deepEqual(checkProtectedFile(dotted, { configDir }), [`${dotted}: must be a normalized absolute path`]); + // A symbolic-link configDir is refused, even with a real 0600 file behind it. + const linkedConfig = join(root, 'linked-config'); + symlinkSync(configDir, linkedConfig); + const viaConfig = join(linkedConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1'); + assert.deepEqual(checkProtectedFile(viaConfig, { configDir: linkedConfig }), [`${viaConfig}: a symbolic link sits on the path at ${linkedConfig}`]); + // A forbidden root named through an alias still covers the file. + const alias = join(root, 'alias'); + symlinkSync(root, alias); + assert.deepEqual(checkProtectedFile(file, { configDir, dataRoot: alias }), [`${file}: resolves inside dataRoot; mail secrets live outside it`]); + assert.deepEqual(checkProtectedFile(file, { configDir, dataRoot: join(root, 'data') }), []); } finally { rmSync(root, { recursive: true, force: true }); } @@ -231,6 +296,16 @@ test('T12 the key lifecycle moves forward only, and an expired key gives no use' assert.equal(keyUse(contentKey({ state: 'retired' }), now), 'decrypt'); assert.equal(keyUse(contentKey({ state: 'expired' }), now), 'none'); assert.equal(keyUse(contentKey(), new Date('2027-10-22T16:00:00.000Z')), 'none', 'past expiresAt, whatever the state says'); + assert.equal(keyUse(contentKey({ state: 'retired' }), new Date('2027-10-22T15:59:59.999Z')), 'decrypt', 'the last instant before expiresAt'); + assert.equal(keyUse(contentKey(), new Date(AT)), 'encrypt-decrypt', 'from createdAt'); + assert.equal(keyUse(contentKey(), new Date('2026-10-22T15:59:59.999Z')), 'none', 'before createdAt'); + // Malformed records or clock fail closed. + assert.equal(keyUse(contentKey({ state: 'escrowed' }), now), 'none', 'unknown state'); + assert.equal(keyUse(contentKey({ state: 'constructor' }), now), 'none', 'prototype key as state'); + assert.equal(keyUse(contentKey({ createdAt: 'not-a-date' }), now), 'none', 'invalid createdAt'); + assert.equal(keyUse(contentKey({ expiresAt: 'not-a-date' }), now), 'none', 'invalid expiresAt'); + assert.equal(keyUse(contentKey(), new Date(Number.NaN)), 'none', 'invalid clock'); + assert.equal(keyUse(contentKey(), '2027-01-01T00:00:00.000Z'), 'none', 'a clock that is not a Date'); refused('content-key', contentKey({ state: 'escrowed' }), 'must be one of'); refused('content-key', contentKey({ expiresAt: AT }), 'must be after createdAt'); }); @@ -238,7 +313,7 @@ test('T12 the key lifecycle moves forward only, and an expired key gives no use' test('T13 the disabled schema leaves v1 unchanged: no mail kind, no mail action', () => { const kinds = readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'); const vocabulary = readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'); - assert.ok(!/mail/i.test(kinds), 'core mail-source registration waits for the approved addendum and Q4'); + assert.ok(!/mail/i.test(kinds), 'core mail-source registration waits for the approved PRD version and the source, grant and route approvals'); assert.ok(!/mail\.access/.test(vocabulary), 'mail.access waits for a Jason-approved PRD version'); const prd = readFileSync(new URL('../../../docs/prd/mosaic-stack.md', import.meta.url), 'utf8'); assert.ok(!/REQ-MAIL-/.test(prd), 'the addendum is a draft until Jason approves it');