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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-governance-phase-intent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"adcontextprotocol": patch
---

Add `intent` to the `governance-phase` enum so a buyer-side `check_governance` call can express the intent-phase check the campaign-governance spec already mandates (the produced token MUST carry `phase: "intent"`). Previously the enum was `[purchase, modification, delivery]`, so an intent check silently defaulted to `purchase` and the intent/execution token separation was unenforceable at the schema layer. Fixes audit finding T1-4 (NS-GOV-001); guarded by `tests/governance-phase-enum.test.cjs`.
54 changes: 54 additions & 0 deletions .github/workflows/normative-guarantees.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Normative Guarantees

# Anti-drift gate for security-relevant normative guarantees.
# Unlike check-testable-snippets (informational, diff-only), this workflow is
# BLOCKING and runs whole-corpus. See specs/spec-anti-drift.md.
#
# SECURITY: the doctest lane executes PR-authored code (see scripts/run-doctests.cjs).
# This job MUST NOT be given secrets and MUST NOT be converted to
# `pull_request_target` — it runs under `pull_request`, so a fork PR gets a
# read-only, secret-less token. run-doctests.cjs additionally scrubs the child
# environment and refuses to run shell blocks; the residual exposure is
# compute-only, the same envelope as the existing snippet lane.

on:
pull_request:
branches: [main]
paths:
# Prose the doctest lane scans:
- 'docs/**/*.md'
- 'docs/**/*.mdx'
- 'specs/**/*.md'
# The registry and its linter:
- 'static/registry/normative-statements/**'
- 'scripts/check-normative-coverage.cjs'
- 'scripts/run-doctests.cjs'
# Enforcement targets referenced by enforced_by (so deleting one re-runs the gate):
- 'static/schemas/source/enums/**'
- 'tests/governance-phase-enum.test.cjs'
- '.github/workflows/normative-guarantees.yml'

permissions:
contents: read

jobs:
normative-guarantees:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.0

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'

- name: Doctest lane (deterministic, offline)
run: npm run test:doctests

- name: Schema-invariant guards
run: npm run test:governance-phase-enum

- name: Normative-statement coverage (registry well-formed; no enforced statement lost)
# One-way floor: counts ENFORCED statements, so adding a `gap` never fails.
# Bump the floor in the same PR that flips a registry row gap -> enforced.
run: node scripts/check-normative-coverage.cjs --min-enforced 3
2 changes: 1 addition & 1 deletion docs/building/by-layer/L1/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1681,7 +1681,7 @@ export async function verifyAdcpRequestSignature(req: Request, ctx: {
const { jwk } = await ctx.resolveJwk(parsed.keyid!); // throws _key_unknown
// 8: key purpose
const j = jwk as any;
if (j.use !== "sig" || !Array.isArray(j.key_ops) || !j.key_ops.includes("verify") || j.example_use !== "request-signing") {
if (j.use !== "sig" || !Array.isArray(j.key_ops) || !j.key_ops.includes("verify") || j.adcp_use !== "request-signing") {
throw new RequestSignatureError("request_signature_key_purpose_invalid");
}
// 9: revocation (BEFORE crypto verify)
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
"test:extensions": "node tests/extension-fields.test.cjs",
"test:extension-schemas": "node tests/extension-schemas.test.cjs",
"test:snippets": "node tests/snippet-validation.test.cjs",
"test:doctests": "node scripts/run-doctests.cjs",
"test:governance-phase-enum": "node tests/governance-phase-enum.test.cjs",
"check:normative-coverage": "node scripts/check-normative-coverage.cjs",
"test:json-schema": "node tests/json-schema-validation.test.cjs",
"test:adagents-catalog-only": "node --test --test-force-exit --test-timeout=30000 tests/adagents-catalog-only.test.cjs",
"test:canonical-fixtures": "node tests/canonical-fixture-validation.test.cjs",
Expand Down
164 changes: 164 additions & 0 deletions scripts/check-normative-coverage.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* Normative-statement registry linter + coverage dashboard.
*
* The registry (static/registry/normative-statements/index.json) catalogs every
* security-relevant MUST/SHOULD and every security claim in the spec, tagged with
* where it is enforced. This script is the enforcement point that keeps the
* registry honest — schema validation alone can't tell "claims to be enforced"
* from "actually has a passing check behind it".
*
* Fails CI (exit 1) when:
* - an entry is malformed (missing required fields, bad id/source shape)
* - an entry has status=enforced but an empty enforced_by/demonstrated_by
* - an enforced_by/demonstrated_by path does not exist on disk
* - the enforced-fraction regresses below the ratchet floor (see --ratchet)
*
* Does NOT fail on status=gap with no enforcement — that is the honest starting
* state the registry exists to make visible.
*
* See specs/spec-anti-drift.md.
*/

const fs = require('fs');
const path = require('path');

const ROOT = path.resolve(__dirname, '..');
const REGISTRY = path.join(ROOT, 'static/registry/normative-statements/index.json');

const args = process.argv.slice(2);
const asJson = args.includes('--json');
const ratchetIdx = args.indexOf('--ratchet');
const ratchet = ratchetIdx !== -1 ? Number.parseFloat(args[ratchetIdx + 1]) : null;
// --min-enforced is the one-way ratchet: it counts ENFORCED statements, not a
// fraction, so honestly cataloguing a new `gap` (which grows the denominator)
// never fails the gate — only losing an enforced statement does.
const minIdx = args.indexOf('--min-enforced');
const minEnforced = minIdx !== -1 ? Number.parseInt(args[minIdx + 1], 10) : null;

const ID_RE = /^NS-[A-Z]+-[0-9]{3}$/;
const SOURCE_RE = /^(docs|static)\/.+:[0-9]+$/;
const LINK_RE = /^(docs|static|scripts|tests|server|specs)\/.+/;
const LEVELS = new Set(['MUST', 'MUST NOT', 'SHOULD', 'SHOULD NOT', 'MAY']);
const LAYERS = new Set([
'schema', 'lint', 'conformance-vector', 'executable-snippet',
'operator-responsibility', 'unenforceable-by-design', 'UNASSIGNED',
]);
const STATUSES = new Set(['enforced', 'gap', 'operator', 'by-design']);
const CLASSES = new Set([
'signing', 'governance', 'identity', 'isolation', 'idempotency',
'privacy', 'creative', 'signals', 'transport',
]);

function fail(msg) {
console.error(`✗ ${msg}`);
process.exitCode = 1;
}

function linkExists(rel) {
// A link may point at file:line or a plain path; strip a trailing :NN if present.
const filePart = rel.replace(/:[0-9]+$/, '');
const resolved = path.resolve(ROOT, filePart);
// Defense in depth: refuse to probe outside the repo even if a link slips a
// `..` past LINK_RE (the prefix anchor already blocks leading `..`).
if (resolved !== ROOT && !resolved.startsWith(ROOT + path.sep)) return false;
return fs.existsSync(resolved);
}

if (!fs.existsSync(REGISTRY)) {
fail(`registry not found at ${path.relative(ROOT, REGISTRY)}`);
process.exit(1);
}

let entries;
try {
entries = JSON.parse(fs.readFileSync(REGISTRY, 'utf8'));
} catch (e) {
fail(`registry is not valid JSON: ${e.message}`);
process.exit(1);
}
if (!Array.isArray(entries)) {
fail('registry must be a JSON array of entries');
process.exit(1);
}

const seenIds = new Set();
let hardErrors = 0;

for (const [i, e] of entries.entries()) {
const where = e && e.id ? e.id : `entry[${i}]`;
const bad = (m) => { fail(`${where}: ${m}`); hardErrors++; };

if (!e || typeof e !== 'object') { bad('not an object'); continue; }
if (!ID_RE.test(e.id || '')) bad(`id must match ${ID_RE}`);
if (seenIds.has(e.id)) bad('duplicate id'); else seenIds.add(e.id);
if (e.type !== 'normative-statement' && e.type !== 'claim') bad('type must be normative-statement|claim');
if (!CLASSES.has(e.class)) bad(`class invalid (got ${JSON.stringify(e.class)})`);
if (typeof e.statement !== 'string' || e.statement.length < 10) bad('statement too short/missing');
if (!SOURCE_RE.test(e.source || '')) bad(`source must be file:line (got ${JSON.stringify(e.source)})`);
if (!LEVELS.has(e.level)) bad('level invalid');
if (!LAYERS.has(e.enforcement_layer)) bad('enforcement_layer invalid');
if (!STATUSES.has(e.status)) bad('status invalid');

const links = e.type === 'claim' ? e.demonstrated_by : e.enforced_by;
const linkField = e.type === 'claim' ? 'demonstrated_by' : 'enforced_by';

if (e.status === 'enforced') {
if (!Array.isArray(links) || links.length === 0) {
bad(`status=enforced requires a non-empty ${linkField}`);
} else {
for (const l of links) {
if (!LINK_RE.test(l)) bad(`${linkField} path has bad shape: ${l}`);
else if (!linkExists(l)) bad(`${linkField} points at a missing file: ${l}`);
}
}
}
}

// Coverage dashboard
const byStatus = {};
const byClass = {};
for (const e of entries) {
byStatus[e.status] = (byStatus[e.status] || 0) + 1;
byClass[e.class] = byClass[e.class] || { total: 0, enforced: 0 };
byClass[e.class].total++;
if (e.status === 'enforced') byClass[e.class].enforced++;
}
const total = entries.length;
const enforced = byStatus.enforced || 0;
const gaps = byStatus.gap || 0;
const enforcedFraction = total ? enforced / total : 1;

if (asJson) {
console.log(JSON.stringify({ total, byStatus, byClass, enforcedFraction, hardErrors }, null, 2));
} else {
console.log('\nNormative-statement coverage');
console.log('============================');
console.log(`total statements : ${total}`);
console.log(`enforced : ${enforced} (${(enforcedFraction * 100).toFixed(1)}%)`);
console.log(`gaps (drift) : ${gaps}`);
console.log(`operator/by-design: ${(byStatus.operator || 0) + (byStatus['by-design'] || 0)}`);
console.log('\nby class (enforced/total):');
for (const [cls, c] of Object.entries(byClass).sort()) {
console.log(` ${cls.padEnd(12)} ${c.enforced}/${c.total}`);
}
if (gaps > 0) {
console.log('\nopen gaps:');
for (const e of entries.filter((x) => x.status === 'gap')) {
console.log(` ${e.id} ${e.source} ${e.finding_ref || ''}`);
}
}
}

if (ratchet !== null && enforcedFraction < ratchet) {
fail(`enforced fraction ${(enforcedFraction * 100).toFixed(1)}% is below ratchet floor ${(ratchet * 100).toFixed(1)}%`);
}
if (minEnforced !== null && enforced < minEnforced) {
fail(`enforced count ${enforced} is below the one-way floor of ${minEnforced} — an enforced statement was lost`);
}

if (process.exitCode === 1) {
console.error(`\n✗ normative-coverage check failed (${hardErrors} entry error(s))`);
} else if (!asJson) {
console.log('\n✓ registry is well-formed; enforced entries all have live links');
}
Loading
Loading