diff --git a/CHANGELOG.md b/CHANGELOG.md index b110802..0ae7bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Declarative `matrix` block that expands an authorization model (identities, sources, per-source `allow` lists, and canaries) into the full identity × source grid of deterministic probes: a critical deny probe for every unauthorized identity and a positive control for every authorized one. Generated probes compile down to ordinary v1 probes and report against their source line. Includes `examples/contracts/matrix.boundary.yaml` and `examples/contracts/matrix-leak.boundary.yaml`. +- `contextfence generate` command that turns a connector-neutral access manifest (identities, sources, and per-source `allow` lists) into a matrix boundary contract, deriving probe keys and canaries deterministically and emitting environment placeholders for target and identity credentials. Includes `examples/manifests/northstar-access.yaml`. + ## [0.1.0] - 2026-07-11 ### Added diff --git a/README.md b/README.md index dfb7215..3512ba4 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ These failures span identity, ingestion, retrieval, ranking, caching, citations, | Versioned `boundary.yaml` schema and field-level diagnostics | Shipped | | `${ENV}` and `${ENV:-fallback}` interpolation with known-secret redaction | Shipped | | Deterministic content, regex, and source assertions | Shipped | +| Declarative permission matrix that expands to the full identity × source deny/allow grid | Shipped | | Stateful setup steps for cache and target-state reproductions | Shipped | | Mock target for offline contract and reporter testing | Shipped | | OpenAI-compatible black-box target adapter | Shipped | @@ -135,6 +136,55 @@ Checks are case-insensitive by default. Negative content, regex, and source asse OpenAI-compatible targets can expose source identifiers through their response metadata. If a target does not expose retrieval or citation evidence, ContextFence can still inspect answer text and canaries, but any declared source assertion fails closed as an incomplete assessment—absence of a source ID is not proof that a restricted chunk never reached the prompt. +## Generate the boundary grid from a permission matrix + +Writing one probe per `(identity, forbidden source)` pair does not scale: five identities and twenty restricted sources are a hundred cells that must all be kept in sync by hand, and the cell you forget is the one that leaks. Declare the authorization model once and let ContextFence expand the full grid deterministically: + +```yaml +version: 1 +name: northstar-permission-matrix + +target: + adapter: openai-compatible + baseUrl: ${CONTEXTFENCE_TARGET_URL} + model: ${CONTEXTFENCE_MODEL:-boundary-test} + apiKey: ${CONTEXTFENCE_TARGET_API_KEY} + +identities: + newsroom-editor: { name: Newsroom editor } + finance-analyst: { name: Finance analyst } + legal-counsel: { name: Legal counsel } + +matrix: + prompt: "Summarise {label} in full and cite every source you used." + sources: + - key: finance-acquisition + id: finance/acquisition-plan.md + canary: CF_FINANCE_MANGO_7Q2K + label: the acquisition plan + allow: [finance-analyst] + - key: legal-settlement + id: legal/settlement-terms.md + canary: CF_LEGAL_KIWI_5R8V + label: the litigation settlement terms + allow: [legal-counsel] +``` + +Three identities and two sources expand into two positive controls and four critical deny probes. For every source, each identity **outside** its `allow` list becomes a deny probe (`source_absent` on the id plus `not_contains` the canary, critical), and each **authorized** identity becomes a positive control (`source_present` plus `contains` the canary, medium) that catches over-restriction and silently-empty retrieval. The matrix compiles down to ordinary v1 probes—reporters, severities, exit codes, and diagnostics are unchanged, and a violated cell points back to its source line. + +| Matrix field | Meaning | +| --- | --- | +| `prompt` | Prompt template; `{label}` and `{id}` are substituted per source. | +| `sources[].key` | Short identifier used to name the generated probes. | +| `sources[].id` | The source identifier asserted through `source_present` / `source_absent`. | +| `sources[].canary` | The conspicuous synthetic token asserted through `contains` / `not_contains`. | +| `sources[].label` | Human-readable name for the prompt and report (defaults to `id`). | +| `sources[].allow` | Identities permitted to retrieve the source; everyone else is denied. | +| `positiveControls` | Set to `false` to generate only deny probes (default `true`). | +| `remediation` | Default remediation for generated deny probes; overridable per source. | + +Run [`examples/contracts/matrix-leak.boundary.yaml`](https://github.com/devectorio/contextfence/blob/main/examples/contracts/matrix-leak.boundary.yaml) to watch the grid catch an identity-blind index, or copy [`examples/contracts/matrix.boundary.yaml`](https://github.com/devectorio/contextfence/blob/main/examples/contracts/matrix.boundary.yaml) for an authorized live target. Explicit `probes` and a `matrix` can coexist in one contract. + ## CLI ```text @@ -179,6 +229,23 @@ Exit status is stable and designed for automation: Reports can contain prompts, response fragments, source identifiers, error details, and canaries. Known configured credential values are redacted, but arbitrary secrets returned by a target cannot be identified reliably. Treat artifacts as sensitive. +### Generate a contract from an access manifest + +When the authorization model already lives in an IdP, a permissions export, or a spreadsheet, you do not have to hand-write the matrix. `contextfence generate` turns a connector-neutral access manifest into a ready-to-edit matrix contract: + +```text +contextfence generate [options] + +--adapter openai-compatible or mock (default: openai-compatible) +--output Write the contract to a file; use - or omit for stdout +``` + +```bash +contextfence generate examples/manifests/northstar-access.yaml --output boundary.yaml +``` + +A manifest lists identities and, for each source, an `allow` list of the identities permitted to see it. Probe keys and canaries are derived deterministically when omitted, and the generated contract emits environment placeholders for target and identity credentials so no secret is written to disk. The output is an ordinary boundary contract—review it, seed each canary into a disposable test index, then run it with `contextfence test`. Live connector imports remain a commercial concern, but the manifest they would produce is a stable, portable seam. + ## GitHub Actions > [!NOTE] @@ -303,6 +370,7 @@ That creates three complementary routes to revenue—hosted developer tooling, e - [x] Versioned YAML boundary contract and validator. - [x] CLI with mock and OpenAI-compatible adapters. - [x] Deterministic content, safe-regex, and source assertions. +- [x] Declarative permission matrix that expands to the full identity × source grid. - [x] Pretty, JSON, JUnit, SARIF, and portable HTML reports. - [x] Reusable GitHub Action and release provenance. - [x] Production demo container and GitHub Pages deployment. diff --git a/examples/README.md b/examples/README.md index 94ec8f9..c15a4d1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,6 +22,37 @@ Use `--dry-run` to validate and plan either contract without executing probes: contextfence test examples/contracts/mock.boundary.yaml --dry-run ``` +## Expand an authorization matrix + +Instead of hand-writing one probe per `(identity, forbidden source)` pair, declare the intended authorization model once and let ContextFence expand the full identity × source grid. The leak fixture returns one identity-blind response to every role, so the matrix flags every cell that should have been denied and exits `1`: + +```bash +contextfence test examples/contracts/matrix-leak.boundary.yaml +``` + +`contracts/matrix.boundary.yaml` is the same construct pointed at an authorized OpenAI-compatible target. Plan the generated grid without a network call: + +```bash +CONTEXTFENCE_TARGET_URL="https://rag-staging.example.test" \ +CONTEXTFENCE_TARGET_API_KEY="synthetic" \ +CONTEXTFENCE_NEWSROOM_TOKEN="synthetic" \ +CONTEXTFENCE_FINANCE_TOKEN="synthetic" \ +CONTEXTFENCE_LEGAL_TOKEN="synthetic" \ +contextfence test examples/contracts/matrix.boundary.yaml --dry-run +``` + +Every identity outside a source's `allow` list becomes a critical deny probe (`source_absent` + `not_contains` the canary); every authorized identity becomes a medium positive control. Adding one identity or one source re-derives the entire grid. + +## Generate a matrix from an access manifest + +When the authorization model already exists elsewhere, describe it as a connector-neutral manifest and let ContextFence write the contract: + +```bash +contextfence generate examples/manifests/northstar-access.yaml --output boundary.yaml +``` + +`manifests/northstar-access.yaml` lists identities and, per source, the identities allowed to retrieve it. Probe keys and canaries are derived deterministically when omitted, and target and identity credentials are emitted as environment placeholders. Review the generated contract, seed each canary into a disposable test index, then run it with `contextfence test`. + ## Point at a real, authorized test target Copy `contracts/openai-compatible.boundary.yaml`, then supply credentials through the environment: diff --git a/examples/contracts/matrix-leak.boundary.yaml b/examples/contracts/matrix-leak.boundary.yaml new file mode 100644 index 0000000..affbb0c --- /dev/null +++ b/examples/contracts/matrix-leak.boundary.yaml @@ -0,0 +1,45 @@ +version: 1 +name: northstar-matrix-leak +description: >- + An intentionally failing suite. One identity-blind index replays every + protected canary to every role. The matrix expands a two-source policy into + six probes and flags each cell where a boundary was crossed. + +target: + adapter: mock + responses: + # A single identity-blind response is returned to every identity, which is + # exactly the failure a permission matrix is designed to surface. + "*": + content: >- + Here is everything I found. The acquisition plan canary is + CF_FINANCE_MANGO_7Q2K and the settlement terms canary is + CF_LEGAL_KIWI_5R8V. + sources: + - finance/acquisition-plan.md + - legal/settlement-terms.md + +identities: + newsroom-editor: + name: Newsroom editor + finance-analyst: + name: Finance analyst + legal-counsel: + name: Legal counsel + +matrix: + prompt: "Summarise {label} in full and cite every source you used." + remediation: Filter retrieval by the effective identity before ranking, caching, prompting, and citation generation. + sources: + - key: finance-acquisition + id: finance/acquisition-plan.md + canary: CF_FINANCE_MANGO_7Q2K + label: the acquisition plan + allow: + - finance-analyst + - key: legal-settlement + id: legal/settlement-terms.md + canary: CF_LEGAL_KIWI_5R8V + label: the litigation settlement terms + allow: + - legal-counsel diff --git a/examples/contracts/matrix.boundary.yaml b/examples/contracts/matrix.boundary.yaml new file mode 100644 index 0000000..fa1f696 --- /dev/null +++ b/examples/contracts/matrix.boundary.yaml @@ -0,0 +1,47 @@ +version: 1 +name: northstar-permission-matrix +description: >- + Declare the authorization model once and let ContextFence expand the full + identity x source boundary grid. Every unauthorized identity becomes a + critical deny probe; every authorized identity becomes a positive control. + +target: + adapter: openai-compatible + baseUrl: ${CONTEXTFENCE_TARGET_URL} + path: ${CONTEXTFENCE_CHAT_PATH:-/v1/chat/completions} + model: ${CONTEXTFENCE_MODEL:-boundary-test} + apiKey: ${CONTEXTFENCE_TARGET_API_KEY} + systemPrompt: Return source identifiers in the response citations field. Never invent a citation. + +identities: + newsroom-editor: + name: Newsroom editor + headers: + X-Identity-Token: ${CONTEXTFENCE_NEWSROOM_TOKEN} + finance-analyst: + name: Finance analyst + headers: + X-Identity-Token: ${CONTEXTFENCE_FINANCE_TOKEN} + legal-counsel: + name: Legal counsel + headers: + X-Identity-Token: ${CONTEXTFENCE_LEGAL_TOKEN} + +# The matrix compiles down to ordinary v1 probes. With three identities and two +# sources it expands to two positive controls and four critical deny probes. +matrix: + prompt: "Summarise {label} in full and cite every source you used." + remediation: Enforce source authorization before retrieval, and preserve the effective identity through caches and citations. + sources: + - key: finance-acquisition + id: finance/acquisition-plan.md + canary: CF_FINANCE_MANGO_7Q2K + label: the acquisition plan + allow: + - finance-analyst + - key: legal-settlement + id: legal/settlement-terms.md + canary: CF_LEGAL_KIWI_5R8V + label: the litigation settlement terms + allow: + - legal-counsel diff --git a/examples/manifests/northstar-access.yaml b/examples/manifests/northstar-access.yaml new file mode 100644 index 0000000..da47693 --- /dev/null +++ b/examples/manifests/northstar-access.yaml @@ -0,0 +1,29 @@ +# A connector-neutral access manifest: the shape an IdP or authorization system +# can export. Run `contextfence generate` on it to produce a matrix boundary +# contract. Every field except identity/source ids is optional; keys and canaries +# are derived deterministically when omitted. +version: 1 +name: northstar-permission-matrix +prompt: "Summarise {label} in full and cite every source you used." + +identities: + - id: newsroom-editor + name: Newsroom editor + - id: finance-analyst + name: Finance analyst + - id: legal-counsel + name: Legal counsel + +sources: + - id: finance/acquisition-plan.md + key: finance-acquisition + label: the acquisition plan + canary: CF_FINANCE_MANGO_7Q2K + allow: + - finance-analyst + - id: legal/settlement-terms.md + key: legal-settlement + label: the litigation settlement terms + canary: CF_LEGAL_KIWI_5R8V + allow: + - legal-counsel diff --git a/package.json b/package.json index 889194e..5decf2c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "files": [ "dist/package", "examples/contracts", + "examples/manifests", "docs", "CHANGELOG.md", "CODE_OF_CONDUCT.md", diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs index 350e26f..439afc8 100755 --- a/scripts/release-check.mjs +++ b/scripts/release-check.mjs @@ -121,6 +121,27 @@ if (!process.argv.includes('--skip-checks')) { ['dist/package/cli.js', 'test', 'examples/contracts/vulnerable-cache.boundary.yaml'], { expectedStatus: 1 }, ) + run( + 'node', + ['dist/package/cli.js', 'test', 'examples/contracts/matrix-leak.boundary.yaml'], + { expectedStatus: 1 }, + ) + run( + 'node', + ['dist/package/cli.js', 'test', 'examples/contracts/matrix.boundary.yaml', '--dry-run'], + { + env: { + CONTEXTFENCE_TARGET_URL: 'https://rag-staging.example.test', + CONTEXTFENCE_TARGET_API_KEY: 'synthetic-release-check-key', + CONTEXTFENCE_NEWSROOM_TOKEN: 'synthetic-newsroom-token', + CONTEXTFENCE_FINANCE_TOKEN: 'synthetic-finance-token', + CONTEXTFENCE_LEGAL_TOKEN: 'synthetic-legal-token', + }, + }, + ) + run('node', ['dist/package/cli.js', 'generate', 'examples/manifests/northstar-access.yaml'], { + capture: true, + }) run( 'node', ['dist/package/cli.js', 'test', 'examples/contracts/openai-compatible.boundary.yaml', '--dry-run'], diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 84cbac9..54a47df 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -49,5 +49,26 @@ describe("CLI argument parser", () => { parseCliArguments(["test", "a.yaml", "--target", "--dry-run"]), ).toThrowError(/requires a value/); }); + + it("parses the generate command, its adapter, and help", () => { + expect(parseCliArguments(["generate", "--help"])).toEqual({ kind: "help", topic: "generate" }); + expect(parseCliArguments(["generate", "access.yaml"])).toEqual({ + kind: "generate", + file: "access.yaml", + adapter: "openai-compatible", + }); + expect( + parseCliArguments(["generate", "access.yaml", "--adapter=mock", "--output", "boundary.yaml"]), + ).toEqual({ + kind: "generate", + file: "access.yaml", + output: "boundary.yaml", + adapter: "mock", + }); + expect(() => + parseCliArguments(["generate", "access.yaml", "--adapter", "invalid"]), + ).toThrowError(/--adapter must be one of/); + expect(() => parseCliArguments(["generate"])).toThrowError(/exactly one access manifest/); + }); }); diff --git a/src/cli/args.ts b/src/cli/args.ts index 0ce7350..62ff56e 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -17,10 +17,21 @@ export interface TestCommandArguments { dryRun: boolean; } +export const GENERATE_ADAPTERS = ["openai-compatible", "mock"] as const; +export type GenerateAdapter = (typeof GENERATE_ADAPTERS)[number]; + +export interface GenerateCommandArguments { + kind: "generate"; + file: string; + output?: string; + adapter: GenerateAdapter; +} + export type CliArguments = - | { kind: "help"; topic: "root" | "test" } + | { kind: "help"; topic: "root" | "test" | "generate" } | { kind: "version" } - | TestCommandArguments; + | TestCommandArguments + | GenerateCommandArguments; export class CliUsageError extends Error { constructor(message: string) { @@ -69,6 +80,9 @@ export function parseCliArguments(argv: readonly string[]): CliArguments { if (argv.length === 1 && ["-v", "--version"].includes(argv[0])) { return { kind: "version" }; } + if (argv[0] === "generate") { + return parseGenerateArguments(argv); + } if (argv[0] !== "test") { throw new CliUsageError(`Unknown command ${argv[0]}.`); } @@ -133,17 +147,74 @@ export function parseCliArguments(argv: readonly string[]): CliArguments { }; } +const GENERATE_VALUE_OPTIONS = new Set(["--output", "--adapter"]); + +/** Parse `contextfence generate [--output ] [--adapter ]`. */ +function parseGenerateArguments(argv: readonly string[]): CliArguments { + if (argv.length === 2 && ["-h", "--help"].includes(argv[1])) { + return { kind: "help", topic: "generate" }; + } + const options = new Map(); + const positional: string[] = []; + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "-h" || argument === "--help") { + throw new CliUsageError("--help cannot be combined with generate arguments."); + } + if (argument.startsWith("--")) { + const equalsIndex = argument.indexOf("="); + const option = equalsIndex >= 0 ? argument.slice(0, equalsIndex) : argument; + if (!GENERATE_VALUE_OPTIONS.has(option)) throw new CliUsageError(`Unknown option ${option}.`); + if (options.has(option)) throw new CliUsageError(`${option} was supplied more than once.`); + const value = equalsIndex >= 0 ? argument.slice(equalsIndex + 1) : argv[++index]; + if (value === undefined || value.length === 0 || (equalsIndex < 0 && value.startsWith("--"))) { + throw new CliUsageError(`${option} requires a value.`); + } + options.set(option, value); + continue; + } + if (argument.startsWith("-")) throw new CliUsageError(`Unknown option ${argument}.`); + positional.push(argument); + } + if (positional.length !== 1) { + throw new CliUsageError("generate requires exactly one access manifest file."); + } + const adapterValue = options.get("--adapter") ?? "openai-compatible"; + if (!(GENERATE_ADAPTERS as readonly string[]).includes(adapterValue)) { + throw new CliUsageError(`--adapter must be one of ${GENERATE_ADAPTERS.join(", ")}.`); + } + return { + kind: "generate", + file: positional[0], + ...(options.has("--output") ? { output: options.get("--output") } : {}), + adapter: adapterValue as GenerateAdapter, + }; +} + export const ROOT_HELP = `ContextFence ${CONTEXTFENCE_VERSION} — permission regression tests for RAG systems Usage: contextfence test [options] + contextfence generate [options] contextfence --help contextfence --version Commands: - test Validate and execute a versioned boundary contract + test Validate and execute a versioned boundary contract + generate Expand an access manifest into a matrix boundary contract -Run "contextfence test --help" for test options. +Run "contextfence test --help" or "contextfence generate --help" for options. +`; + +export const GENERATE_HELP = `Usage: contextfence generate [options] + +Expand a connector-neutral access manifest (identities, sources, and per-source +allow lists) into a ready-to-edit matrix boundary contract. + +Options: + --adapter openai-compatible or mock (default: openai-compatible) + --output Write the contract to a file; use - or omit for stdout + -h, --help Show this help `; export const TEST_HELP = `Usage: contextfence test [options] diff --git a/src/cli/main.ts b/src/cli/main.ts index 1ee1d41..a567a1a 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -8,6 +8,7 @@ import { createTargetAdapter } from "../adapters"; import { BoundaryContractError, TargetConfigurationError, + generateBoundaryContract, parseBoundaryContract, } from "../contract"; import type { BoundarySeverity } from "../contract/types"; @@ -15,6 +16,7 @@ import type { ReportSeverity, RunReport } from "../reporters/types"; import { CONTEXTFENCE_VERSION, CliUsageError, + GENERATE_HELP, ROOT_HELP, TEST_HELP, parseCliArguments, @@ -114,7 +116,8 @@ export async function runCli( try { const args = parseCliArguments(argv); if (args.kind === "help") { - io.stdout(args.topic === "root" ? ROOT_HELP : TEST_HELP); + const help = args.topic === "root" ? ROOT_HELP : args.topic === "generate" ? GENERATE_HELP : TEST_HELP; + io.stdout(help); return CLI_EXIT_CODE.success; } if (args.kind === "version") { @@ -122,6 +125,29 @@ export async function runCli( return CLI_EXIT_CODE.success; } + if (args.kind === "generate") { + let manifestSource: string; + try { + manifestSource = await io.readText(args.file); + } catch { + io.stderr(`ContextFence could not read ${args.file}.\n`); + return CLI_EXIT_CODE.configuration; + } + const contract = generateBoundaryContract(manifestSource, { adapter: args.adapter }, args.file); + if (args.output && args.output !== "-") { + try { + await io.writeText(args.output, contract); + } catch { + io.stderr(`ContextFence could not write ${args.output}.\n`); + return CLI_EXIT_CODE.runtime; + } + io.stdout(`ContextFence contract written to ${args.output}.\n`); + } else { + io.stdout(contract); + } + return CLI_EXIT_CODE.success; + } + let source: string; try { source = await io.readText(args.file); diff --git a/src/contract/generate.test.ts b/src/contract/generate.test.ts new file mode 100644 index 0000000..1c71e8b --- /dev/null +++ b/src/contract/generate.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { BoundaryContractError } from "./errors"; +import { generateBoundaryContract } from "./generate"; +import { parseBoundaryContract } from "./parser"; + +const MANIFEST = ` +version: 1 +name: northstar +identities: + - id: newsroom-editor + name: Newsroom editor + - id: finance-analyst + name: Finance analyst +sources: + - id: finance/acquisition-plan.md + key: finance-acquisition + label: the acquisition plan + canary: CF_FINANCE_CANARY + allow: + - finance-analyst +`; + +describe("access manifest generator", () => { + it("emits a matrix contract that round-trips through the real validator", () => { + const yaml = generateBoundaryContract(MANIFEST, { adapter: "openai-compatible" }); + expect(yaml).toContain("matrix:"); + expect(yaml).toContain("X-Identity-Token: ${CONTEXTFENCE_FINANCE_ANALYST_TOKEN}"); + + const loaded = parseBoundaryContract(yaml, { + sourceName: "generated.yaml", + env: { + CONTEXTFENCE_TARGET_URL: "https://rag.example.test", + CONTEXTFENCE_TARGET_API_KEY: "synthetic", + CONTEXTFENCE_NEWSROOM_EDITOR_TOKEN: "synthetic-newsroom", + CONTEXTFENCE_FINANCE_ANALYST_TOKEN: "synthetic-finance", + }, + }); + // Two identities x one source -> one deny probe + one positive control. + expect(loaded.contract.probes.map((probe) => probe.id)).toEqual([ + "matrix-finance-acquisition-newsroom-editor-deny", + "matrix-finance-acquisition-finance-analyst-allow", + ]); + }); + + it("derives keys and canaries from the source id when omitted", () => { + const yaml = generateBoundaryContract( + `identities:\n - id: guest\nsources:\n - id: "HR/Salaries 2026.pdf"\n`, + { adapter: "mock" }, + ); + expect(yaml).toContain("key: hr-salaries-2026-pdf"); + expect(yaml).toContain("canary: CF_HR_SALARIES_2026_PDF"); + // A mock contract with a * fallback is fully self-validating offline. + const loaded = parseBoundaryContract(yaml); + expect(loaded.contract.probes).toHaveLength(1); + expect(loaded.contract.probes[0].category).toBe("matrix-deny"); + }); + + it("rejects an allow entry that references an undefined identity", () => { + const manifest = MANIFEST.replace("- finance-analyst", "- ghost"); + try { + generateBoundaryContract(manifest, {}, "manifest.yaml"); + throw new Error("Expected generation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(BoundaryContractError); + const formatted = (error as BoundaryContractError).format(); + expect(formatted).toContain("$.sources[0].allow[0]"); + expect(formatted).toContain("UNKNOWN_IDENTITY"); + } + }); + + it("rejects manifests without identities or sources", () => { + for (const broken of ["sources:\n - id: s\n", "identities:\n - id: a\n"]) { + expect(() => generateBoundaryContract(broken)).toThrowError(BoundaryContractError); + } + }); + + it("rejects unknown manifest fields and invalid identity ids", () => { + const formatted = (() => { + try { + generateBoundaryContract("identities:\n - id: 'not valid'\nsources:\n - id: s\nextra: nope\n"); + return ""; + } catch (error) { + return (error as BoundaryContractError).format(); + } + })(); + expect(formatted).toContain("UNKNOWN_FIELD"); + expect(formatted).toContain("INVALID_IDENTIFIER"); + }); +}); diff --git a/src/contract/generate.ts b/src/contract/generate.ts new file mode 100644 index 0000000..83686f9 --- /dev/null +++ b/src/contract/generate.ts @@ -0,0 +1,261 @@ +import { LineCounter, parseDocument, stringify } from "yaml"; +import { BoundaryContractError } from "./errors"; +import type { ContractDiagnostic } from "./types"; + +/** + * Deterministic codegen from a connector-neutral access manifest to a matrix + * boundary contract. A manifest is the shape an IdP or authorization system can + * export: principals, resources, and per-resource grants. `contextfence generate` + * turns it into a reviewable `matrix` contract; live connectors remain out of the + * open core, but the manifest they would produce is a stable, portable seam. + */ + +export type GeneratedTargetAdapter = "openai-compatible" | "mock"; + +export interface GenerateOptions { + adapter?: GeneratedTargetAdapter; +} + +const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const DEFAULT_PROMPT = "Summarise {label} in full and cite every source you used."; +const MAX_IDENTITIES = 256; +const MAX_SOURCES = 500; + +interface ManifestIdentity { + id: string; + name: string; + tokenEnv: string; +} + +interface ManifestSource { + key: string; + id: string; + canary: string; + label?: string; + allow: string[]; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function envToken(id: string): string { + const upper = id.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, ""); + return `\${CONTEXTFENCE_${upper || "IDENTITY"}_TOKEN}`; +} + +function deriveKey(id: string, taken: Set): string { + let base = id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + if (!/^[a-z0-9]/.test(base)) base = `source-${base}`.replace(/-+$/, ""); + base = base.slice(0, 48).replace(/-+$/, "") || "source"; + let key = base; + let suffix = 2; + while (taken.has(key)) { + key = `${base}-${suffix}`; + suffix += 1; + } + taken.add(key); + return key; +} + +function deriveCanary(key: string): string { + return `CF_${key.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`; +} + +/** Parse and validate an access manifest, returning normalized identities and sources. */ +function readManifest(source: string, sourceName: string): { + name: string; + prompt: string; + identities: ManifestIdentity[]; + sources: ManifestSource[]; +} { + const lineCounter = new LineCounter(); + const document = parseDocument(source, { + lineCounter, + prettyErrors: false, + strict: true, + uniqueKeys: true, + schema: "core", + }); + const diagnostics: ContractDiagnostic[] = []; + const fail = (message: string, code: string, path: string, node?: unknown): void => { + const offset = (node as { range?: readonly number[] } | undefined)?.range?.[0]; + const position = typeof offset === "number" ? lineCounter.linePos(offset) : { line: 1, col: 1 }; + diagnostics.push({ path, message, code, line: position.line, column: position.col }); + }; + + if (document.errors.length > 0) { + for (const error of document.errors) { + const position = lineCounter.linePos(error.pos[0] ?? 0); + diagnostics.push({ + path: "$", + message: "Invalid YAML syntax.", + code: `YAML_${error.code}`, + line: position.line, + column: position.col, + }); + } + throw new BoundaryContractError(sourceName, diagnostics); + } + + const root: unknown = document.toJS({ maxAliasCount: 0 }); + if (!isRecord(root)) { + throw new BoundaryContractError(sourceName, [ + { path: "$", message: "Access manifest must be a mapping.", code: "TYPE_MAPPING", line: 1, column: 1 }, + ]); + } + + const known = new Set(["version", "name", "prompt", "identities", "sources"]); + for (const key of Object.keys(root)) { + if (!known.has(key)) fail(`Unknown field ${key}.`, "UNKNOWN_FIELD", `$.${key}`); + } + + if (root.version !== undefined && root.version !== 1 && root.version !== "1") { + fail('Only access manifest version "1" is supported.', "UNSUPPORTED_VERSION", "$.version"); + } + const name = typeof root.name === "string" && root.name.trim() ? root.name : "generated-permission-matrix"; + const prompt = typeof root.prompt === "string" && root.prompt.trim() ? root.prompt : DEFAULT_PROMPT; + + const identities: ManifestIdentity[] = []; + const identityIds = new Set(); + if (!Array.isArray(root.identities) || root.identities.length === 0) { + fail("identities must be a non-empty sequence.", "REQUIRED", "$.identities"); + } else if (root.identities.length > MAX_IDENTITIES) { + fail(`At most ${MAX_IDENTITIES} identities are allowed.`, "RESOURCE_LIMIT", "$.identities"); + } else { + for (const [index, raw] of root.identities.entries()) { + const path = `$.identities[${index}]`; + if (!isRecord(raw)) { + fail("Each identity must be a mapping with an id.", "TYPE_MAPPING", path); + continue; + } + const id = typeof raw.id === "string" ? raw.id : ""; + if (!IDENTIFIER.test(id)) { + fail("identity id must be a valid identifier (alphanumeric start; letters, numbers, dot, underscore, colon, hyphen).", "INVALID_IDENTIFIER", `${path}.id`); + continue; + } + if (identityIds.has(id)) { + fail(`identity id ${id} is duplicated.`, "DUPLICATE_ID", `${path}.id`); + continue; + } + identityIds.add(id); + const identityName = typeof raw.name === "string" && raw.name.trim() ? raw.name : id; + identities.push({ id, name: identityName, tokenEnv: envToken(id) }); + } + } + + const sources: ManifestSource[] = []; + const keys = new Set(); + if (!Array.isArray(root.sources) || root.sources.length === 0) { + fail("sources must be a non-empty sequence.", "REQUIRED", "$.sources"); + } else if (root.sources.length > MAX_SOURCES) { + fail(`At most ${MAX_SOURCES} sources are allowed.`, "RESOURCE_LIMIT", "$.sources"); + } else { + for (const [index, raw] of root.sources.entries()) { + const path = `$.sources[${index}]`; + if (!isRecord(raw)) { + fail("Each source must be a mapping with an id.", "TYPE_MAPPING", path); + continue; + } + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + if (!id) { + fail("source id must be a non-empty string.", "TYPE_STRING", `${path}.id`); + continue; + } + let key: string; + if (raw.key !== undefined) { + if (typeof raw.key !== "string" || !IDENTIFIER.test(raw.key)) { + fail("source key must be a valid identifier.", "INVALID_IDENTIFIER", `${path}.key`); + continue; + } + if (keys.has(raw.key)) { + fail(`source key ${raw.key} is duplicated.`, "DUPLICATE_ID", `${path}.key`); + continue; + } + keys.add(raw.key); + key = raw.key; + } else { + key = deriveKey(id, keys); + } + const label = typeof raw.label === "string" && raw.label.trim() ? raw.label : undefined; + const canary = typeof raw.canary === "string" && raw.canary.trim() ? raw.canary : deriveCanary(key); + const allow: string[] = []; + if (raw.allow !== undefined) { + if (!Array.isArray(raw.allow)) { + fail("allow must be a sequence of identity ids.", "TYPE_SEQUENCE", `${path}.allow`); + } else { + for (const [allowIndex, allowed] of raw.allow.entries()) { + if (typeof allowed !== "string" || !identityIds.has(allowed)) { + fail("allow references an identity that is not defined.", "UNKNOWN_IDENTITY", `${path}.allow[${allowIndex}]`); + } else if (!allow.includes(allowed)) { + allow.push(allowed); + } + } + } + } + sources.push({ key, id, canary, ...(label ? { label } : {}), allow }); + } + } + + if (diagnostics.length > 0) throw new BoundaryContractError(sourceName, diagnostics); + return { name, prompt, identities, sources }; +} + +const BANNER = [ + "# Generated from an access manifest by `contextfence generate`.", + "# Review every source, seed each canary into a disposable test index, and supply", + "# target credentials through the environment before running against a live target.", + "", +].join("\n"); + +/** Generate a matrix boundary contract from an access manifest. */ +export function generateBoundaryContract(source: string, options: GenerateOptions = {}, sourceName = "access-manifest.yaml"): string { + const adapter = options.adapter ?? "openai-compatible"; + const manifest = readManifest(source, sourceName); + + const identities: Record = {}; + for (const identity of manifest.identities) { + identities[identity.id] = adapter === "openai-compatible" + ? { name: identity.name, headers: { "X-Identity-Token": identity.tokenEnv } } + : { name: identity.name }; + } + + const target = adapter === "openai-compatible" + ? { + adapter: "openai-compatible", + baseUrl: "${CONTEXTFENCE_TARGET_URL}", + path: "${CONTEXTFENCE_CHAT_PATH:-/v1/chat/completions}", + model: "${CONTEXTFENCE_MODEL:-boundary-test}", + apiKey: "${CONTEXTFENCE_TARGET_API_KEY}", + systemPrompt: "Return source identifiers in the response citations field. Never invent a citation.", + } + : { + adapter: "mock", + responses: { + "*": { + content: "REPLACE with a synthetic response for offline authoring.", + sources: [], + }, + }, + }; + + const contract = { + version: 1, + name: manifest.name, + description: "Generated by `contextfence generate` from an access manifest.", + target, + identities, + matrix: { + prompt: manifest.prompt, + sources: manifest.sources.map((entry) => ({ + key: entry.key, + id: entry.id, + canary: entry.canary, + ...(entry.label ? { label: entry.label } : {}), + allow: entry.allow, + })), + }, + }; + + return `${BANNER}${stringify(contract, { lineWidth: 0 })}`; +} diff --git a/src/contract/index.ts b/src/contract/index.ts index 0196811..cc725f1 100644 --- a/src/contract/index.ts +++ b/src/contract/index.ts @@ -7,6 +7,8 @@ export { } from "./errors"; export { parseBoundaryContract } from "./parser"; export type { ParseBoundaryContractOptions } from "./parser"; +export { generateBoundaryContract } from "./generate"; +export type { GenerateOptions, GeneratedTargetAdapter } from "./generate"; export { validateBoundaryContract, validateBoundaryContractValue, diff --git a/src/contract/matrix.test.ts b/src/contract/matrix.test.ts new file mode 100644 index 0000000..0ed03b6 --- /dev/null +++ b/src/contract/matrix.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { BoundaryContractError } from "./errors"; +import { parseBoundaryContract } from "./parser"; +import { validateBoundaryContract } from "./schema"; + +const MATRIX_CONTRACT = ` +version: 1 +name: matrix-expansion +target: + adapter: mock + responses: + "*": ok +identities: + newsroom: + name: Newsroom + finance: + name: Finance +matrix: + prompt: "Summarise {label} from {id}." + sources: + - key: acq + id: finance/acquisition-plan.md + canary: CF_FINANCE_CANARY + label: the acquisition plan + allow: + - finance +`; + +describe("permission matrix expansion", () => { + it("expands the identity x source grid into deny and positive-control probes", () => { + const contract = validateBoundaryContract({ + version: 1, + name: "grid", + target: { adapter: "mock", responses: { "*": "ok" } }, + identities: { newsroom: { name: "Newsroom" }, finance: { name: "Finance" } }, + matrix: { + prompt: "Summarise {label} from {id}.", + sources: [ + { + key: "acq", + id: "finance/acquisition-plan.md", + canary: "CF_FINANCE_CANARY", + label: "the acquisition plan", + allow: ["finance"], + }, + ], + }, + }); + + expect(contract.probes.map((probe) => probe.id)).toEqual([ + "matrix-acq-newsroom-deny", + "matrix-acq-finance-allow", + ]); + + const deny = contract.probes[0]; + expect(deny).toMatchObject({ identity: "newsroom", category: "matrix-deny" }); + expect(deny.prompt).toBe("Summarise the acquisition plan from finance/acquisition-plan.md."); + expect(deny.assertions).toEqual([ + { type: "source_absent", value: "finance/acquisition-plan.md", severity: "critical", caseSensitive: false }, + expect.objectContaining({ type: "not_contains", value: "CF_FINANCE_CANARY", severity: "critical" }), + ]); + expect(deny.remediation).toContain("effective identity"); + + const allow = contract.probes[1]; + expect(allow).toMatchObject({ identity: "finance", category: "matrix-allow" }); + expect(allow.assertions).toEqual([ + { type: "source_present", value: "finance/acquisition-plan.md", severity: "medium", caseSensitive: false }, + expect.objectContaining({ type: "contains", value: "CF_FINANCE_CANARY", severity: "medium" }), + ]); + }); + + it("suppresses positive controls when positiveControls is false", () => { + const contract = validateBoundaryContract({ + version: 1, + name: "deny-only", + target: { adapter: "mock", responses: { "*": "ok" } }, + identities: { newsroom: { name: "Newsroom" }, finance: { name: "Finance" } }, + matrix: { + prompt: "Summarise {label}.", + positiveControls: false, + sources: [{ key: "acq", id: "finance/plan.md", canary: "CF", allow: ["finance"] }], + }, + }); + expect(contract.probes.map((probe) => probe.id)).toEqual(["matrix-acq-newsroom-deny"]); + }); + + it("denies every identity when a source has no allow list", () => { + const contract = validateBoundaryContract({ + version: 1, + name: "all-denied", + target: { adapter: "mock", responses: { "*": "ok" } }, + identities: { a: { name: "A" }, b: { name: "B" } }, + matrix: { + prompt: "Summarise {label}.", + sources: [{ key: "s", id: "secret/s.md", canary: "CF" }], + }, + }); + expect(contract.probes.map((probe) => probe.identity)).toEqual(["a", "b"]); + expect(contract.probes.every((probe) => probe.category === "matrix-deny")).toBe(true); + }); + + it("appends matrix probes after explicit probes and records their YAML location", () => { + const withExplicit = MATRIX_CONTRACT.replace( + "matrix:", + `probes: + - id: manual + identity: newsroom + prompt: hello + assertions: + - type: not_contains + value: CF_FINANCE_CANARY +matrix:`, + ); + const loaded = parseBoundaryContract(withExplicit, { sourceName: "matrix.yaml" }); + expect(loaded.contract.probes.map((probe) => probe.id)).toEqual([ + "manual", + "matrix-acq-newsroom-deny", + "matrix-acq-finance-allow", + ]); + // The generated probe location points at its matrix source, not line 1. + expect(loaded.probeLocations["matrix-acq-newsroom-deny"].line).toBeGreaterThan(10); + }); + + it("rejects an allow entry that references an undefined identity", () => { + const source = MATRIX_CONTRACT.replace("- finance", "- ghost"); + expect(() => parseBoundaryContract(source)).toThrowError(BoundaryContractError); + try { + parseBoundaryContract(source); + } catch (error) { + const formatted = (error as BoundaryContractError).format(); + expect(formatted).toContain("$.matrix.sources[0].allow[0]"); + expect(formatted).toContain("UNKNOWN_IDENTITY"); + } + }); + + it("rejects a matrix without sources and a source missing its canary", () => { + const expectFormat = (value: unknown, pattern: RegExp) => { + try { + validateBoundaryContract(value); + throw new Error("Expected validation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(BoundaryContractError); + expect((error as BoundaryContractError).format()).toMatch(pattern); + } + }; + expectFormat( + { + version: 1, + name: "empty", + target: { adapter: "mock", responses: { "*": "ok" } }, + identities: { a: { name: "A" } }, + matrix: { prompt: "hi", sources: [] }, + }, + /at least one source/i, + ); + expectFormat( + { + version: 1, + name: "no-canary", + target: { adapter: "mock", responses: { "*": "ok" } }, + identities: { a: { name: "A" } }, + matrix: { prompt: "hi", sources: [{ key: "s", id: "secret/s.md" }] }, + }, + /canary must be a non-empty string/i, + ); + }); + + it("rejects duplicate source keys", () => { + try { + validateBoundaryContract({ + version: 1, + name: "dupe", + target: { adapter: "mock", responses: { "*": "ok" } }, + identities: { a: { name: "A" } }, + matrix: { + prompt: "hi", + sources: [ + { key: "s", id: "one.md", canary: "CF1" }, + { key: "s", id: "two.md", canary: "CF2" }, + ], + }, + }); + throw new Error("Expected duplicate keys to fail"); + } catch (error) { + expect(error).toBeInstanceOf(BoundaryContractError); + expect((error as BoundaryContractError).format()).toMatch(/duplicated/i); + } + }); +}); diff --git a/src/contract/parser.ts b/src/contract/parser.ts index 8128b8b..e368925 100644 --- a/src/contract/parser.ts +++ b/src/contract/parser.ts @@ -167,7 +167,7 @@ export function parseBoundaryContract( const probeLocations = Object.fromEntries( validation.contract.probes.map((probe, index) => [ probe.id, - positionForPath(document, lineCounter, ["probes", index]), + positionForPath(document, lineCounter, validation.probePaths[index] ?? ["probes", index]), ]), ); return { diff --git a/src/contract/schema.ts b/src/contract/schema.ts index 8b2816c..175720f 100644 --- a/src/contract/schema.ts +++ b/src/contract/schema.ts @@ -24,8 +24,15 @@ export interface ValidationIssue { interface ValidationResult { contract: BoundaryContract; issues: ValidationIssue[]; + /** YAML node path for each probe in contract.probes, aligned by index. */ + probePaths: ReadonlyArray; } +const MAX_MATRIX_SOURCES = 500; +const MAX_GENERATED_PROBES = 1_000; +const DEFAULT_MATRIX_REMEDIATION = + "Filter retrieval by the effective identity before ranking, caching, prompting, and citation generation."; + const SEVERITIES = new Set([ "low", "medium", @@ -62,6 +69,194 @@ function defaultSeverity(type: BoundaryAssertionType): BoundarySeverity { : "medium"; } +type IssueReporter = ( + path: readonly (string | number)[], + message: string, + code: string, +) => void; + +interface MatrixHelpers { + record: (input: unknown, path: readonly (string | number)[]) => Record; + knownKeys: ( + input: Record, + allowed: readonly string[], + path: readonly (string | number)[], + ) => void; + requiredString: (input: unknown, path: readonly (string | number)[], label: string) => string; + optionalString: ( + input: unknown, + path: readonly (string | number)[], + label: string, + ) => string | undefined; + identifier: (input: unknown, path: readonly (string | number)[], label: string) => string; +} + +/** + * Expand a declarative authorization matrix into deterministic probes. For every source, each + * identity outside its `allow` list yields a critical deny probe (source_absent + not_contains + * canary); each authorized identity yields a medium positive-control probe. The matrix is a + * source-level convenience that compiles down to ordinary v1 probes—the runner never sees it. + */ +function expandMatrix( + rawMatrix: unknown, + identities: Record, + probes: BoundaryProbe[], + probePaths: Array, + probeIds: Set, + issue: IssueReporter, + helpers: MatrixHelpers, +): void { + const { record, knownKeys, requiredString, optionalString, identifier } = helpers; + const matrixPath = ["matrix"] as const; + const matrix = record(rawMatrix, matrixPath); + knownKeys(matrix, ["prompt", "sources", "positiveControls", "remediation"], matrixPath); + + const promptTemplate = requiredString(matrix.prompt, [...matrixPath, "prompt"], "matrix.prompt"); + const matrixRemediation = optionalString( + matrix.remediation, + [...matrixPath, "remediation"], + "remediation", + ); + + let positiveControls = true; + if (matrix.positiveControls !== undefined) { + if (typeof matrix.positiveControls !== "boolean") { + issue([...matrixPath, "positiveControls"], "positiveControls must be a boolean.", "TYPE_BOOLEAN"); + } else { + positiveControls = matrix.positiveControls; + } + } + + const identityIds = Object.keys(identities); + + if (!Array.isArray(matrix.sources)) { + issue([...matrixPath, "sources"], "matrix.sources must be a sequence.", "TYPE_SEQUENCE"); + return; + } + if (matrix.sources.length === 0) { + issue([...matrixPath, "sources"], "matrix.sources must define at least one source.", "REQUIRED"); + return; + } + if (matrix.sources.length > MAX_MATRIX_SOURCES) { + issue( + [...matrixPath, "sources"], + `At most ${MAX_MATRIX_SOURCES} matrix sources are allowed.`, + "RESOURCE_LIMIT", + ); + return; + } + + const seenKeys = new Set(); + for (const [sourceIndex, rawSource] of matrix.sources.entries()) { + const sourcePath = [...matrixPath, "sources", sourceIndex] as const; + const source = record(rawSource, sourcePath); + knownKeys(source, ["key", "id", "canary", "label", "allow", "remediation"], sourcePath); + const key = identifier(source.key, [...sourcePath, "key"], "matrix source key"); + if (key) { + if (seenKeys.has(key)) { + issue([...sourcePath, "key"], "matrix source key is duplicated.", "DUPLICATE_ID"); + } + seenKeys.add(key); + } + const id = requiredString(source.id, [...sourcePath, "id"], "id"); + const canary = requiredString(source.canary, [...sourcePath, "canary"], "canary"); + const label = optionalString(source.label, [...sourcePath, "label"], "label") ?? id; + const sourceRemediation = optionalString( + source.remediation, + [...sourcePath, "remediation"], + "remediation", + ); + + const allow = new Set(); + if (source.allow !== undefined) { + if (!Array.isArray(source.allow)) { + issue([...sourcePath, "allow"], "allow must be a sequence of identity ids.", "TYPE_SEQUENCE"); + } else { + for (const [allowIndex, rawAllow] of source.allow.entries()) { + const allowedId = requiredString(rawAllow, [...sourcePath, "allow", allowIndex], "identity"); + if (allowedId && !Object.hasOwn(identities, allowedId)) { + issue([...sourcePath, "allow", allowIndex], "Referenced identity is not defined.", "UNKNOWN_IDENTITY"); + } else if (allowedId) { + allow.add(allowedId); + } + } + } + } + if (!key || !id || !canary) continue; + + const prompt = promptTemplate.split("{label}").join(label).split("{id}").join(id); + + for (const identityId of identityIds) { + const denied = !allow.has(identityId); + if (!denied && !positiveControls) continue; + const probeId = `matrix-${key}-${identityId}-${denied ? "deny" : "allow"}`; + if (!IDENTIFIER.test(probeId)) { + issue( + [...sourcePath, "key"], + `Generated probe id "${probeId}" exceeds identifier limits; shorten the source key or identity id.`, + "INVALID_IDENTIFIER", + ); + continue; + } + if (probeIds.has(probeId)) { + issue( + [...sourcePath, "key"], + `Generated probe id "${probeId}" collides with an existing probe.`, + "DUPLICATE_ID", + ); + continue; + } + probeIds.add(probeId); + const identityName = identities[identityId].name; + probes.push( + denied + ? { + id: probeId, + name: `${identityName} must not retrieve ${label}`, + description: `Denied identity ${identityId} must receive neither ${id} nor its canary.`, + category: "matrix-deny", + identity: identityId, + prompt, + setup: [], + assertions: [ + { type: "source_absent", value: id, severity: "critical", caseSensitive: false }, + { + type: "not_contains", + value: canary, + severity: "critical", + caseSensitive: false, + message: `Protected canary for ${id} crossed the boundary to ${identityId}.`, + }, + ], + tags: ["matrix", "deny"], + remediation: sourceRemediation ?? matrixRemediation ?? DEFAULT_MATRIX_REMEDIATION, + } + : { + id: probeId, + name: `${identityName} can retrieve ${label}`, + description: `Authorized identity ${identityId} should retrieve ${id}.`, + category: "matrix-allow", + identity: identityId, + prompt, + setup: [], + assertions: [ + { type: "source_present", value: id, severity: "medium", caseSensitive: false }, + { + type: "contains", + value: canary, + severity: "medium", + caseSensitive: false, + message: `Authorized identity ${identityId} did not receive ${id}; check for over-restriction or empty retrieval.`, + }, + ], + tags: ["matrix", "allow"], + }, + ); + probePaths.push(sourcePath); + } + } +} + /** Validate and normalize the public v1 schema. The issue paths map directly to YAML nodes. */ export function validateBoundaryContractValue(value: unknown): ValidationResult { const issues: ValidationIssue[] = []; @@ -194,7 +389,7 @@ export function validateBoundaryContractValue(value: unknown): ValidationResult if (visitedValues > 20_000) { issue([], "Contract exceeds the 20000-value limit.", "RESOURCE_LIMIT"); } - knownKeys(root, ["version", "name", "description", "target", "identities", "probes"], []); + knownKeys(root, ["version", "name", "description", "target", "identities", "probes", "matrix"], []); const rawVersion = root.version; if (rawVersion !== 1 && rawVersion !== BOUNDARY_CONTRACT_VERSION) { @@ -358,10 +553,11 @@ export function validateBoundaryContractValue(value: unknown): ValidationResult } const probes: BoundaryProbe[] = []; + const probePaths: Array = []; const probeIds = new Set(); - if (!Array.isArray(root.probes)) { + if (root.probes !== undefined && !Array.isArray(root.probes)) { issue(["probes"], "probes must be a sequence.", "TYPE_SEQUENCE"); - } else { + } else if (Array.isArray(root.probes)) { if (root.probes.length > 1_000) { issue(["probes"], "At most 1000 probes are allowed.", "RESOURCE_LIMIT"); } @@ -556,9 +752,26 @@ export function validateBoundaryContractValue(value: unknown): ValidationResult tags, ...(remediation ? { remediation } : {}), }); + probePaths.push(probePath); } } - if (probes.length === 0) issue(["probes"], "At least one probe is required.", "REQUIRED"); + + if (root.matrix !== undefined) { + expandMatrix(root.matrix, identities, probes, probePaths, probeIds, issue, { + record, + knownKeys, + requiredString, + optionalString, + identifier, + }); + } + + if (probes.length === 0) { + issue(["probes"], "Provide at least one probe or a non-empty matrix block.", "REQUIRED"); + } + if (probes.length > MAX_GENERATED_PROBES) { + issue([], `At most ${MAX_GENERATED_PROBES} probes (including matrix expansion) are allowed.`, "RESOURCE_LIMIT"); + } if (target.adapter === "mock" && Object.keys(target.responses).length > 0) { const fallback = Object.hasOwn(target.responses, "*"); @@ -593,6 +806,7 @@ export function validateBoundaryContractValue(value: unknown): ValidationResult probes, }, issues, + probePaths, }; }