From 92b72d0637381e9f463ac3672444cb3c33f7edbf Mon Sep 17 00:00:00 2001 From: Illia Pashkov Date: Wed, 1 Jul 2026 01:13:53 -0700 Subject: [PATCH 1/7] feat: bind regulated data policy to capability tokens --- .../regulated-agent-runtime-quickstart.md | 27 ++++ .../regulated-agent-runtime-profile-v1.md | 13 ++ .../__tests__/validator.test.ts | 48 +++++++ packages/capability-tokens/src/delegator.ts | 1 + packages/capability-tokens/src/issuer.ts | 2 + packages/core/src/constants/schema-catalog.ts | 1 + .../src/schemas/capability-token.schema.ts | 12 ++ packages/core/src/types/capability-token.ts | 22 +++ .../__tests__/regulated-data-policy.test.ts | 109 ++++++++++++++ .../src/regulated-data-policy.ts | 134 +++++++++++++++--- 10 files changed, 351 insertions(+), 18 deletions(-) diff --git a/docs/guides/regulated-agent-runtime-quickstart.md b/docs/guides/regulated-agent-runtime-quickstart.md index 3880392..caed6e7 100644 --- a/docs/guides/regulated-agent-runtime-quickstart.md +++ b/docs/guides/regulated-agent-runtime-quickstart.md @@ -57,6 +57,33 @@ const gateway = new PolicyGateway({ The plugin runs after token validation and before normal tier assignment. If it returns a deny or transform decision, the request stops there. +When a token carries `regulatedDataPolicy`, the plugin intersects those +token-bound allowlists with deployment defaults. The token can narrow processor, +model, region, purpose, data class, context-field, and fallback authority. It +cannot expand deployment policy. + +```typescript +const tokenRequest = { + issuer, + subject: agentId, + resource: "fhir://fhir.example.org/Observation/*", + actions: ["read"], + constraints: {}, + regulatedDataPolicy: { + allowedDataClasses: ["PHI"], + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedContextFields: ["resourceType", "interaction", "resourceId"], + allowFallback: true, + }, + delegationChain: { parentTokenId: null, depth: 0, attenuated: false }, + expiresAt, + revocable: true, +}; +``` + ## Build Regulated Metadata ```typescript diff --git a/docs/specs/regulated-agent-runtime-profile-v1.md b/docs/specs/regulated-agent-runtime-profile-v1.md index 56a15fb..58d6737 100644 --- a/docs/specs/regulated-agent-runtime-profile-v1.md +++ b/docs/specs/regulated-agent-runtime-profile-v1.md @@ -74,6 +74,19 @@ These checks are token-bound. Configuration may provide deployment defaults, but the effective authority must come from the signed capability token and any valid attenuated delegation chain. +The optional `regulatedDataPolicy` capability-token extension binds: + +- allowed data classes +- allowed purposes of use +- approved processors +- approved models +- approved regions +- allowed downstream context fields +- fallback-routing permission + +Deployment policy can be stricter than the token. It must not expand token +authority. + ## Context Attenuation Downstream agents inherit less authority than their parent, never more. If a diff --git a/packages/capability-tokens/__tests__/validator.test.ts b/packages/capability-tokens/__tests__/validator.test.ts index 0d2dffe..2426d57 100644 --- a/packages/capability-tokens/__tests__/validator.test.ts +++ b/packages/capability-tokens/__tests__/validator.test.ts @@ -276,6 +276,54 @@ describe("Capability Token Validator", () => { expect(result.ok).toBe(true); }); + it("should sign and validate token-bound regulated data policy", () => { + const token = createValidToken({ + regulatedDataPolicy: { + allowedDataClasses: ["PHI", "PII"], + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedContextFields: ["symptoms", "medications"], + allowFallback: true, + }, + }); + + expect(computeSigningPayload(token)).toContain("regulatedDataPolicy"); + const result = validateCapabilityToken(token, { + resource: "ros2:///cmd_vel", + action: "publish", + }); + expect(result.ok).toBe(true); + }); + + it("should reject tampered token-bound regulated data policy", () => { + const token = createValidToken({ + regulatedDataPolicy: { + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedPurposes: ["TREAT"], + }, + }); + const tampered: SintCapabilityToken = { + ...token, + regulatedDataPolicy: { + ...token.regulatedDataPolicy, + approvedProcessors: ["external-processor"], + }, + }; + + const result = validateCapabilityToken(tampered, { + resource: "ros2:///cmd_vel", + action: "publish", + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe("INVALID_SIGNATURE"); + }); + it("should reject stale verifiable compute metadata", () => { const token = createValidToken({ verifiableComputeRequirements: { diff --git a/packages/capability-tokens/src/delegator.ts b/packages/capability-tokens/src/delegator.ts index 88db208..48f1742 100644 --- a/packages/capability-tokens/src/delegator.ts +++ b/packages/capability-tokens/src/delegator.ts @@ -157,6 +157,7 @@ export function delegateCapabilityToken( actions: delegatedActions, constraints, modelConstraints: parentToken.modelConstraints, + regulatedDataPolicy: parentToken.regulatedDataPolicy, attestationRequirements: parentToken.attestationRequirements, verifiableComputeRequirements: parentToken.verifiableComputeRequirements, executionEnvelope: parentToken.executionEnvelope, diff --git a/packages/capability-tokens/src/issuer.ts b/packages/capability-tokens/src/issuer.ts index 1a04a30..2d54e06 100644 --- a/packages/capability-tokens/src/issuer.ts +++ b/packages/capability-tokens/src/issuer.ts @@ -56,6 +56,7 @@ export function computeSigningPayload( modelConstraints: token.modelConstraints, passportId: token.passportId, postQuantumSignatures: token.postQuantumSignatures, + regulatedDataPolicy: token.regulatedDataPolicy, resource: token.resource, revocable: token.revocable, revocationEndpoint: token.revocationEndpoint, @@ -130,6 +131,7 @@ export function issueCapabilityToken( actions: request.actions, constraints: request.constraints, modelConstraints: request.modelConstraints, + regulatedDataPolicy: request.regulatedDataPolicy, attestationRequirements: request.attestationRequirements, verifiableComputeRequirements: request.verifiableComputeRequirements, executionEnvelope: request.executionEnvelope, diff --git a/packages/core/src/constants/schema-catalog.ts b/packages/core/src/constants/schema-catalog.ts index a3c05e6..078d738 100644 --- a/packages/core/src/constants/schema-catalog.ts +++ b/packages/core/src/constants/schema-catalog.ts @@ -40,6 +40,7 @@ const CAPABILITY_TOKEN_SCHEMA: JsonSchemaDoc = { actions: { type: "array", items: { type: "string" } }, constraints: { type: "object" }, modelConstraints: { type: "object" }, + regulatedDataPolicy: { type: "object" }, attestationRequirements: { type: "object" }, verifiableComputeRequirements: { type: "object" }, executionEnvelope: { type: "object" }, diff --git a/packages/core/src/schemas/capability-token.schema.ts b/packages/core/src/schemas/capability-token.schema.ts index 187b0f3..5873500 100644 --- a/packages/core/src/schemas/capability-token.schema.ts +++ b/packages/core/src/schemas/capability-token.schema.ts @@ -103,6 +103,16 @@ export const modelConstraintsSchema = z.object({ modelFingerprintHash: sha256Schema.optional(), }).strict(); +export const regulatedDataPolicySchema = z.object({ + allowedDataClasses: z.array(z.string().min(1).max(64)).min(1).max(32).optional(), + allowedPurposes: z.array(z.string().min(1).max(128)).min(1).max(32).optional(), + approvedProcessors: z.array(z.string().min(1).max(128)).min(1).max(64).optional(), + approvedRegions: z.array(z.string().min(1).max(128)).min(1).max(64).optional(), + approvedModels: z.array(z.string().min(1).max(128)).min(1).max(64).optional(), + allowedContextFields: z.array(z.string().min(1).max(128)).min(1).max(128).optional(), + allowFallback: z.boolean().optional(), +}).strict(); + export const attestationRequirementsSchema = z.object({ minAttestationGrade: z.number().int().min(0).max(3).optional(), allowedTeeBackends: z.array( @@ -161,6 +171,7 @@ export const capabilityTokenSchema = z.object({ actions: z.array(z.string().min(1).max(64)).min(1).max(16), constraints: physicalConstraintsSchema, modelConstraints: modelConstraintsSchema.optional(), + regulatedDataPolicy: regulatedDataPolicySchema.optional(), attestationRequirements: attestationRequirementsSchema.optional(), verifiableComputeRequirements: verifiableComputeRequirementsSchema.optional(), executionEnvelope: executionEnvelopeSchema.optional(), @@ -190,6 +201,7 @@ export const capabilityTokenRequestSchema = z.object({ actions: z.array(z.string().min(1).max(64)).min(1).max(16), constraints: physicalConstraintsSchema, modelConstraints: modelConstraintsSchema.optional(), + regulatedDataPolicy: regulatedDataPolicySchema.optional(), attestationRequirements: attestationRequirementsSchema.optional(), verifiableComputeRequirements: verifiableComputeRequirementsSchema.optional(), executionEnvelope: executionEnvelopeSchema.optional(), diff --git a/packages/core/src/types/capability-token.ts b/packages/core/src/types/capability-token.ts index 1e6bcc3..459add6 100644 --- a/packages/core/src/types/capability-token.ts +++ b/packages/core/src/types/capability-token.ts @@ -164,6 +164,24 @@ export interface SintModelConstraints { readonly modelFingerprintHash?: string; } +/** Token-bound policy for regulated data runtime paths. */ +export interface SintRegulatedDataPolicy { + /** Data classes this token may carry or process. */ + readonly allowedDataClasses?: readonly string[]; + /** Purposes of use this token authorizes. */ + readonly allowedPurposes?: readonly string[]; + /** Approved processor identifiers for this token. */ + readonly approvedProcessors?: readonly string[]; + /** Approved region/residency identifiers for this token. */ + readonly approvedRegions?: readonly string[]; + /** Approved model identifiers for this token. */ + readonly approvedModels?: readonly string[]; + /** Context fields that downstream agents may receive. */ + readonly allowedContextFields?: readonly string[]; + /** Whether approved fallback routing is permitted for this token. */ + readonly allowFallback?: boolean; +} + /** Attestation backends supported by SINT enforcement and evidence flows. */ export type SintAttestationBackend = | "intel-sgx" @@ -347,6 +365,8 @@ export interface SintCapabilityToken { readonly constraints: SintPhysicalConstraints; /** Optional model identity restrictions for runtime use of this token. */ readonly modelConstraints?: SintModelConstraints; + /** Optional token-bound regulated data policy. */ + readonly regulatedDataPolicy?: SintRegulatedDataPolicy; /** Optional attestation requirements for this token. */ readonly attestationRequirements?: SintAttestationRequirements; /** Optional verifiable-compute proof requirements for this token. */ @@ -426,6 +446,8 @@ export interface SintCapabilityTokenRequest { readonly constraints: SintPhysicalConstraints; /** Optional model identity restrictions. */ readonly modelConstraints?: SintModelConstraints; + /** Optional token-bound regulated data policy. */ + readonly regulatedDataPolicy?: SintRegulatedDataPolicy; /** Optional TEE attestation requirements. */ readonly attestationRequirements?: SintAttestationRequirements; /** Optional ZK/TEE verifiable-compute proof requirements. */ diff --git a/packages/policy-gateway/__tests__/regulated-data-policy.test.ts b/packages/policy-gateway/__tests__/regulated-data-policy.test.ts index 342c448..a6b4e95 100644 --- a/packages/policy-gateway/__tests__/regulated-data-policy.test.ts +++ b/packages/policy-gateway/__tests__/regulated-data-policy.test.ts @@ -202,6 +202,115 @@ describe("DefaultRegulatedDataPolicyPlugin", () => { }), ); }); + + it("uses token-bound policy to deny data classes outside token authority", () => { + const token = makeToken({ + regulatedDataPolicy: { + allowedDataClasses: ["ADMIN"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedPurposes: ["TREAT"], + }, + }); + const policy = makePolicy(); + + const decision = policy.evaluate(makeRequest(token), token, { + requestId: "01905f7c-4e8a-7b3d-9a1e-f2c3d4e50100", + timestamp: new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + }); + + expect(decision?.action).toBe("deny"); + expect(decision?.denial?.policyViolated).toBe("DATA_CLASS_NOT_AUTHORIZED"); + }); + + it("intersects token-bound processor, region, model, and purpose allowlists", () => { + const token = makeToken({ + regulatedDataPolicy: { + allowedDataClasses: ["PHI", "PII"], + approvedProcessors: ["ehr-core"], + approvedRegions: ["us-west-2"], + approvedModels: ["intake-triage-approved"], + allowedPurposes: ["PAYMENT"], + }, + }); + const policy = makePolicy(); + + const decision = policy.evaluate(makeRequest(token), token, { + requestId: "01905f7c-4e8a-7b3d-9a1e-f2c3d4e50101", + timestamp: new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + }); + + expect(decision?.action).toBe("deny"); + expect(decision?.denial?.policyViolated).toBe("PURPOSE_NOT_AUTHORIZED"); + }); + + it("lets token-bound context fields minimize delegated context without deployment map", () => { + const token = makeToken({ + regulatedDataPolicy: { + allowedDataClasses: ["PHI", "PII"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedPurposes: ["TREAT"], + allowedContextFields: ["symptoms"], + }, + }); + const policy = makePolicy(); + + const decision = policy.evaluate(makeRequest(token), token, { + requestId: "01905f7c-4e8a-7b3d-9a1e-f2c3d4e50102", + timestamp: new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + }); + + expect(decision?.action).toBe("transform"); + expect(decision?.transformations?.additionalAuditFields).toEqual( + expect.objectContaining({ + transformations: ["minimize_context"], + effectiveContextFields: ["symptoms"], + minimizedFields: ["medications"], + }), + ); + }); + + it("denies unsafe original path when token disables fallback", () => { + const token = makeToken({ + regulatedDataPolicy: { + allowedDataClasses: ["PHI", "PII"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-west-2"], + approvedModels: ["intake-triage-approved"], + allowedPurposes: ["TREAT"], + allowFallback: false, + }, + }); + const policy = makePolicy(); + const request = makeRequest(token, { + params: { + regulatedData: { + dataClasses: ["PHI", "PII"], + purposeOfUse: "TREAT", + processor: "external-processor", + region: "eu-central-1", + model: "general-model", + fallback: { + processor: "in-region-model-router", + region: "us-west-2", + model: "intake-triage-approved", + transformations: ["redact_direct_identifiers"], + }, + }, + }, + }); + + const decision = policy.evaluate(request, token, { + requestId: request.requestId, + timestamp: request.timestamp, + }); + + expect(decision?.action).toBe("deny"); + expect(decision?.denial?.policyViolated).toBe("PROCESSOR_NOT_APPROVED"); + }); }); describe("RegulatedDataPolicyPlugin — Gateway integration", () => { diff --git a/packages/policy-gateway/src/regulated-data-policy.ts b/packages/policy-gateway/src/regulated-data-policy.ts index 5415548..fb228cb 100644 --- a/packages/policy-gateway/src/regulated-data-policy.ts +++ b/packages/policy-gateway/src/regulated-data-policy.ts @@ -77,10 +77,11 @@ export class DefaultRegulatedDataPolicyPlugin implements RegulatedDataPolicyPlug const assignedTier = assignRegulatedDataTier(metadata, request); const assignedRisk = riskForTier(assignedTier); + const policy = this.resolveEffectivePolicy(token); if ( - this.allowedPurposes && - !this.allowedPurposes.has(metadata.purposeOfUse) + policy.allowedPurposes && + !policy.allowedPurposes.has(metadata.purposeOfUse) ) { return deny( context, @@ -91,7 +92,17 @@ export class DefaultRegulatedDataPolicyPlugin implements RegulatedDataPolicyPlug ); } - if (this.fallbackAllowed(metadata)) { + if (!metadata.dataClasses.every((dataClass) => policy.allowedDataClasses?.has(dataClass) ?? true)) { + return deny( + context, + assignedTier, + assignedRisk, + "DATA_CLASS_NOT_AUTHORIZED", + "One or more regulated data classes are not authorized by this token", + ); + } + + if (this.fallbackAllowed(metadata, policy)) { return transform(context, assignedTier, assignedRisk, { transformations: metadata.fallback!.transformations, fallbackProcessor: metadata.fallback!.processor, @@ -101,7 +112,7 @@ export class DefaultRegulatedDataPolicyPlugin implements RegulatedDataPolicyPlug }); } - if (!this.approvedProcessors.has(metadata.processor)) { + if (!policy.approvedProcessors.has(metadata.processor)) { return deny( context, assignedTier, @@ -111,7 +122,7 @@ export class DefaultRegulatedDataPolicyPlugin implements RegulatedDataPolicyPlug ); } - if (!this.approvedRegions.has(metadata.region)) { + if (!policy.approvedRegions.has(metadata.region)) { return deny( context, assignedTier, @@ -121,7 +132,7 @@ export class DefaultRegulatedDataPolicyPlugin implements RegulatedDataPolicyPlug ); } - if (!this.approvedModels.has(metadata.model)) { + if (!policy.approvedModels.has(metadata.model)) { return deny( context, assignedTier, @@ -132,14 +143,19 @@ export class DefaultRegulatedDataPolicyPlugin implements RegulatedDataPolicyPlug } const requestedContextFields = metadata.requestedContextFields ?? []; - const allowedContextFields = this.config.allowedContextFieldsByTokenId?.[token.tokenId]; - if (allowedContextFields && exceedsContextScope(requestedContextFields, allowedContextFields)) { - const allowed = new Set(allowedContextFields); - const minimizedFields = requestedContextFields.filter((field) => !allowed.has(field)); + if ( + policy.allowedContextFields && + exceedsContextScope(requestedContextFields, policy.allowedContextFields) + ) { + const minimizedFields = requestedContextFields.filter( + (field) => !policy.allowedContextFields!.has(field), + ); return transform(context, assignedTier, assignedRisk, { transformations: ["minimize_context"], requestedContextFields, - effectiveContextFields: requestedContextFields.filter((field) => allowed.has(field)), + effectiveContextFields: requestedContextFields.filter((field) => + policy.allowedContextFields!.has(field), + ), minimizedFields, reason: "delegated_context_minimized", }); @@ -148,17 +164,63 @@ export class DefaultRegulatedDataPolicyPlugin implements RegulatedDataPolicyPlug return undefined; } - private fallbackAllowed(metadata: RegulatedDataRequestMetadata): boolean { + private fallbackAllowed( + metadata: RegulatedDataRequestMetadata, + policy: EffectiveRegulatedDataPolicy, + ): boolean { const fallback = metadata.fallback; if (!fallback) { return false; } + if (policy.allowFallback === false) { + return false; + } return ( - this.approvedProcessors.has(fallback.processor) && - this.approvedRegions.has(fallback.region) && - this.approvedModels.has(fallback.model) + policy.approvedProcessors.has(fallback.processor) && + policy.approvedRegions.has(fallback.region) && + policy.approvedModels.has(fallback.model) ); } + + private resolveEffectivePolicy(token: SintCapabilityToken): EffectiveRegulatedDataPolicy { + const tokenPolicy = token.regulatedDataPolicy; + return { + approvedProcessors: intersectIfPresent( + this.approvedProcessors, + tokenPolicy?.approvedProcessors, + ), + approvedRegions: intersectIfPresent( + this.approvedRegions, + tokenPolicy?.approvedRegions, + ), + approvedModels: intersectIfPresent( + this.approvedModels, + tokenPolicy?.approvedModels, + ), + allowedPurposes: intersectOptionalIfPresent( + this.allowedPurposes, + tokenPolicy?.allowedPurposes, + ), + allowedDataClasses: tokenPolicy?.allowedDataClasses + ? new Set(tokenPolicy.allowedDataClasses) + : undefined, + allowedContextFields: resolveContextFields( + this.config.allowedContextFieldsByTokenId?.[token.tokenId], + tokenPolicy?.allowedContextFields, + ), + allowFallback: tokenPolicy?.allowFallback, + }; + } +} + +interface EffectiveRegulatedDataPolicy { + readonly approvedProcessors: ReadonlySet; + readonly approvedRegions: ReadonlySet; + readonly approvedModels: ReadonlySet; + readonly allowedPurposes?: ReadonlySet; + readonly allowedDataClasses?: ReadonlySet; + readonly allowedContextFields?: ReadonlySet; + readonly allowFallback?: boolean; } function parseRegulatedDataMetadata(value: unknown): RegulatedDataRequestMetadata | undefined { @@ -268,10 +330,46 @@ function transform( function exceedsContextScope( requestedContextFields: readonly string[], - allowedContextFields: readonly string[], + allowedContextFields: ReadonlySet, ): boolean { - const allowed = new Set(allowedContextFields); - return requestedContextFields.some((field) => !allowed.has(field)); + return requestedContextFields.some((field) => !allowedContextFields.has(field)); +} + +function intersectIfPresent( + deploymentAllowed: ReadonlySet, + tokenAllowed: readonly string[] | undefined, +): ReadonlySet { + if (!tokenAllowed) { + return deploymentAllowed; + } + const tokenSet = new Set(tokenAllowed); + return new Set([...deploymentAllowed].filter((value) => tokenSet.has(value))); +} + +function intersectOptionalIfPresent( + deploymentAllowed: ReadonlySet | undefined, + tokenAllowed: readonly string[] | undefined, +): ReadonlySet | undefined { + if (!deploymentAllowed && !tokenAllowed) { + return undefined; + } + if (!deploymentAllowed) { + return new Set(tokenAllowed ?? []); + } + return intersectIfPresent(deploymentAllowed, tokenAllowed); +} + +function resolveContextFields( + deploymentFields: readonly string[] | undefined, + tokenFields: readonly string[] | undefined, +): ReadonlySet | undefined { + if (!deploymentFields && !tokenFields) { + return undefined; + } + if (!deploymentFields) { + return new Set(tokenFields ?? []); + } + return intersectIfPresent(new Set(deploymentFields), tokenFields); } function readString(value: unknown): string | undefined { From 8c6653841f1790b10b6f58a4378b46132a3443b9 Mon Sep 17 00:00:00 2001 From: Illia Pashkov Date: Wed, 1 Jul 2026 01:20:27 -0700 Subject: [PATCH 2/7] feat: attenuate regulated data policy on delegation --- .../__tests__/delegator.test.ts | 100 ++++++++++++++++++ packages/capability-tokens/src/delegator.ts | 96 ++++++++++++++++- 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/packages/capability-tokens/__tests__/delegator.test.ts b/packages/capability-tokens/__tests__/delegator.test.ts index 3fb02e3..bcf24ae 100644 --- a/packages/capability-tokens/__tests__/delegator.test.ts +++ b/packages/capability-tokens/__tests__/delegator.test.ts @@ -39,6 +39,34 @@ describe("Capability Token Delegator", () => { return result.value; } + function issueRegulatedRootToken( + regulatedDataPolicyOverrides: Partial> = {}, + ) { + const request: SintCapabilityTokenRequest = { + issuer: root.publicKey, + subject: agent1.publicKey, + resource: "health://intake/*", + actions: ["read", "summarize"], + constraints: {}, + regulatedDataPolicy: { + allowedDataClasses: ["PHI", "PII"], + allowedPurposes: ["TREAT", "PAYMENT"], + approvedProcessors: ["ehr-core", "in-region-model-router"], + approvedRegions: ["us-east-1", "us-west-2"], + approvedModels: ["clinical-summary-local", "billing-code-helper"], + allowedContextFields: ["symptoms", "medications", "insurance_plan"], + allowFallback: true, + ...regulatedDataPolicyOverrides, + }, + delegationChain: { parentTokenId: null, depth: 0, attenuated: false }, + expiresAt: futureISO(24), + revocable: true, + }; + const result = issueCapabilityToken(request, root.privateKey); + if (!result.ok) throw new Error("Failed to issue regulated root token"); + return result.value; + } + it("should delegate with attenuated constraints", () => { const rootToken = issueRootToken(); @@ -165,4 +193,76 @@ describe("Capability Token Delegator", () => { }); expect(valid.ok).toBe(false); }); + + it("should delegate with attenuated regulated data policy", () => { + const rootToken = issueRegulatedRootToken(); + + const delegated = delegateCapabilityToken( + rootToken, + { + newSubject: agent2.publicKey, + restrictActions: ["summarize"], + tightenRegulatedDataPolicy: { + allowedDataClasses: ["PHI"], + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedContextFields: ["symptoms"], + allowFallback: false, + }, + }, + agent1.privateKey, + ); + + expect(delegated.ok).toBe(true); + if (!delegated.ok) return; + expect(delegated.value.regulatedDataPolicy).toEqual({ + allowedDataClasses: ["PHI"], + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedContextFields: ["symptoms"], + allowFallback: false, + }); + }); + + it("should reject regulated data policy expansion during delegation", () => { + const rootToken = issueRegulatedRootToken(); + + const delegated = delegateCapabilityToken( + rootToken, + { + newSubject: agent2.publicKey, + tightenRegulatedDataPolicy: { + approvedProcessors: ["unapproved-processor"], + }, + }, + agent1.privateKey, + ); + + expect(delegated.ok).toBe(false); + if (delegated.ok) return; + expect(delegated.error).toBe("INSUFFICIENT_PERMISSIONS"); + }); + + it("should reject enabling fallback when parent disabled it", () => { + const rootToken = issueRegulatedRootToken({ allowFallback: false }); + + const delegated = delegateCapabilityToken( + rootToken, + { + newSubject: agent2.publicKey, + tightenRegulatedDataPolicy: { + allowFallback: true, + }, + }, + agent1.privateKey, + ); + + expect(delegated.ok).toBe(false); + if (delegated.ok) return; + expect(delegated.error).toBe("INSUFFICIENT_PERMISSIONS"); + }); }); diff --git a/packages/capability-tokens/src/delegator.ts b/packages/capability-tokens/src/delegator.ts index 48f1742..ad582f6 100644 --- a/packages/capability-tokens/src/delegator.ts +++ b/packages/capability-tokens/src/delegator.ts @@ -19,6 +19,7 @@ import { type Result, type SintCapabilityToken, type SintPhysicalConstraints, + type SintRegulatedDataPolicy, err, } from "@pshkv/core"; import { issueCapabilityToken } from "./issuer.js"; @@ -37,6 +38,9 @@ export interface DelegationParams { /** Optional: Tighten physical constraints (can only reduce, never increase). */ readonly tightenConstraints?: Partial; + /** Optional: Tighten regulated-data policy (subset only, never expand). */ + readonly tightenRegulatedDataPolicy?: Partial; + /** Optional: Override expiry (must be <= parent's expiry). */ readonly expiresAt?: ISO8601; } @@ -72,6 +76,89 @@ function minOptional(a?: number, b?: number): number | undefined { return Math.min(a, b); } +function attenuateRegulatedDataPolicy( + parent: SintRegulatedDataPolicy | undefined, + tighten: Partial | undefined, +): Result { + if (!tighten) { + return { ok: true, value: parent ? { ...parent } : undefined }; + } + if (!parent) { + return err("INSUFFICIENT_PERMISSIONS"); + } + + const allowedDataClasses = attenuateStringList( + parent.allowedDataClasses, + tighten.allowedDataClasses, + ); + const allowedPurposes = attenuateStringList( + parent.allowedPurposes, + tighten.allowedPurposes, + ); + const approvedProcessors = attenuateStringList( + parent.approvedProcessors, + tighten.approvedProcessors, + ); + const approvedRegions = attenuateStringList( + parent.approvedRegions, + tighten.approvedRegions, + ); + const approvedModels = attenuateStringList( + parent.approvedModels, + tighten.approvedModels, + ); + const allowedContextFields = attenuateStringList( + parent.allowedContextFields, + tighten.allowedContextFields, + ); + + if ( + !allowedDataClasses.ok || + !allowedPurposes.ok || + !approvedProcessors.ok || + !approvedRegions.ok || + !approvedModels.ok || + !allowedContextFields.ok + ) { + return err("INSUFFICIENT_PERMISSIONS"); + } + + if (parent.allowFallback === false && tighten.allowFallback === true) { + return err("INSUFFICIENT_PERMISSIONS"); + } + + return { + ok: true, + value: { + ...parent, + allowedDataClasses: allowedDataClasses.value, + allowedPurposes: allowedPurposes.value, + approvedProcessors: approvedProcessors.value, + approvedRegions: approvedRegions.value, + approvedModels: approvedModels.value, + allowedContextFields: allowedContextFields.value, + allowFallback: tighten.allowFallback ?? parent.allowFallback, + }, + }; +} + +function attenuateStringList( + parent: readonly string[] | undefined, + requested: readonly string[] | undefined, +): Result { + if (!requested) { + return { ok: true, value: parent }; + } + if (!parent) { + return err("INSUFFICIENT_PERMISSIONS"); + } + const parentSet = new Set(parent); + if (!requested.every((value) => parentSet.has(value))) { + return err("INSUFFICIENT_PERMISSIONS"); + } + return { ok: true, value: [...requested] }; +} + /** * Delegate a capability token — create a child token with attenuated permissions. * @@ -147,6 +234,13 @@ export function delegateCapabilityToken( parentToken.constraints, params.tightenConstraints, ); + const regulatedDataPolicy = attenuateRegulatedDataPolicy( + parentToken.regulatedDataPolicy, + params.tightenRegulatedDataPolicy, + ); + if (!regulatedDataPolicy.ok) { + return regulatedDataPolicy; + } // Issue the delegated token return issueCapabilityToken( @@ -157,7 +251,7 @@ export function delegateCapabilityToken( actions: delegatedActions, constraints, modelConstraints: parentToken.modelConstraints, - regulatedDataPolicy: parentToken.regulatedDataPolicy, + regulatedDataPolicy: regulatedDataPolicy.value, attestationRequirements: parentToken.attestationRequirements, verifiableComputeRequirements: parentToken.verifiableComputeRequirements, executionEnvelope: parentToken.executionEnvelope, From 09296c5ecdde92b76d8620fc90397829ca08d5ce Mon Sep 17 00:00:00 2001 From: Illia Pashkov Date: Thu, 2 Jul 2026 00:16:16 -0700 Subject: [PATCH 3/7] test: add token-bound regulated data conformance --- .../regulated-agent-runtime.v1.json | 97 +++++++++++++++++++ .../conformance-tests/src/fixture-loader.ts | 40 ++++++++ ...egulated-agent-runtime-conformance.test.ts | 81 ++++++++++++++++ 3 files changed, 218 insertions(+) diff --git a/packages/conformance-tests/fixtures/compliance/regulated-agent-runtime.v1.json b/packages/conformance-tests/fixtures/compliance/regulated-agent-runtime.v1.json index 8a585ff..b333844 100644 --- a/packages/conformance-tests/fixtures/compliance/regulated-agent-runtime.v1.json +++ b/packages/conformance-tests/fixtures/compliance/regulated-agent-runtime.v1.json @@ -513,9 +513,106 @@ } } ], + "tokenBoundAuthority": { + "extensionName": "regulatedDataPolicy", + "signedFields": [ + "allowedDataClasses", + "allowedPurposes", + "approvedProcessors", + "approvedRegions", + "approvedModels", + "allowedContextFields", + "allowFallback" + ], + "signaturePayloadIncludesExtension": true, + "deploymentPolicyCannotExpandToken": true, + "delegationAttenuationOnly": true, + "parentPolicy": { + "allowedDataClasses": [ + "PHI", + "PII" + ], + "allowedPurposes": [ + "TREAT", + "PAYMENT" + ], + "approvedProcessors": [ + "ehr-core", + "in-region-model-router", + "billing-service" + ], + "approvedRegions": [ + "us-east-1", + "us-west-2" + ], + "approvedModels": [ + "clinical-summary-local", + "intake-triage-approved", + "billing-code-helper" + ], + "allowedContextFields": [ + "symptoms", + "medications", + "insurance_plan" + ], + "allowFallback": true + }, + "validDelegation": { + "allowedDataClasses": [ + "PHI" + ], + "allowedPurposes": [ + "TREAT" + ], + "approvedProcessors": [ + "in-region-model-router" + ], + "approvedRegions": [ + "us-east-1" + ], + "approvedModels": [ + "clinical-summary-local" + ], + "allowedContextFields": [ + "symptoms" + ], + "allowFallback": false + }, + "invalidDelegations": [ + { + "name": "processor_expansion", + "requestedPolicy": { + "approvedProcessors": [ + "external-processor" + ] + }, + "expectedError": "INSUFFICIENT_PERMISSIONS" + }, + { + "name": "region_expansion", + "requestedPolicy": { + "approvedRegions": [ + "eu-central-1" + ] + }, + "expectedError": "INSUFFICIENT_PERMISSIONS" + }, + { + "name": "context_expansion", + "requestedPolicy": { + "allowedContextFields": [ + "raw_treatment_note" + ] + }, + "expectedError": "INSUFFICIENT_PERMISSIONS" + } + ] + }, "successCriteria": { "everyDecisionFlowsThroughPolicyGateway": true, "inheritedContextOnlyNarrows": true, + "tokenBoundPolicySigned": true, + "tokenBoundPolicyDelegatesByAttenuationOnly": true, "unapprovedProcessorsDenied": true, "crossRegionTransfersDeniedOrRerouted": true, "sensitivePayloadsMinimizedInEvidence": true, diff --git a/packages/conformance-tests/src/fixture-loader.ts b/packages/conformance-tests/src/fixture-loader.ts index 327d6a0..34fdf1d 100644 --- a/packages/conformance-tests/src/fixture-loader.ts +++ b/packages/conformance-tests/src/fixture-loader.ts @@ -2292,9 +2292,49 @@ export interface RegulatedAgentRuntimeFixture { readonly receiptFields: readonly string[]; }; }>; + readonly tokenBoundAuthority: { + readonly extensionName: "regulatedDataPolicy"; + readonly signedFields: readonly string[]; + readonly signaturePayloadIncludesExtension: boolean; + readonly deploymentPolicyCannotExpandToken: boolean; + readonly delegationAttenuationOnly: boolean; + readonly parentPolicy: { + readonly allowedDataClasses: readonly string[]; + readonly allowedPurposes: readonly string[]; + readonly approvedProcessors: readonly string[]; + readonly approvedRegions: readonly string[]; + readonly approvedModels: readonly string[]; + readonly allowedContextFields: readonly string[]; + readonly allowFallback: boolean; + }; + readonly validDelegation: { + readonly allowedDataClasses: readonly string[]; + readonly allowedPurposes: readonly string[]; + readonly approvedProcessors: readonly string[]; + readonly approvedRegions: readonly string[]; + readonly approvedModels: readonly string[]; + readonly allowedContextFields: readonly string[]; + readonly allowFallback: boolean; + }; + readonly invalidDelegations: readonly Array<{ + readonly name: string; + readonly requestedPolicy: { + readonly allowedDataClasses?: readonly string[]; + readonly allowedPurposes?: readonly string[]; + readonly approvedProcessors?: readonly string[]; + readonly approvedRegions?: readonly string[]; + readonly approvedModels?: readonly string[]; + readonly allowedContextFields?: readonly string[]; + readonly allowFallback?: boolean; + }; + readonly expectedError: "INSUFFICIENT_PERMISSIONS"; + }>; + }; readonly successCriteria: { readonly everyDecisionFlowsThroughPolicyGateway: boolean; readonly inheritedContextOnlyNarrows: boolean; + readonly tokenBoundPolicySigned: boolean; + readonly tokenBoundPolicyDelegatesByAttenuationOnly: boolean; readonly unapprovedProcessorsDenied: boolean; readonly crossRegionTransfersDeniedOrRerouted: boolean; readonly sensitivePayloadsMinimizedInEvidence: boolean; diff --git a/packages/conformance-tests/src/regulated-agent-runtime-conformance.test.ts b/packages/conformance-tests/src/regulated-agent-runtime-conformance.test.ts index 9ae3542..feff0eb 100644 --- a/packages/conformance-tests/src/regulated-agent-runtime-conformance.test.ts +++ b/packages/conformance-tests/src/regulated-agent-runtime-conformance.test.ts @@ -13,6 +13,7 @@ import { } from "./fixture-loader.js"; type RuntimeScenario = RegulatedAgentRuntimeFixture["scenarios"][number]; +type TokenBoundAuthority = RegulatedAgentRuntimeFixture["tokenBoundAuthority"]; interface RuntimeDecisionResult { readonly decisionAction: RuntimeScenario["expected"]["decisionAction"]; @@ -124,6 +125,47 @@ class RegulatedAgentRuntimeHarness { } } +function isSubset( + child: readonly string[] | undefined, + parent: readonly string[], +): boolean { + if (!child) { + return true; + } + const parentSet = new Set(parent); + return child.every((value) => parentSet.has(value)); +} + +function policyIsSubset( + child: TokenBoundAuthority["validDelegation"], + parent: TokenBoundAuthority["parentPolicy"], +): boolean { + return ( + isSubset(child.allowedDataClasses, parent.allowedDataClasses) && + isSubset(child.allowedPurposes, parent.allowedPurposes) && + isSubset(child.approvedProcessors, parent.approvedProcessors) && + isSubset(child.approvedRegions, parent.approvedRegions) && + isSubset(child.approvedModels, parent.approvedModels) && + isSubset(child.allowedContextFields, parent.allowedContextFields) && + !(parent.allowFallback === false && child.allowFallback === true) + ); +} + +function requestedPolicyExpandsParent( + requested: TokenBoundAuthority["invalidDelegations"][number]["requestedPolicy"], + parent: TokenBoundAuthority["parentPolicy"], +): boolean { + return ( + !isSubset(requested.allowedDataClasses, parent.allowedDataClasses) || + !isSubset(requested.allowedPurposes, parent.allowedPurposes) || + !isSubset(requested.approvedProcessors, parent.approvedProcessors) || + !isSubset(requested.approvedRegions, parent.approvedRegions) || + !isSubset(requested.approvedModels, parent.approvedModels) || + !isSubset(requested.allowedContextFields, parent.allowedContextFields) || + (parent.allowFallback === false && requested.allowFallback === true) + ); +} + describe("Regulated agent runtime profile fixture v1", () => { const fixture = loadRegulatedAgentRuntimeFixture(); const harness = new RegulatedAgentRuntimeHarness(fixture); @@ -240,6 +282,45 @@ describe("Regulated agent runtime profile fixture v1", () => { expect(fixture.successCriteria.inheritedContextOnlyNarrows).toBe(true); }); + it("binds regulated data authority to signed token policy", () => { + expect(fixture.tokenBoundAuthority.extensionName).toBe("regulatedDataPolicy"); + expect(fixture.tokenBoundAuthority.signaturePayloadIncludesExtension).toBe(true); + expect(fixture.successCriteria.tokenBoundPolicySigned).toBe(true); + expect(fixture.tokenBoundAuthority.signedFields).toEqual( + expect.arrayContaining([ + "allowedDataClasses", + "allowedPurposes", + "approvedProcessors", + "approvedRegions", + "approvedModels", + "allowedContextFields", + "allowFallback", + ]), + ); + }); + + it("requires regulated data delegation to attenuate token-bound authority", () => { + const authority = fixture.tokenBoundAuthority; + + expect(authority.deploymentPolicyCannotExpandToken).toBe(true); + expect(authority.delegationAttenuationOnly).toBe(true); + expect(fixture.successCriteria.tokenBoundPolicyDelegatesByAttenuationOnly).toBe(true); + expect(policyIsSubset(authority.validDelegation, authority.parentPolicy)).toBe(true); + + for (const invalidDelegation of authority.invalidDelegations) { + expect(invalidDelegation.expectedError, invalidDelegation.name).toBe( + "INSUFFICIENT_PERMISSIONS", + ); + expect( + requestedPolicyExpandsParent( + invalidDelegation.requestedPolicy, + authority.parentPolicy, + ), + invalidDelegation.name, + ).toBe(true); + } + }); + it("denies or reroutes unsafe processor, region, and model paths", () => { expect(fixture.successCriteria.unapprovedProcessorsDenied).toBe(true); expect(fixture.successCriteria.crossRegionTransfersDeniedOrRerouted).toBe(true); From ae27e8c3d8a18aca4ee76025dfe0a639d716271d Mon Sep 17 00:00:00 2001 From: Illia Pashkov Date: Thu, 2 Jul 2026 17:45:14 -0700 Subject: [PATCH 4/7] feat: build regulated data token policy from health mappings --- .../regulated-agent-runtime-quickstart.md | 13 ++ packages/bridge-health/README.md | 11 ++ .../regulated-runtime-metadata.test.ts | 113 ++++++++++++++++++ packages/bridge-health/src/index.ts | 2 + .../src/regulated-runtime-metadata.ts | 31 +++++ 5 files changed, 170 insertions(+) diff --git a/docs/guides/regulated-agent-runtime-quickstart.md b/docs/guides/regulated-agent-runtime-quickstart.md index caed6e7..c2a1a0b 100644 --- a/docs/guides/regulated-agent-runtime-quickstart.md +++ b/docs/guides/regulated-agent-runtime-quickstart.md @@ -88,6 +88,7 @@ const tokenRequest = { ```typescript import { + buildFHIRRegulatedDataPolicy, buildFHIRRegulatedRuntimeMetadata, mapFHIRToSint, withRegulatedRuntimeParams, @@ -101,6 +102,15 @@ const mapping = mapFHIRToSint({ patientId: "patient-456", }); +const regulatedDataPolicy = buildFHIRRegulatedDataPolicy(mapping, { + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedContextFields: ["resourceType", "interaction", "resourceId"], + allowFallback: true, +}); + const regulatedData = buildFHIRRegulatedRuntimeMetadata(mapping, { purposeOfUse: "TREAT", processor: "in-region-model-router", @@ -115,6 +125,9 @@ const params = withRegulatedRuntimeParams( ); ``` +Use `regulatedDataPolicy` as `SintCapabilityTokenRequest.regulatedDataPolicy` +when issuing the token for this workflow. + The resulting request params include: ```json diff --git a/packages/bridge-health/README.md b/packages/bridge-health/README.md index 3db4991..728afd0 100644 --- a/packages/bridge-health/README.md +++ b/packages/bridge-health/README.md @@ -257,6 +257,7 @@ purpose, fallback, and context-minimization rules inside `PolicyGateway.intercep ```typescript import { + buildFHIRRegulatedDataPolicy, buildFHIRRegulatedRuntimeMetadata, mapFHIRToSint, withRegulatedRuntimeParams, @@ -270,6 +271,15 @@ const mapping = mapFHIRToSint({ patientId: "patient-456", }); +const regulatedDataPolicy = buildFHIRRegulatedDataPolicy(mapping, { + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedContextFields: ["resourceType", "interaction", "resourceId"], + allowFallback: true, +}); + const regulatedData = buildFHIRRegulatedRuntimeMetadata(mapping, { purposeOfUse: "TREAT", processor: "in-region-model-router", @@ -285,6 +295,7 @@ const params = withRegulatedRuntimeParams( // The bridge forwards `resource`, `action`, and `params` to PolicyGateway.intercept(). // It does not authorize the request itself. +// Use `regulatedDataPolicy` as SintCapabilityTokenRequest.regulatedDataPolicy. ``` ### HealthKit On-Device Access diff --git a/packages/bridge-health/__tests__/regulated-runtime-metadata.test.ts b/packages/bridge-health/__tests__/regulated-runtime-metadata.test.ts index 5c15ecb..1ce2d41 100644 --- a/packages/bridge-health/__tests__/regulated-runtime-metadata.test.ts +++ b/packages/bridge-health/__tests__/regulated-runtime-metadata.test.ts @@ -6,6 +6,11 @@ import { describe, expect, it } from "vitest"; import { ApprovalTier } from "@pshkv/core"; import { DefaultRegulatedDataPolicyPlugin } from "@pshkv/gate-policy-gateway"; import { + generateKeypair, + issueCapabilityToken, +} from "@pshkv/gate-capability-tokens"; +import { + buildFHIRRegulatedDataPolicy, buildFHIRRegulatedRuntimeMetadata, classifyFHIRDataClasses, withRegulatedRuntimeParams, @@ -75,6 +80,39 @@ describe("regulated runtime metadata", () => { expect(params.regulatedData).toBe(metadata); }); + it("builds token-bound regulated data policy from the same FHIR mapping", () => { + const mapping = mapFHIRToSint({ + serverUrl: "https://fhir.example.test", + resourceType: "Observation", + resourceId: "obs-123", + interaction: "read", + patientId: "patient-456", + }); + + const tokenPolicy = buildFHIRRegulatedDataPolicy(mapping, { + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowFallback: false, + }); + + expect(tokenPolicy).toEqual({ + allowedDataClasses: ["PHI"], + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedContextFields: [ + "resourceType", + "interaction", + "resourceId", + "patientId", + ], + allowFallback: false, + }); + }); + it("emits metadata compatible with the gateway regulated-data policy", () => { const mapping = mapFHIRToSint({ serverUrl: "https://fhir.example.test", @@ -141,4 +179,79 @@ describe("regulated runtime metadata", () => { }), ); }); + + it("uses generated token policy to bind issued token authority", () => { + const issuer = generateKeypair(); + const subject = generateKeypair(); + const mapping = mapFHIRToSint({ + serverUrl: "https://fhir.example.test", + resourceType: "Observation", + resourceId: "obs-123", + interaction: "read", + patientId: "patient-456", + }); + const tokenPolicy = buildFHIRRegulatedDataPolicy(mapping, { + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedContextFields: ["resourceType", "interaction"], + allowFallback: false, + }); + const issued = issueCapabilityToken( + { + issuer: issuer.publicKey, + subject: subject.publicKey, + resource: mapping.resource, + actions: [mapping.action], + constraints: {}, + regulatedDataPolicy: tokenPolicy, + delegationChain: { parentTokenId: null, depth: 0, attenuated: false }, + expiresAt: new Date(Date.now() + 60_000).toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + revocable: true, + }, + issuer.privateKey, + ); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + + const metadata = buildFHIRRegulatedRuntimeMetadata(mapping, { + purposeOfUse: "TREAT", + processor: "in-region-model-router", + region: "us-east-1", + model: "clinical-summary-local", + requestedContextFields: ["resourceType", "interaction", "patientId"], + }); + const policy = new DefaultRegulatedDataPolicyPlugin({ + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedPurposes: ["TREAT"], + }); + const decision = policy.evaluate( + { + requestId: "01905f7c-4e8a-7b3d-9a1e-f2c3d4e50003" as any, + timestamp: new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + agentId: subject.publicKey, + tokenId: issued.value.tokenId, + resource: mapping.resource, + action: mapping.action, + params: withRegulatedRuntimeParams({}, metadata), + }, + issued.value, + { + requestId: "01905f7c-4e8a-7b3d-9a1e-f2c3d4e50003", + timestamp: new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + }, + ); + + expect(decision?.action).toBe("transform"); + expect(decision?.transformations?.additionalAuditFields).toEqual( + expect.objectContaining({ + transformations: ["minimize_context"], + effectiveContextFields: ["resourceType", "interaction"], + minimizedFields: ["patientId"], + }), + ); + }); }); diff --git a/packages/bridge-health/src/index.ts b/packages/bridge-health/src/index.ts index ad5600ca..3dc9c88 100644 --- a/packages/bridge-health/src/index.ts +++ b/packages/bridge-health/src/index.ts @@ -76,9 +76,11 @@ export { // Regulated runtime metadata for PolicyGateway regulated-data policy export { + buildFHIRRegulatedDataPolicy, buildFHIRRegulatedRuntimeMetadata, classifyFHIRDataClasses, withRegulatedRuntimeParams, + type FHIRRegulatedDataPolicyOptions, type RegulatedRuntimeRouteContext, type RegulatedRuntimeParams, } from "./regulated-runtime-metadata.js"; diff --git a/packages/bridge-health/src/regulated-runtime-metadata.ts b/packages/bridge-health/src/regulated-runtime-metadata.ts index 9f6f088..fb7c24f 100644 --- a/packages/bridge-health/src/regulated-runtime-metadata.ts +++ b/packages/bridge-health/src/regulated-runtime-metadata.ts @@ -6,6 +6,7 @@ */ import type { RegulatedDataRequestMetadata } from "@pshkv/gate-policy-gateway"; +import type { SintRegulatedDataPolicy } from "@pshkv/core"; import type { FHIRResourceMapping, FHIRResourceType } from "./fhir-mapper.js"; export interface RegulatedRuntimeRouteContext { @@ -21,6 +22,15 @@ export interface RegulatedRuntimeParams { readonly regulatedData: RegulatedDataRequestMetadata; } +export interface FHIRRegulatedDataPolicyOptions { + readonly allowedPurposes: readonly string[]; + readonly approvedProcessors: readonly string[]; + readonly approvedRegions: readonly string[]; + readonly approvedModels: readonly string[]; + readonly allowedContextFields?: readonly string[]; + readonly allowFallback?: boolean; +} + const CLINICAL_PHI_RESOURCES = new Set([ "Observation", "Condition", @@ -54,6 +64,27 @@ export function buildFHIRRegulatedRuntimeMetadata( }; } +/** + * Build a token-bound regulated data policy from a FHIR mapping. + * + * Use this as `SintCapabilityTokenRequest.regulatedDataPolicy` so token + * authority and request metadata are derived from the same bridge mapping. + */ +export function buildFHIRRegulatedDataPolicy( + mapping: FHIRResourceMapping, + options: FHIRRegulatedDataPolicyOptions, +): SintRegulatedDataPolicy { + return { + allowedDataClasses: classifyFHIRDataClasses(mapping.context.resourceType), + allowedPurposes: options.allowedPurposes, + approvedProcessors: options.approvedProcessors, + approvedRegions: options.approvedRegions, + approvedModels: options.approvedModels, + allowedContextFields: options.allowedContextFields ?? defaultContextFields(mapping), + allowFallback: options.allowFallback, + }; +} + /** * Attach regulated metadata under `params.regulatedData` while preserving any * bridge-specific params already present. From 565dea09b5c71bd942a4a9a7c4b07d3954b78965 Mon Sep 17 00:00:00 2001 From: Illia Pashkov Date: Thu, 2 Jul 2026 17:47:39 -0700 Subject: [PATCH 5/7] test: cover delegated bridge-derived data policy --- .../regulated-agent-runtime-quickstart.md | 21 ++++ .../regulated-runtime-metadata.test.ts | 99 +++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/docs/guides/regulated-agent-runtime-quickstart.md b/docs/guides/regulated-agent-runtime-quickstart.md index c2a1a0b..6a722c4 100644 --- a/docs/guides/regulated-agent-runtime-quickstart.md +++ b/docs/guides/regulated-agent-runtime-quickstart.md @@ -128,6 +128,27 @@ const params = withRegulatedRuntimeParams( Use `regulatedDataPolicy` as `SintCapabilityTokenRequest.regulatedDataPolicy` when issuing the token for this workflow. +Delegation can only narrow this policy. For example, a parent token can remove +`patientId` from the child token's context authority: + +```typescript +import { delegateCapabilityToken } from "@pshkv/gate-capability-tokens"; + +const delegated = delegateCapabilityToken( + parentToken, + { + newSubject: childAgentId, + tightenRegulatedDataPolicy: { + allowedContextFields: ["resourceType", "interaction", "resourceId"], + }, + }, + parentAgentPrivateKey, +); +``` + +If the child request asks for fields outside that narrowed list, the gateway +returns a `transform` decision with `minimize_context` audit metadata. + The resulting request params include: ```json diff --git a/packages/bridge-health/__tests__/regulated-runtime-metadata.test.ts b/packages/bridge-health/__tests__/regulated-runtime-metadata.test.ts index 1ce2d41..35f3e65 100644 --- a/packages/bridge-health/__tests__/regulated-runtime-metadata.test.ts +++ b/packages/bridge-health/__tests__/regulated-runtime-metadata.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { ApprovalTier } from "@pshkv/core"; import { DefaultRegulatedDataPolicyPlugin } from "@pshkv/gate-policy-gateway"; import { + delegateCapabilityToken, generateKeypair, issueCapabilityToken, } from "@pshkv/gate-capability-tokens"; @@ -254,4 +255,102 @@ describe("regulated runtime metadata", () => { }), ); }); + + it("delegates generated token policy by narrowing bridge-derived context fields", () => { + const issuer = generateKeypair(); + const parentSubject = generateKeypair(); + const childSubject = generateKeypair(); + const mapping = mapFHIRToSint({ + serverUrl: "https://fhir.example.test", + resourceType: "Observation", + resourceId: "obs-123", + interaction: "read", + patientId: "patient-456", + }); + const parentPolicy = buildFHIRRegulatedDataPolicy(mapping, { + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedContextFields: [ + "resourceType", + "interaction", + "resourceId", + "patientId", + ], + allowFallback: false, + }); + const issued = issueCapabilityToken( + { + issuer: issuer.publicKey, + subject: parentSubject.publicKey, + resource: mapping.resource, + actions: [mapping.action], + constraints: {}, + regulatedDataPolicy: parentPolicy, + delegationChain: { parentTokenId: null, depth: 0, attenuated: false }, + expiresAt: new Date(Date.now() + 60_000).toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + revocable: true, + }, + issuer.privateKey, + ); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + + const delegated = delegateCapabilityToken( + issued.value, + { + newSubject: childSubject.publicKey, + tightenRegulatedDataPolicy: { + allowedContextFields: ["resourceType", "interaction"], + }, + }, + parentSubject.privateKey, + ); + expect(delegated.ok).toBe(true); + if (!delegated.ok) return; + expect(delegated.value.regulatedDataPolicy?.allowedContextFields).toEqual([ + "resourceType", + "interaction", + ]); + + const metadata = buildFHIRRegulatedRuntimeMetadata(mapping, { + purposeOfUse: "TREAT", + processor: "in-region-model-router", + region: "us-east-1", + model: "clinical-summary-local", + requestedContextFields: ["resourceType", "interaction", "patientId"], + }); + const policy = new DefaultRegulatedDataPolicyPlugin({ + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedPurposes: ["TREAT"], + }); + const decision = policy.evaluate( + { + requestId: "01905f7c-4e8a-7b3d-9a1e-f2c3d4e50004" as any, + timestamp: new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + agentId: childSubject.publicKey, + tokenId: delegated.value.tokenId, + resource: mapping.resource, + action: mapping.action, + params: withRegulatedRuntimeParams({}, metadata), + }, + delegated.value, + { + requestId: "01905f7c-4e8a-7b3d-9a1e-f2c3d4e50004", + timestamp: new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000Z"), + }, + ); + + expect(decision?.action).toBe("transform"); + expect(decision?.transformations?.additionalAuditFields).toEqual( + expect.objectContaining({ + transformations: ["minimize_context"], + effectiveContextFields: ["resourceType", "interaction"], + minimizedFields: ["patientId"], + }), + ); + }); }); From ca138bc282ad9f3f48fa2013f18f9da569cd5c03 Mon Sep 17 00:00:00 2001 From: Illia Pashkov Date: Thu, 2 Jul 2026 17:48:38 -0700 Subject: [PATCH 6/7] docs: show delegated regulated runtime example --- examples/regulated-agent-runtime/README.md | 62 +++++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/examples/regulated-agent-runtime/README.md b/examples/regulated-agent-runtime/README.md index 79c2e23..0a645b1 100644 --- a/examples/regulated-agent-runtime/README.md +++ b/examples/regulated-agent-runtime/README.md @@ -10,10 +10,15 @@ This example shows the intended integration shape for regulated-data workflows: ```typescript import { + buildFHIRRegulatedDataPolicy, buildFHIRRegulatedRuntimeMetadata, mapFHIRToSint, withRegulatedRuntimeParams, } from "@pshkv/bridge-health"; +import { + delegateCapabilityToken, + issueCapabilityToken, +} from "@pshkv/gate-capability-tokens"; import { DefaultRegulatedDataPolicyPlugin, PolicyGateway, @@ -27,11 +32,64 @@ const mapping = mapFHIRToSint({ patientId: "patient-456", }); +const regulatedDataPolicy = buildFHIRRegulatedDataPolicy(mapping, { + allowedPurposes: ["TREAT"], + approvedProcessors: ["in-region-model-router"], + approvedRegions: ["us-east-1"], + approvedModels: ["clinical-summary-local"], + allowedContextFields: [ + "resourceType", + "interaction", + "resourceId", + "patientId", + ], + allowFallback: false, +}); + +const parentToken = issueCapabilityToken( + { + issuer: issuerPublicKey, + subject: parentAgentPublicKey, + resource: mapping.resource, + actions: [mapping.action], + constraints: {}, + regulatedDataPolicy, + delegationChain: { parentTokenId: null, depth: 0, attenuated: false }, + expiresAt, + revocable: true, + }, + issuerPrivateKey, +); + +const delegatedToken = parentToken.ok + ? delegateCapabilityToken( + parentToken.value, + { + newSubject: childAgentPublicKey, + tightenRegulatedDataPolicy: { + allowedContextFields: ["resourceType", "interaction", "resourceId"], + }, + }, + parentAgentPrivateKey, + ) + : parentToken; + +if (parentToken.ok) tokenStore.set(parentToken.value.tokenId, parentToken.value); +if (delegatedToken.ok) { + tokenStore.set(delegatedToken.value.tokenId, delegatedToken.value); +} + const regulatedData = buildFHIRRegulatedRuntimeMetadata(mapping, { purposeOfUse: "TREAT", processor: "in-region-model-router", region: "us-east-1", model: "clinical-summary-local", + requestedContextFields: [ + "resourceType", + "interaction", + "resourceId", + "patientId", + ], }); const gateway = new PolicyGateway({ @@ -47,8 +105,8 @@ const gateway = new PolicyGateway({ const decision = await gateway.intercept({ requestId, timestamp, - agentId, - tokenId, + agentId: childAgentPublicKey, + tokenId: delegatedToken.ok ? delegatedToken.value.tokenId : tokenId, resource: mapping.resource, action: mapping.action, params: withRegulatedRuntimeParams({ fhir: mapping.context }, regulatedData), From 5d3834267e6ea192be908fe628ed7c9d99877c06 Mon Sep 17 00:00:00 2001 From: Illia Pashkov Date: Thu, 2 Jul 2026 22:31:19 -0700 Subject: [PATCH 7/7] docs: align bridge health regulated delegation --- packages/bridge-health/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/bridge-health/README.md b/packages/bridge-health/README.md index 728afd0..36ad71e 100644 --- a/packages/bridge-health/README.md +++ b/packages/bridge-health/README.md @@ -262,6 +262,7 @@ import { mapFHIRToSint, withRegulatedRuntimeParams, } from "@pshkv/bridge-health"; +import { delegateCapabilityToken } from "@pshkv/gate-capability-tokens"; const mapping = mapFHIRToSint({ serverUrl: "https://fhir.example.org", @@ -296,6 +297,17 @@ const params = withRegulatedRuntimeParams( // The bridge forwards `resource`, `action`, and `params` to PolicyGateway.intercept(). // It does not authorize the request itself. // Use `regulatedDataPolicy` as SintCapabilityTokenRequest.regulatedDataPolicy. + +const delegatedToken = delegateCapabilityToken( + parentToken, + { + newSubject: childAgentPublicKey, + tightenRegulatedDataPolicy: { + allowedContextFields: ["resourceType", "interaction"], + }, + }, + parentAgentPrivateKey, +); ``` ### HealthKit On-Device Access