Row 258 (E01, #1763) r2: della-0023 F1 (mail use helpers fail closed on malformed records or clock and outside the grant or key lifetime), F2 (secret placement compared after resolving links and root aliases; protected file under configDir with no linked configDir), N1 (key transition is a pure edge check; E03 owns anti-rollback); mos-1313 W1-W4 draft wording; mutants M1-M11 (Vikunja 460)

This commit is contained in:
jason.woltje committed 2026-10-22 11:54:11 -05:00
1 parent a8f0cf9248
commit 3094e3bfa4
5 files changed
+237 -73

No files matched your search

+49 -13
View File
@@ -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. | | `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/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/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 | | `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`, 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. 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 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. to E03. `checkKeyLocation` and `checkProtectedFile` apply to both.
9. **Key lifecycle.** active → retired → expired, or active → expired. No step backwards, so a restored 9. **Key lifecycle.** active → retired → expired, or active → expired. `keyTransitionAllowed` is a pure
record can't revive a key. Past `expiresAt`, use is none whatever the state says. 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`, 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. 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) ## 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 `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/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) - `packages/cli/tests/stack-copy.test.mjs`, `packages/cli/tests/stack-release.test.mjs` (fixture copies)
Suggested mutants (the reviewer names the final set): Mutants, named by Della (della-0023). Each must die on a semantic assertion in the whole
- M1 `defaults`: `mailboxScope` default `["*"]` or a fixture mailbox → T1, T2 fail. `schema.test.mjs`, run on a CLEAR slot. The wildcard tests stay.
- M2 drop `const: "human"` from `changedBy.kind` → T3 fails.
- M3 drop the wildcard check → T5 fails on its message. | Mutant | Change | Must fail |
- M4 drop `looksSecret` → T6 fails. | --- | --- | --- |
- M5 `effectiveMailboxes` ignores the grant's mailboxes → T2 fails. | M1 | `mailboxScope` default nonempty | T1/T2 |
- M6 `inside()` returns false → T10 fails. | M2 | allow an agent settings author | T3 |
- M7 `checkProtectedFile` accepts mode 640 → T11 fails. | M3 | remove the source/business binding comparison | T8 |
- M8 allow `retired → active` → T12 fails. | M4 | disable secret-shape rejection | T6 |
- M9 raise `pageSize` maximum to 101 → T9 fails. | 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).
+29 -27
View File
@@ -6,11 +6,16 @@
it lands there as a new PRD version, and only then does any requirement it lands there as a new PRD version, and only then does any requirement
here bind. here bind.
- Source: `docs/plans/2026-10-21_email-plan.md`, Della's revision - Source: `docs/plans/2026-10-21_email-plan.md`, Della's revision
179391055, approved by Ludwig (ludwig-0037). Q217 settles the mailbox 179391055, approved by Ludwig (ludwig-0037). DECISIONS Q217 settles
(one agent mailbox on woltje.com at InMotion, which Jason creates). the mailbox (one agent mailbox on woltje.com at InMotion, which Jason
Q222 settles personal mail (none for now) and the settings rule: things creates). DECISIONS Q222 settles personal mail (none for now) and the
are tunable, not hardcoded. 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. - 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 ## 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 setting may take. Implementers and reviewers set that range. Widening it
needs a reviewed schema and test change. Only an authenticated human needs a reviewed schema and test change. Only an authenticated human
changes a setting, through the audited CLI and later the Console. An 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 agent can't change one, not even to narrow it. For mail, an invalid
closed. 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 Each requirement lists the build rows that deliver it. The rows are in
the email plan's "Ordered build rows". 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 fenced before in-flight content returns. A read is the intersection of
the source scope, the provider's rights and the principal's current the source scope, the provider's rights and the principal's current
grant. Widening a scope grants no agent anything. No personal mailbox 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. Rows: E01, E02, E13.
- **REQ-MAIL-3.** A new plug-in kind, `mail-source`, carries providers - **REQ-MAIL-3.** A new plug-in kind, `mail-source`, carries providers
(REQ-PLUG-1). Its modules export `descriptor`, `scan`, `fetch` and (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 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 modules. POP3, Gmail API and JMAP wait for a named need. The kind
enters `packages/plugins/src/kinds.mjs` with its first production 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. Rows: E05, E06.
- **REQ-MAIL-4.** A source connects through its own service principal, - **REQ-MAIL-4.** A source connects through its own service principal,
`mail-service`. This is a new approved extension, not an exception to `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 quarantined, never dropped. Active content never runs. Attachments
start as metadata. An authorized fetch quarantines the bytes, by start as metadata. An authorized fetch quarantines the bytes, by
default up to 10 MiB per part and per message. Extracting attachment 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. isolated with no network and a scanner.
Rows: E04, E16. Rows: E04, E16.
- **REQ-MAIL-7.** A route table maps trusted delivery evidence to a Stack - **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 mint a grant or pose as the human. Mail never wakes an agent by
default (`unattendedWake` is off, and its sender allowlist is empty). 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 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 The woken work runs in a fresh restricted profile, and anything it
starts keeps `source: mail`. starts keeps `source: mail`.
Rows: E10. Rows: E10.
- **REQ-MAIL-11.** A mail-triggered decision goes through the stack's one - **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, schema. The mail service never calls a model itself. Without a model,
deterministic intake keeps working and model work is held. deterministic intake keeps working and model work is held.
Rows: E11. Rows: E11.
@@ -122,7 +131,7 @@ the email plan's "Ordered build rows".
cursor commit, lag, backlog and credential state. A live process alone cursor commit, lag, backlog and credential state. A live process alone
is not healthy. Outages pause only the affected source. Recovery is not healthy. Outages pause only the affected source. Recovery
follows the system manager's protected mode and its recovery latch 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. Rows: E12.
- **REQ-MAIL-13.** Mail data lives under `<dataRoot>/mail/<business>/` - **REQ-MAIL-13.** Mail data lives under `<dataRoot>/mail/<business>/`
and never enters git or an image. By default the stack keeps metadata and never enters git or an image. By default the stack keeps metadata
@@ -130,8 +139,9 @@ the email plan's "Ordered build rows".
`<configDir>/secrets/mosaic-stack/mail-content/<business>/`, outside `<configDir>/secrets/mosaic-stack/mail-content/<business>/`, outside
dataRoot and its backups, mode 600, with no symbolic link. Retention dataRoot and its backups, mode 600, with no symbolic link. Retention
defaults: bodies and drafts 30 days, attachments 7 days, metadata 365 defaults: bodies and drafts 30 days, attachments 7 days, metadata 365
days. A retention setting never authorizes deletion. Deleting needs days. When an item's time runs out, the stack stops serving it. A
`data.delete` or a retention authority Jason approves later. 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 restored backup must not bring back expired content or redeliver old
work. work.
Rows: E01, E03, E13. Rows: E01, E03, E13.
@@ -142,21 +152,13 @@ the email plan's "Ordered build rows".
Rows: E05, E06, E09, E14. Rows: E05, E06, E09, E14.
- **REQ-MAIL-15.** Every mail limit, scope and retention time is a - **REQ-MAIL-15.** Every mail limit, scope and retention time is a
setting. The schema holds its default and range. Only a human changes 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. override grants, hard waits or the system manager's contract.
Rows: E01, E04, E13. Rows: E01, E04, E13.
- **REQ-MAIL-16.** A live pilot is separate. It needs Jason's mailbox, - **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. is judged on evidence for each message, not counts.
Rows: E15. 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.
+18 -8
View File
@@ -3,7 +3,7 @@
Post-v1. This package holds the record schemas for mail intake and access, 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 and a validator. Nothing here opens a connection, reads a secret or
registers a plug-in kind. Core `mail-source` registration waits for the 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. `docs/prd/mail-addendum-draft.md`; it binds nothing until Jason approves it.
| Schema | Record | | 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/service.schema.json` | A source's service principal and its slot path |
| `schemas/content-key.schema.json` | A content key's location and lifecycle | | `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 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)` 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 different business, source or provider, and a grant past the source's
mailbox scope. mailbox scope.
- `effectiveMailboxes(...)` intersects the source scope, the provider's - `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 - `checkSlotLocation` and `checkKeyLocation` keep provider slots and
content keys under `<configDir>/secrets/mosaic-stack/`, outside content keys under `<configDir>/secrets/mosaic-stack/`, outside
dataRoot, the repository and any backup root. dataRoot, the repository and any backup root. They compare the paths
- `checkProtectedFile` wants a regular file, owned, mode 600, with no as written and again after resolving symbolic links, so a linked
symbolic link between configDir and the file. configDir or an aliased root can't hide a secret inside a forbidden root.
- `keyTransitionAllowed` and `keyUse` move a key forward only: active, - `checkProtectedFile` wants a regular file under configDir, owned, mode
retired (decrypt only), expired (no use). 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. Tests: `node --test tests/*.test.mjs` from this directory.
+64 -23
View File
@@ -1,8 +1,9 @@
import { lstatSync, readFileSync, realpathSync } from 'node:fs'; 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 // 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']); export const KINDS = Object.freeze(['source', 'data', 'access', 'service', 'content-key']);
const KEYWORDS = new Set(['$schema', '$id', 'type', 'const', 'enum', 'properties', 'required', const KEYWORDS = new Set(['$schema', '$id', 'type', 'const', 'enum', 'properties', 'required',
'additionalProperties', 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength', 'additionalProperties', 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength',
@@ -137,11 +138,16 @@ export function checkBinding({ source, service, access }) {
return problems; 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. // 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() }) { export function effectiveMailboxes({ source, providerMailboxes = [], access = null, now = new Date() }) {
if (!source.sourceEnabled || !access) return []; if (!access || validateRecord('source', source).length || validateRecord('access', access).length) return [];
if (Date.parse(access.expiresAt) <= now.getTime()) 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 []; if (access.business !== source.business || access.source !== source.source) return [];
const provider = new Set(providerMailboxes); const provider = new Set(providerMailboxes);
const granted = new Set(access.mailboxes); const granted = new Set(access.mailboxes);
@@ -152,14 +158,38 @@ function inside(root, path) {
const rel = relative(root, path); const rel = relative(root, path);
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel)); 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 = []; const problems = [];
if (!isAbsolute(path) || normalize(path) !== path) return [`${label}: must be a normalized absolute path`]; const roots = [['dataRoot', dataRoot], ['the repository', repoRoot], ...backupRoots.map(root => ['a backup root', root])]
if (!inside(expectedDir, path) || path === expectedDir) problems.push(`${label}: must sit under ${expectedDir}`); .filter(([, root]) => root);
for (const [name, root] of [['dataRoot', dataRoot], ['the repository', repoRoot], ...backupRoots.map(root => ['a backup root', root])]) for (const [name, root] of roots) if (inside(root, path)) problems.push(`${label}: inside ${name}; mail secrets live outside it`);
if (root && 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; 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). // 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 slotDir = ({ configDir, business, source }) => join(configDir, 'secrets', 'mosaic-stack', 'mail', business, source);
export const checkSlotLocation = (service, roots) => export const checkSlotLocation = (service, roots) =>
@@ -172,25 +202,36 @@ export function checkKeyLocation(record, roots) {
return problems; return problems;
} }
// A protected file: owned, mode 600, regular, no symbolic link between configDir and the file. // A protected file: inside configDir, owned, mode 600, regular, with no symbolic link from configDir
export function checkProtectedFile(path, { configDir, uid = process.getuid() }) { // 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; let stat;
try { stat = lstatSync(path); } catch { return [`${path}: not found`]; } 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`]; 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.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`); if ((stat.mode & 0o777) !== 0o600) problems.push(`${path}: mode ${(stat.mode & 0o777).toString(8)}; use 600`);
let real = null; return [...problems, ...forbidden(path, path, roots)];
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;
} }
// Key lifecycle: active keys encrypt and decrypt, retired keys only decrypt, expired keys do neither and // Key lifecycle states: active keys encrypt and decrypt, retired keys only decrypt, expired keys do
// every copy is destroyed. No state moves backwards, so a restored backup can't revive a key. // 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: [] }; 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 keyTransitionAllowed = (from, to) => Object.hasOwn(KEY_STEPS, from) && KEY_STEPS[from].includes(to);
export const keyUse = (record, now = new Date()) => // The use a record allows now. An invalid record or clock, or a time outside [createdAt, expiresAt), is none.
record.state === 'expired' || Date.parse(record.expiresAt) <= now.getTime() ? 'none' export function keyUse(record, now = new Date()) {
: record.state === 'retired' ? 'decrypt' : 'encrypt-decrypt'; 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';
}
+77 -2
View File
@@ -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: 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, 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'); 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') }), ['[email protected]'], '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: '[email protected]' }), [], 'provider list that is not an array');
}); });
test('T3 only a human changes settings', () => { 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']); ['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', () => { 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-')); const root = mkdtempSync(join(tmpdir(), 'mail-schema-'));
try { 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 }); writeFileSync(join(realDir, 'k3'), 'synthetic', { mode: 0o600 });
symlinkSync(realDir, join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta')); symlinkSync(realDir, join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta'));
const viaLink = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta', 'k3'); 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`]); assert.deepEqual(checkProtectedFile(join(dir, 'missing'), { configDir }), [`${join(dir, 'missing')}: not found`]);
mkdirSync(join(dir, 'k4')); mkdirSync(join(dir, 'k4'));
assert.deepEqual(checkProtectedFile(join(dir, 'k4'), { configDir }), [`${join(dir, 'k4')}: must be a regular file, not a symbolic link`]); 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 { } finally {
rmSync(root, { recursive: true, force: true }); 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: 'retired' }), now), 'decrypt');
assert.equal(keyUse(contentKey({ state: 'expired' }), now), 'none'); 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(), 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({ state: 'escrowed' }), 'must be one of');
refused('content-key', contentKey({ expiresAt: AT }), 'must be after createdAt'); 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', () => { 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 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'); 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'); 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'); 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'); assert.ok(!/REQ-MAIL-/.test(prd), 'the addendum is a draft until Jason approves it');