Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .changeset/security-swallow-family-batch-2.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
"@objectstack/plugin-security": patch
---

fix(plugin-security): count and report the refused writes seven `catch {}` sites swallowed (#12981)

Batch 2 of the ruled `catch { return null; }` worklist. The census instrument
that landed with batch 1 (`scripts/measure-durability-swallow-family.mjs`)
named seven tier-1 DARK sites in this package; all seven are repaired here, and
a re-run moves tier 1 from **28 sites in 14 files to 21 in 11** while
`channelled` rises **19 → 26** — the seven, moved, nothing else touched.

Each site swallowed a refused write into a bare `catch`, so a pass in which the
store refused **everything** returned counts identical to a pass with nothing to
do — and in two of the three files the summary log is suppressed on exactly
those counts, so the one boot that needed a line printed none. The refusals are
now recorded through the in-package accumulator, reported **once** per pass with
the consequence and the remedy, and the `> 0` summary suppressors are widened.

- **`bootstrap-system-capabilities.ts`** — a refused `sys_capability` insert on
the *derived* half reached no counter and no log at all, and a refused
*update* was silent on both halves, while the boot went on logging "system
capabilities seeded" at `info` over zero landed rows. Reported on the
durability channel (`error`, falling back to `warn`), stating what is actually
lost: registry state, not authorization — grants resolve capabilities by name,
not by row.
- **`cleanup-package-permissions.ts`** — ADR-0090 D5 promises that uninstalling
a package "revokes it everywhere at once. No ghost grants." A refused deletion
left the grant live while the package door answered `success`, and the
all-zero outcome was the same one an uninstall of a package that granted
nothing returns.
- **`suggested-audience-bindings.ts`** ×4 — refused create / confirm / prune /
reap. The insert site previously filed **every** failure under its documented
"unique-index race — benign" rationale; the shared accumulator classifies with
the shipped `isUniqueViolationError` predicate, so the genuine race is still
treated as benign and excluded, while store outages are counted and reported.

`PackagePermissionCleanupOutcome`, `SuggestionSyncOutcome` and
`CapabilitySeedResult` gain a `refused` count (additive; they are returned, not
constructed by callers).

⚠️ Two of the three files keep their report at `warn` rather than `error`. Their
sinks ride on types exported from this package's `index.ts` that declare `warn`
optional, and adding `error?` would enrol them into
`check:optional-error-sink-contract`'s population, which requires a
non-optional `warn` — a published-shape break, and a contract call above this
repair. The **silence** is what is fixed here and it needed no contract; the
**level** is recorded on #12981.

⛔ No entry was added to `scripts/durability-degradation.baseline.json` and the
gate vocabulary is untouched in either direction, as the 2026-08-29 ruling
requires until the family is repaired.

⭐ One file outside the package changed, declared on #12981 before editing:
`scripts/measure-durability-swallow-family.mjs` (the census instrument, wired
into no workflow) pinned its `dark` positive control to
`bootstrap-system-capabilities.ts` — one of the seven — so repairing it turned
the instrument's own `--self-test` red. The control now names
`plugin-sharing`'s `share-link-service.ts`, which batch 1 judged permanently
OUT of the programme (a `use_count` telemetry stamp), because any tier-1 DARK
member still ON the worklist is a control the programme is designed to destroy.
No predicate, tier or vocabulary change; `--self-test` is green.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,7 +166,7 @@ The largest single consumer — **20 of the 109 sites**.
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:95`, `:128` |
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:139`, `:190` |
| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:548` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
| 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:59`, `webhook-provenance.ts:50` |
| 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `runtime-identity.ts:279`, called from `builtin/crud-nodes.ts:318` |
| 61 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `inbox-caller.ts:148` |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -1260,3 +1260,115 @@ describe('#11518 — a page that could not fit the answer must not report "absen
expect(warn.mock.calls[0][1]).toMatchObject({ organization: 'org_jia' });
});
});

/**
* [#12981] The `catch { return null; }` / `catch { return false; }` swallow
* family — this file is where the card quotes its shape from.
*
* ⚠️ The engines below THROW where `makeQl` returns `null`. That is not a
* harsher double for its own sake: a SQL driver signals a refused write by
* throwing, and `makeQl`'s unique-key model answers `null` instead, so nothing
* in this file previously exercised the `catch` arms at all. Every assertion
* here is about what the seeder does with a thrown refusal.
*/
describe('[#12981] a wholly refused seeding pass is distinguishable from a quiet one', () => {
const CURATED = KNOWN_CAPABILITIES[0];

/** A store that accepts reads and refuses every write. */
function refuseWrites(ql: any) {
ql.insert = async () => { throw new Error('store is refusing writes'); };
ql.update = async () => { throw new Error('store is refusing writes'); };
return ql;
}

it('counts refused INSERTS on the derived half, which reported nothing at all before', async () => {
// The derived half falls through `else if (!isDerived)`, so a refused
// derived insert hit no counter and no log — total silence.
const refused = await bootstrapSystemCapabilities(
refuseWrites(makeQl()), [{ systemPermissions: ['export_data'] }], { logger: { warn: () => {} } },
);
expect(refused.seeded).toBe(0);
expect(refused.refused).toBeGreaterThan(0);

// The steady-state comparison: a pass with nothing to do also seeds 0.
const settled = makeQl();
await bootstrapSystemCapabilities(settled, [{ systemPermissions: ['export_data'] }]);
const quiet = await bootstrapSystemCapabilities(settled, [{ systemPermissions: ['export_data'] }]);
expect(quiet.seeded).toBe(0);
expect(quiet.refused).toBe(0);

// ⛔ Before the repair these two agreed on every field they reported.
expect(refused.refused).not.toBe(quiet.refused);
});

it('counts a refused UPDATE, which was silent on BOTH halves', async () => {
// A platform row that exists but has drifted from the curated definition:
// the seeder reaches `tryUpdate`, and `tryUpdate` answered `false` for
// "refused" and for "nothing to do" alike.
const ql = makeQl();
ql.rows.push({
id: 'cap_drifted', name: CURATED.name, label: 'STALE', description: 'STALE',
scope: CURATED.scope, managed_by: 'platform', organization_id: null, active: true,
});
ql.update = async () => { throw new Error('store is refusing writes'); };

const out = await bootstrapSystemCapabilities(ql, [], { logger: { warn: () => {} } });
expect(out.updated).toBe(0);
expect(out.unchanged).toBe(0);
expect(out.refused).toBeGreaterThan(0);
// The row is still stale — the point of counting.
expect(ql.rows.find((r: any) => r.id === 'cap_drifted').label).toBe('STALE');
});

it('reports ONCE on the durability channel, with the consequence and the remedy', async () => {
const errors: Array<{ msg: string; meta?: any }> = [];
const warns: string[] = [];
const out = await bootstrapSystemCapabilities(
refuseWrites(makeQl()), [{ systemPermissions: ['export_data'] }],
{
logger: {
warn: (m: string) => { warns.push(m); },
error: (msg: string, _e?: Error, meta?: any) => { errors.push({ msg, meta }); },
},
},
);

// ONE line for the whole pass, on `error` — a refused registry write is a
// durability degradation (AGENTS.md "Degradation log levels").
expect(errors).toHaveLength(1);
expect(errors[0].msg).toContain('REFUSED');
expect(errors[0].msg).toContain('WILL GO ON LOOKING');
// The CONSEQUENCE is stated without overclaiming: registry state, not authz.
expect(errors[0].msg).toContain('resolve capabilities BY NAME');
// ...and the REMEDY, both classes named.
expect(errors[0].msg).toContain('os migrate');
expect(errors[0].meta).toMatchObject({ refused: out.refused });
});

it('falls back to `warn` when the host injected a sink with no `error`', async () => {
// `logSeedDurabilityFailure`'s contract: an absent `error` has a declared
// destination. ⛔ Never `logger?.error?.(…)`, which prints nothing here.
const warns: string[] = [];
await bootstrapSystemCapabilities(
refuseWrites(makeQl()), [{ systemPermissions: ['export_data'] }],
{ logger: { warn: (m: string) => { warns.push(m); } } },
);
expect(warns.filter((m) => m.includes('REFUSED'))).toHaveLength(1);
});

it('adds no steady-state noise — a settled pass reports nothing new', async () => {
const errors: string[] = [];
const warns: string[] = [];
const ql = makeQl();
await bootstrapSystemCapabilities(ql, []);
const out = await bootstrapSystemCapabilities(ql, [], {
logger: {
warn: (m: string) => { warns.push(m); },
error: (m: string) => { errors.push(m); },
},
});
expect(out.refused).toBe(0);
expect(errors).toEqual([]);
expect(warns.filter((m) => m.includes('REFUSED'))).toEqual([]);
});
});
Loading
Loading