feat(p9): CMP consent gate in the Capability Broker
Task S2.1: the broker now checks consent (cmp.checkPurpose) after the OPA gate + obligations and before the provider — a DENY blocks the send (BLOCKED/CONSENT_DENIED, provider never called); ALLOW/NOT_REQUIRED proceed, ALLOW carrying the consent receipt. Contracts gain CapabilityRequest.purpose + CapabilityResult.consentReceiptRef. FakeCmp is now purpose-aware (denyPurpose/allowOnly). 5 new tests: allow+receipt, deny→blocked, not-required, purpose-aware, OPA-before-CMP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,4 +52,47 @@ describe('CapabilityBroker (P9 — governed egress)', () => {
|
||||
it('unknown channel → fails closed (throws, no silent egress)', async () => {
|
||||
await expect(broker(makeFakePorts()).invoke(req({ channelType: 'CARRIER_PIGEON' }))).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ── CMP consent-at-egress (P9 slice 2) ──────────────────────────
|
||||
it('CMP allow → SENT and carries the consent receipt', async () => {
|
||||
const ports = makeFakePorts();
|
||||
ports.cmp.setStatus('ALLOW');
|
||||
const r = await broker(ports).invoke(req());
|
||||
expect(r.outcome).toBe('SENT');
|
||||
expect(r.consentReceiptRef).toBe('fake-receipt');
|
||||
});
|
||||
|
||||
it('CMP DENY → BLOCKED (CONSENT_DENIED), provider never called', async () => {
|
||||
const ports = makeFakePorts();
|
||||
ports.cmp.setStatus('DENY');
|
||||
const registry = new CapabilityProviderRegistry();
|
||||
let called = false;
|
||||
registry.register({ name: 'spy', channelTypes: ['WEBHOOK'], capabilities: { canSend: true }, async send() { called = true; return { providerRef: 'x', outcome: 'SENT' }; } });
|
||||
const r = await new CapabilityBroker(ports, registry).invoke(req());
|
||||
expect(r.outcome).toBe('BLOCKED');
|
||||
expect(r.errorCode).toBe('CONSENT_DENIED');
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('CMP NOT_REQUIRED → SENT (no receipt required)', async () => {
|
||||
const r = await broker(makeFakePorts()).invoke(req()); // default fake is NOT_REQUIRED
|
||||
expect(r.outcome).toBe('SENT');
|
||||
expect(r.consentReceiptRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it('purpose-aware: deny "marketing" but allow "support" for the same target', async () => {
|
||||
const ports = makeFakePorts();
|
||||
ports.cmp.denyPurpose('marketing');
|
||||
const blocked = await broker(ports).invoke(req({ purpose: 'marketing' }));
|
||||
const sent = await broker(ports).invoke(req({ purpose: 'support', idempotencyKey: 'k2' }));
|
||||
expect(blocked.outcome).toBe('BLOCKED');
|
||||
expect(sent.outcome).toBe('SENT');
|
||||
});
|
||||
|
||||
it('OPA is consulted before CMP (policy deny short-circuits consent)', async () => {
|
||||
const ports = makeFakePorts();
|
||||
ports.opa.deny('not allowed');
|
||||
ports.cmp.setStatus('ALLOW');
|
||||
await expect(broker(ports).invoke(req())).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user