From a208916dc7502a58866b95f0158343e7774f120a Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 9 Aug 2026 21:50:50 +0000 Subject: [PATCH 1/2] fix(plugin-audit): forward the caller's execution envelope to the sys_comment sharing gates (#7141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `callerContext()` rebuilt a five-field projection of the caller's `ExecutionContext` before handing it to `ISharingService.canEdit`, whose contract declares the full envelope and whose doc block forbids callers from rebuilding a subset of it (#6523 / the #6206 ruling). #7136 (PR #7140) widened the return annotation; this is the deferred body change. The projection was doing two jobs. Stripping the middleware-private keys is correct and is preserved — plugin-security stamps the access DEPTH resolved for `sys_comment` onto the context in place, while these gates ask the sharing service about the PARENT record's object — but it is now done by the `__` PREFIX, which also covers the engine's other operation-private markers (`__expandRead` waives object-level CRUD) and cannot go stale. Stripping the principal fields was the defect: `onBehalfOf` (which `ISecurityService.hasWriteBypass` reads to fail CLOSED on a delegated context) and `principalKind` (which `resolvePermissionSetsForContext` reads to cap an ADR-0090 D10 agent at its consented ceiling instead of appending the additive human baseline), plus `systemPermissions`, `accessible_org_ids`, `posture`, `audience` and `rlsMembership`. No access depth is synthesised for the parent object: absent depth leaves the owner-match at its narrowest (`own`), byte-for-byte what the projection produced. The read side's parent probe gets the same envelope-minus-private-keys treatment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ --- .changeset/comment-gate-caller-envelope.md | 56 +++++ .../src/comment-access-hooks.test.ts | 195 ++++++++++++++++++ .../plugin-audit/src/comment-access-hooks.ts | 108 ++++++++-- .../src/comment-read-visibility.test.ts | 39 +++- 4 files changed, 376 insertions(+), 22 deletions(-) create mode 100644 .changeset/comment-gate-caller-envelope.md diff --git a/.changeset/comment-gate-caller-envelope.md b/.changeset/comment-gate-caller-envelope.md new file mode 100644 index 0000000000..0c03a81c4d --- /dev/null +++ b/.changeset/comment-gate-caller-envelope.md @@ -0,0 +1,56 @@ +--- +"@objectstack/plugin-audit": patch +--- + +fix(plugin-audit): forward the caller's full execution envelope to the `sys_comment` sharing gates (#7141) + +`callerContext()` in `comment-access-hooks.ts` rebuilt a five-field projection +of the caller's `ExecutionContext` (`userId` / `tenantId` / `positions` / +`permissions` / `isSystem`) before handing it to `ISharingService.canEdit`, +whose contract declares the **full** envelope and whose doc block tells callers +they "MUST NOT rebuild a subset of it" (#6523 / the #6206 ruling). #7136 (PR +#7140) widened the return *annotation*; this is the body. + +The projection was doing two jobs at once and only one of them was correct: + +- **Dropping the middleware-private keys was correct**, and is preserved. + plugin-security's middleware stamps the access DEPTH it resolved for the + object of the operation in flight — `sys_comment` — onto the context in place + (`sc.__readScope = …`), while these gates ask the sharing service about the + **parent record's** object. Forwarding that whole would hand one object's + widening to another object's owner-match, the stale-scope leak + `resolveWriteScopeForSharing` was extracted to prevent. The keys are now + dropped by the `__` **prefix** rather than by name, which also covers the + engine's other operation-private markers on that channel (`__expandRead` + waives the object-level CRUD check, `__referentialFieldClear` the + referential-clear write) and cannot go stale when a fifth key is added. +- **Dropping the principal fields was the defect.** Two of them decide the + verdict this gate then trusts: + - `onBehalfOf` — `ISecurityService.hasWriteBypass`, the `modifyAllRecords` + probe `SharingService.canEdit` consults last, is documented to fail CLOSED + on a delegated context and implements that by reading exactly + `context?.onBehalfOf?.userId`. Stripped, the guard could never fire on this + path, and the `/mcp` OAuth agent principal that `resolve-execution-context` + builds *with* the delegation link reached the bypass probe looking like an + ordinary direct call. + - `principalKind` — `resolvePermissionSetsForContext` keys the ADR-0090 D10 + rule "an agent's grants are EXACTLY its scope-derived ceiling" on + `principalKind === 'agent'`. Stripped, the additive human baseline was + appended to an agent's ceiling here, so the sets the bypass probe evaluated + were a superset of what the user consented to. + + `systemPermissions`, `accessible_org_ids`, `posture`, `audience` and + `rlsMembership` were dropped by the same projection and are forwarded now for + the same reason. + +The same envelope-minus-private-keys rule is applied to the read side's +parent-record probe, which spread the whole operation context into a `find` on +a different object. + +No access depth is synthesised for the parent object: absent depth leaves the +sharing owner-match at its narrowest (`own`), which is the safe direction and +byte-for-byte what the projection produced. Resolving the parent's own depth +would WIDEN this gate and is deliberately left as a separate decision. + +Enforcement effect: a delegated (`onBehalfOf`-carrying) principal is now refused +where the contract says it is refused. No caller gains access. diff --git a/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts b/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts index 31305d2054..f1dc8d6720 100644 --- a/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts +++ b/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts @@ -301,3 +301,198 @@ describe('comment access — beforeDelete (author or parent editor)', () => { ).resolves.toBeUndefined(); }); }); + + +// ───────────────────────────────────────────────────────────────────────── +// #7141 — what the gate FORWARDS to the sharing service +// ───────────────────────────────────────────────────────────────────────── + +/** + * The caller's execution envelope as a real transport builds it — an OAuth MCP + * agent principal acting on behalf of a human (`resolve-execution-context.ts` + * is the live producer of `principalKind: 'agent'` + `onBehalfOf`) — with the + * middleware-private keys plugin-security stamps for the object of the + * operation in flight (`sys_comment`) riding along, because that is exactly + * what `sc.__readScope = …` leaves on the context these hooks receive. + */ +const DELEGATED_ENVELOPE = { + userId: 'human_1', + tenantId: 'org_1', + email: 'human@example.com', + positions: [], + permissions: ['mcp_agent_data_write'], + systemPermissions: [], + principalKind: 'agent', + onBehalfOf: { userId: 'human_1', principalKind: 'human' }, + audience: 'internal', + posture: 'authenticated', + accessible_org_ids: ['org_1'], + rlsMembership: { team: ['t1'] }, + isSystem: false, + // Middleware-private, resolved for `sys_comment` — NOT for the parent. + __readScope: 'org', + __writeScope: 'org', + __delegatorReadScope: 'org', + __delegatorWriteScope: 'org', + __expandRead: true, +} as const; + +/** The same context, shaped the way the write hooks receive it. */ +const envelopeWriteCtx = ( + event: 'beforeUpdate' | 'beforeDelete', + input: any, + exec: Record, +) => ({ + object: 'sys_comment', + event, + input: { ...input, options: { ...(input.options ?? {}), context: exec } }, + session: { userId: exec.userId as string }, + api: apiFor([]), +}); + +/** The deployment's `fallbackPermissionSet` (ADR-0056 D7: an app's `isDefault` + * profile, else the built-in `member_default`). */ +const DEPLOYMENT_BASELINE_SET = 'app_default_profile'; + +/** + * `ISecurityService.hasWriteBypass` as plugin-security implements it + * (`security-plugin.ts`) — the three guard lines, then the `modifyAllRecords` + * set probe. A DOUBLE, not a copy of production logic: plugin-audit does not + * depend on plugin-security (dependency-free posture), so the only way to pin + * the OUTCOME on this side of the seam is to model the contract the gate is + * documented to be talking to. `setsWithBypass` names which permission sets + * carry the bit in the modelled deployment. + */ +function hasWriteBypassDouble(context: any, setsWithBypass: string[]): boolean { + if (context?.isSystem) return true; + if (!context?.userId) return false; + if (context?.onBehalfOf?.userId) return false; // documented fail-CLOSED on delegation + // `resolvePermissionSetsForContext`: positions + explicit sets, plus the + // ADDITIVE human baseline — which an ADR-0090 D10 agent principal must NOT + // receive (its grants are exactly its scope-derived ceiling). + const requested = [...(context?.positions ?? []), ...(context?.permissions ?? [])]; + const resolved = + context?.principalKind === 'agent' ? requested : [...requested, DEPLOYMENT_BASELINE_SET]; + return resolved.some((name: string) => setsWithBypass.includes(name)); +} + +/** + * `SharingService.checkEdit`'s positive bases, in order: ownership widened by + * the middleware-stamped write DEPTH (`matchesOwnerScope` — `__writeScope === + * 'org'` short-circuits to true), then the `modifyAllRecords` bypass. The share + * branch is omitted (no grants in these fixtures). + */ +function sharingCanEditDouble(opts: { ownerId: string; setsWithBypass?: string[] }) { + return vi.fn(async (_object: string, _recordId: string, callerCtx: any) => { + if (callerCtx?.isSystem) return true; + if (!callerCtx?.userId) return false; + if ((callerCtx as any).__writeScope === 'org') return true; // depth fast-exit + if (String(callerCtx.userId) === opts.ownerId) return true; + return hasWriteBypassDouble(callerCtx, opts.setsWithBypass ?? []); + }); +} + +describe('#7141 — caller envelope forwarded to the sharing gate', () => { + const row = { id: 'c1', thread_id: 'crm_opportunity:opp1', author_id: 'someone_else', body: 'hi' }; + + it('forwards the whole envelope MINUS the operation-private keys', async () => { + const canEdit = vi.fn(async () => true); + const { beforeDelete } = install({ comments: [row], sharing: { canEdit } }); + await beforeDelete(envelopeWriteCtx('beforeDelete', { id: 'c1' }, { ...DELEGATED_ENVELOPE })); + + const forwarded = canEdit.mock.calls[0]![2] as Record; + // Every principal field survives — the #6523 contract's unit is the envelope + // and #6206 forbids rebuilding a subset of it. + expect(forwarded).toEqual({ + userId: 'human_1', + tenantId: 'org_1', + email: 'human@example.com', + positions: [], + permissions: ['mcp_agent_data_write'], + systemPermissions: [], + principalKind: 'agent', + onBehalfOf: { userId: 'human_1', principalKind: 'human' }, + audience: 'internal', + posture: 'authenticated', + accessible_org_ids: ['org_1'], + rlsMembership: { team: ['t1'] }, + isSystem: false, + }); + // …and every middleware-private key resolved for `sys_comment` is gone. + for (const key of ['__readScope', '__writeScope', '__delegatorReadScope', '__delegatorWriteScope', '__expandRead']) { + expect(forwarded).not.toHaveProperty(key); + } + }); + + it('hands the service a COPY, so a callee stamping its own depth cannot write back', async () => { + const exec: Record = { ...DELEGATED_ENVELOPE }; + const canEdit = vi.fn(async (_o: string, _r: string, callerCtx: any) => { + // What plugin-security does right before it calls the sharing service. + callerCtx.__writeScope = 'unit'; + return true; + }); + const { beforeDelete } = install({ comments: [row], sharing: { canEdit } }); + await beforeDelete(envelopeWriteCtx('beforeDelete', { id: 'c1' }, exec)); + + expect(canEdit.mock.calls[0]![2]).not.toBe(exec); + expect(exec.__writeScope).toBe('org'); // untouched: still sys_comment's own + }); + + it('REFUSES a delegated principal whose sets carry modifyAllRecords (fail-closed, #7141)', async () => { + // The exploit shape the card names: an OAuth agent on the `/mcp` surface + // presenting sets that carry the super-user write bypass. `hasWriteBypass` + // is documented to fail CLOSED on `onBehalfOf` — it can only do that if the + // field reaches it. + const canEdit = sharingCanEditDouble({ ownerId: 'other_owner', setsWithBypass: ['admin_full_access'] }); + const { beforeDelete } = install({ comments: [row], sharing: { canEdit } }); + await expect( + beforeDelete( + envelopeWriteCtx('beforeDelete', { id: 'c1' }, { + ...DELEGATED_ENVELOPE, + permissions: ['admin_full_access'], + }), + ), + ).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); + expect(canEdit).toHaveBeenCalledTimes(1); + }); + + it('keeps an AGENT principal capped at its ceiling — no additive human baseline (ADR-0090 D10)', async () => { + // `resolvePermissionSetsForContext` keys that rule on `principalKind`, which + // the old projection dropped: the agent was resolved as a human and the + // deployment's default profile was appended to its consented ceiling. + const canEdit = sharingCanEditDouble({ + ownerId: 'other_owner', + setsWithBypass: [DEPLOYMENT_BASELINE_SET], + }); + const { beforeDelete } = install({ comments: [row], sharing: { canEdit } }); + await expect( + beforeDelete( + envelopeWriteCtx('beforeDelete', { id: 'c1' }, { + ...DELEGATED_ENVELOPE, + onBehalfOf: undefined, // isolate the ceiling rule from the delegation guard + }), + ), + ).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); + }); + + it('does NOT carry sys_comment\'s access DEPTH into the parent\'s owner-match', async () => { + // The half of the old projection that was CORRECT and must survive: the + // context carries `__writeScope: 'org'` resolved for `sys_comment`, and the + // gate asks about `crm_opportunity`. Forwarding it whole would widen one + // object's question with another object's answer. + const canEdit = sharingCanEditDouble({ ownerId: 'other_owner' }); + const { beforeDelete } = install({ comments: [row], sharing: { canEdit } }); + await expect( + beforeDelete( + envelopeWriteCtx('beforeDelete', { id: 'c1' }, { + ...DELEGATED_ENVELOPE, + principalKind: 'human', + onBehalfOf: undefined, + userId: 'plain_member', + permissions: [], + }), + ), + ).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 }); + expect((canEdit.mock.calls[0]![2] as any).__writeScope).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-audit/src/comment-access-hooks.ts b/packages/plugins/plugin-audit/src/comment-access-hooks.ts index 5543bb3c2c..e37a0b6b5a 100644 --- a/packages/plugins/plugin-audit/src/comment-access-hooks.ts +++ b/packages/plugins/plugin-audit/src/comment-access-hooks.ts @@ -180,35 +180,90 @@ function asIdList(id: unknown): Array | null { return null; } +/** + * Keys plugin-security's middleware STAMPS onto the operation context, resolved + * for the object of the CURRENT operation — `sys_comment` here. + * + * They are middleware-private vocabulary, not fields of `ExecutionContext`, and + * they are all read as WIDENING inputs by whoever consumes them: the ADR-0057 + * D1 access DEPTH the sharing owner-match expands to (`__readScope` / + * `__writeScope`, plus the ADR-0090 D10 delegator halves + * `__delegatorReadScope` / `__delegatorWriteScope`, `security-plugin.ts` — `sc.__readScope = …`), + * and the engine's internal privilege markers on the same channel + * (`__expandRead` waives the object-level CRUD check for a lookup expansion, + * `__referentialFieldClear` the referential-clear write). + * + * Every gate in this module asks about the PARENT record's object, never about + * `sys_comment`, so carrying any of these across is one object's widening + * applied to another object's question — the exact stale-scope leak + * `resolveWriteScopeForSharing` was extracted to prevent ("a stale value can + * never leak in through a spread", `security-plugin.ts`). They are therefore + * dropped by PREFIX rather than by a name list: the `__` convention is what + * marks a key as belonging to the operation in flight, and a list would go + * stale the day the middleware stamps a fifth one. + */ +const OPERATION_PRIVATE_KEY_PREFIX = '__'; + +/** + * The caller's execution envelope, minus the operation-private keys above. + * + * [#7141] A FRESH object every time, so a callee that stamps its own + * `__writeScope` onto what it receives (which is exactly what plugin-security + * does before it calls the sharing service) can never write back into the + * operation context this hook was handed. + */ +function withoutOperationPrivateKeys(exec: Record): ExecutionContext { + const out: Record = {}; + for (const [key, value] of Object.entries(exec)) { + if (key.startsWith(OPERATION_PRIVATE_KEY_PREFIX)) continue; + out[key] = value; + } + return out as ExecutionContext; +} + /** The caller's ExecutionContext rides on the operation options — the session * snapshot lacks `permissions`, which sharing bypasses need. * * [#7136] Typed as the full envelope, which is what `ISharingService` declares * for every parameter this value is handed to (#6523 / the #6206 ruling). * - * ⚠️ The BODY still projects a five-field subset, which the same ruling tells - * callers not to do — and that half is deliberately NOT changed here, because - * it is not the inert half. Widening the annotation is type-side; forwarding - * `exec` whole is a RUNTIME change. plugin-security's middleware MUTATES the - * operation context in place (`sc.__readScope = …`, `security-plugin.ts`), so - * the context this hook receives carries the depth resolved for `sys_comment` — - * the object of the operation — while these gates ask the sharing service about - * the PARENT record's object. Forwarding it would hand one object's access - * depth to another object's owner-match, the exact stale-scope leak - * `resolveWriteScopeForSharing` was extracted to prevent ("a stale value can - * never leak in through a spread"). This projection is currently what stops - * that, so replacing it needs its own card and its own evidence — filed rather - * than folded in. */ + * [#7141] And FORWARDED as the full envelope, which is the other half of that + * ruling: a caller "MUST NOT rebuild a subset of it". The five-field projection + * this replaced (`userId` / `tenantId` / `positions` / `permissions` / + * `isSystem`) was doing two jobs at once, and only one of them was correct: + * + * - dropping the middleware-private keys — CORRECT, and preserved above by + * {@link withoutOperationPrivateKeys}: `return exec;` would hand + * `sys_comment`'s access depth to the parent object's owner-match; + * - dropping the PRINCIPAL fields — the defect. Two of them decide the + * verdict the gate then trusts: + * * `onBehalfOf` — `ISecurityService.hasWriteBypass`, the `modifyAllRecords` + * probe `SharingService.canEdit` consults last, is documented to fail + * CLOSED on a delegated context and implements that by reading exactly + * `context?.onBehalfOf?.userId` (`security-plugin.ts`). Stripped, that + * guard could never fire here, and a `/mcp` OAuth agent principal (which + * `resolve-execution-context.ts` builds WITH the delegation link) reached + * the bypass probe looking like an ordinary direct call. + * * `principalKind` — `resolvePermissionSetsForContext` keys the ADR-0090 + * D10 rule "an agent's grants are EXACTLY its scope-derived ceiling" on + * `principalKind === 'agent'`; stripped, the additive human baseline + * (`member_default`) was appended to an agent's ceiling on this path, so + * the sets the bypass probe evaluated were a SUPERSET of what the user + * consented to. + * + * `systemPermissions`, `accessible_org_ids`, `posture`, `audience` and + * `rlsMembership` were dropped by the same projection; they are forwarded + * now for the same reason — the envelope is the contract's unit. + * + * Note what deliberately did NOT change: no access DEPTH is synthesised for the + * parent object. Absent depth leaves the sharing owner-match at its narrowest + * (`own`) — the safe direction, and byte-for-byte the behaviour the projection + * produced. Resolving the parent's own depth (the other candidate shape) would + * WIDEN this gate and is a separate decision; see #7141's PR discussion. */ function callerContext(ctx: any): ExecutionContext { const exec = ctx?.input?.options?.context; if (exec && typeof exec === 'object') { - return { - userId: exec.userId, - tenantId: exec.tenantId, - positions: exec.positions, - permissions: exec.permissions, - isSystem: exec.isSystem, - }; + return withoutOperationPrivateKeys(exec as Record); } const s = ctx?.session ?? {}; return { userId: s.userId, tenantId: s.tenantId ?? s.organizationId, positions: s.positions }; @@ -506,6 +561,17 @@ async function computeThreadVisibilityFilter( // 2. Per parent object, the visible id subset via the CALLER's context — // the parent object's own RLS/OWD/sharing applies. + // + // [#7141] The caller's envelope, minus the operation-private keys: this + // probe reads a DIFFERENT object than the one the middleware resolved its + // depth for, and `__readScope` / `__expandRead` are widening inputs that + // would arrive attached to the wrong question (the security middleware + // re-stamps the depth for THIS object when it resolves any set, so the + // only thing dropping them can do is leave the owner-match at its + // narrowest — the safe direction). Same rule as `callerContext` above. + const callerEnvelope = withoutOperationPrivateKeys( + (ctx.context ?? {}) as Record, + ); const visibleByObject = new Map>(); for (const [parentObject, idSet] of byObject) { const ids = [...idSet]; @@ -515,7 +581,7 @@ async function computeThreadVisibilityFilter( where: { id: { $in: ids } }, fields: ['id'], limit: ids.length, - context: { ...ctx.context }, + context: { ...callerEnvelope }, }); visible = rows.map((r) => String(r.id)).filter(Boolean); } catch { diff --git a/packages/plugins/plugin-audit/src/comment-read-visibility.test.ts b/packages/plugins/plugin-audit/src/comment-read-visibility.test.ts index 0e8b298594..cc4977be71 100644 --- a/packages/plugins/plugin-audit/src/comment-read-visibility.test.ts +++ b/packages/plugins/plugin-audit/src/comment-read-visibility.test.ts @@ -75,7 +75,11 @@ function install(opts: { visible: Record>; }) { let mw!: (ctx: CommentReadMiddlewareCtx, next: () => Promise) => Promise; - const calls = { parentFinds: [] as Array<{ object: string; ids: string[]; userId?: string }> }; + const calls = { + parentFinds: [] as Array<{ object: string; ids: string[]; userId?: string }>, + /** [#7141] The context each parent probe was handed, verbatim. */ + parentFindContexts: [] as any[], + }; const engine: CommentAccessEngine = { registerHook: () => {}, @@ -90,6 +94,7 @@ function install(opts: { const userId = options?.context?.userId as string | undefined; const ids: string[] = (options?.where?.id?.$in ?? []).map(String); calls.parentFinds.push({ object, ids, userId }); + calls.parentFindContexts.push(options?.context); const vis = opts.visible[object]?.[userId ?? ''] ?? []; return ids.filter((id) => vis.includes(id)).map((id) => ({ id })) as any; }, @@ -202,6 +207,38 @@ describe('installCommentReadVisibility', () => { expect(calls.parentFinds).toHaveLength(0); }); + // [#7141] The parent probe reads a DIFFERENT object than the one the security + // middleware resolved its depth for, so the caller's envelope crosses over + // but the operation-private keys must not: `__readScope` is `sys_comment`'s + // access DEPTH and `__expandRead` waives the object-level CRUD check. + it('probes the parent with the caller ENVELOPE, minus the operation-private keys', async () => { + const { mw, calls } = install(dataset); + await runRead(mw, { + context: { + userId: 'rep2', + principalKind: 'agent', + onBehalfOf: { userId: 'human_1', principalKind: 'human' }, + accessible_org_ids: ['org_1'], + __readScope: 'org', + __delegatorReadScope: 'org', + __expandRead: true, + } as any, + }); + const probed = calls.parentFindContexts; + expect(probed.length).toBeGreaterThan(0); + for (const c of probed) { + expect(c).toMatchObject({ + userId: 'rep2', + principalKind: 'agent', + onBehalfOf: { userId: 'human_1', principalKind: 'human' }, + accessible_org_ids: ['org_1'], + }); + for (const key of ['__readScope', '__delegatorReadScope', '__expandRead']) { + expect(c).not.toHaveProperty(key); + } + } + }); + it('does not touch write operations (the hooks gate those)', async () => { const { mw } = install(dataset); const { where } = await runRead(mw, { From 9e66d4a193d7135801d861cb814feeb999c23578 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 9 Aug 2026 21:53:03 +0000 Subject: [PATCH 2/2] test(plugin-audit): type the canEdit spy so tsc can read its call tuple (#7141) `vi.fn(async () => true)` gives the mock a zero-length parameter tuple, so `calls[0]![2]` is a TS2493 under `tsc --noEmit` even though vitest records the argument at runtime. Declare the three parameters the contract passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ --- .../plugins/plugin-audit/src/comment-access-hooks.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts b/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts index f1dc8d6720..0cd47d87c3 100644 --- a/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts +++ b/packages/plugins/plugin-audit/src/comment-access-hooks.test.ts @@ -396,11 +396,11 @@ describe('#7141 — caller envelope forwarded to the sharing gate', () => { const row = { id: 'c1', thread_id: 'crm_opportunity:opp1', author_id: 'someone_else', body: 'hi' }; it('forwards the whole envelope MINUS the operation-private keys', async () => { - const canEdit = vi.fn(async () => true); + const canEdit = vi.fn(async (_object: string, _recordId: string, _callerCtx: any) => true); const { beforeDelete } = install({ comments: [row], sharing: { canEdit } }); await beforeDelete(envelopeWriteCtx('beforeDelete', { id: 'c1' }, { ...DELEGATED_ENVELOPE })); - const forwarded = canEdit.mock.calls[0]![2] as Record; + const forwarded = canEdit.mock.calls[0]![2] as unknown as Record; // Every principal field survives — the #6523 contract's unit is the envelope // and #6206 forbids rebuilding a subset of it. expect(forwarded).toEqual({