diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f42e70..7c76bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,102 @@ All notable changes to Writmint will land here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] — 2026-05-15 + +A **breaking** release. The public API splits its vocabulary into an outer +identity layer (the *capability* — the manifest as a unit of governance) +and an inner permission layer (the individual grants the manifest +declares). Adds manifest hardening that runs at submit-time so an agent +gets a structured rejection before approval, not after. + +### Breaking changes + +#### Outer identity layer renamed: feature → capability + +The manifest itself, the lifecycle around it, and the audit envelope all +move to *capability* vocabulary. + +| v0.1 | v0.2 | +|---|---| +| `FeatureManifest` | `CapabilityManifest` | +| `MemoryFeatureStore` | `MemoryCapabilityStore` | +| `FeatureStore` interface | `CapabilityStore` | +| `FeatureRecord` | `CapabilityRecord` | +| `FeatureStatus` | `CapabilityStatus` | +| `ApproveInput.featureId` | `ApproveInput.capabilityId` | +| `AuditEvent.featureId` / `featureVersionHash` | `AuditEvent.capabilityId` / `capabilityVersionHash` | +| `AuditEventKind` value `feature_emit` | `capability_emit` (and `capability_call` / `capability_denied`) | +| error code `approval.unknown_feature` | `approval.unknown_capability` | +| `src/feature-manifest.ts` | `src/capability-manifest.ts` | + +#### Inner permission layer renamed: capabilities[] → permissions[] + +Inside the manifest, what v0.1 called *capabilities* (one per permission +grant) is now *permissions*. The error vocabulary follows. + +| v0.1 | v0.2 | +|---|---| +| `CapabilityManifest.capabilities[]` (was on FeatureManifest) | `permissions[]` | +| `ActionManifest.capabilities[]` | `permissions[]` | +| `CapabilityError` class | `PermissionError` | +| broker envelope field `capabilityId` (inner) | `permissionId` | +| `AuditTransport.emit({capabilityId,…})` | `AuditTransport.emit({permissionId,…})` | +| `createFeatureCapabilityRegistry()` | `createPermissionRegistry()` | +| `src/capabilities.ts` | `src/permissions.ts` | +| `capability.*` error codes (`capability.denied`, `capability.network.host_denied`, `capability.storage.write_denied`, `capability.undeclared`, `capability.action.unknown`, `capability.audit.no_transport`, …) | `permission.*` (same suffixes) | +| `manifest.capabilities.type` validator code | `manifest.permissions.type` | +| `action.capability_ref.type` / `.unknown` | `action.permission_ref.type` / `.unknown` | + +The outer `capabilityId` on `AuditEvent` is the manifest identity; the +inner `permissionId` (also on `AuditEvent`, and on the broker emit +envelope) is the specific permission entry the event came through. Both +fields ride on every event. + +#### Hash shift + +`hashManifest()` is unchanged in algorithm, but every shipped manifest +will produce a different `versionHash` under v0.2 because the renamed +object keys (`permissions[]`, etc.) are part of the canonical hash input. +**Re-submit and re-approve any manifest carried over from v0.1.** + +### Added + +- **`hardenManifest()`** (`src/capability-manifest.ts`) — runs after + structural validation. Enforces five strictness rules that an approver + would otherwise have to check by eye: + - `permission.reason.too_short` — every `reason` must be ≥ 5 words. + - `action.description.too_short` — every action `description` must be + ≥ 5 words. + - `permission.network.host_wildcard` — no `*` in any allowed host. + - `permission.storage.scope_wildcard` — no `*` in any storage scope. + - `permission.reason.no_action_ref` (warning) — every permission's + reason should mention at least one action that references it. +- **`ApprovalLifecycle.submit()`** now runs `hardenManifest()` and throws + `ApprovalError` on the first hardening error. The new return shape + `SubmitResult` extends `CapabilityRecord` with a `warnings: + ManifestWarning[]` field carrying non-blocking signals (e.g. the + no-action-ref warning). +- 13 new tests covering each hardening rule (positive + negative) and + `submit()` wiring. Total test count: **737 → 750**. +- README rewritten around a *show-by-failing* opener: the agent writes a + manifest with a wildcard host, `submit()` rejects it with a verified + structured error, the agent fixes and resubmits. Locks the tagline: + *"Writmint is a verifier for capabilities an author can't author past."* + +### Changed + +- `fixtures/suspicious-transaction-triage/manifest.ts` — every + `permission.reason` now opens with "Used by ``…" so the + fixture passes hardening with **0 errors, 0 warnings**. + +### Notes + +- Pre-stable. Public API surface may change before v1.0. +- Requires Node ≥ 22. +- All 24 demo phases still pass. + +[0.2.0]: https://github.com/razukc/writmint/releases/tag/v0.2.0 + ## [0.1.0] — 2026-05-01 Initial release. Five pillars + the canonical triage demo. diff --git a/README.md b/README.md index f43eb8d..cc36edb 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,154 @@ # Writmint -**An issuance authority for AI-authored features.** +**Writmint is a verifier for capabilities an author can't author past.** -Writmint is a TypeScript runtime for shipping features that an AI agent wrote, into a system you don't want to break. A feature defined against Writmint can be validated, reviewed, and executed safely — with full visibility into what it does, what it touches, and what it produced — without trusting the author. +You let an AI agent write a capability. Writmint refuses to let the capability do anything its manifest doesn't account for — and tells the agent exactly what to fix when it tries. -> Status: **v0.1.0 — early.** API surface is stable enough for the demo below, not yet stable enough to depend on. Issues and feedback welcome. +> Status: **v0.2.0 — early.** API surface is stable enough for the demo below, not yet stable enough to depend on. Issues and feedback welcome. --- -## Why this exists +## Show, by failing -If you let an agent author features that run inside a regulated system — bank ops, healthcare workflows, insurance triage — you need answers to four questions before any feature reaches production, and you need them every time: +An agent writes a capability that reads a flagged transaction and writes back a decision. Here is what Writmint does, in four beats. -1. *What does this feature claim to do?* -2. *What is it allowed to touch?* -3. *Did a human approve this exact version?* -4. *Can we replay what it actually did?* +### Beat 1 — The agent writes a manifest -Writmint is the smallest set of primitives that makes those four questions answerable, automatically, for every feature an agent ships. +```ts +const manifest: CapabilityManifest = { + schemaVersion: 1, + id: 'ops.fraud.triage', + version: '0.1.0', + title: 'Triage', + description: 'Pull a flagged transaction and record the analyst decision.', + permissions: [ + { + type: 'network', + id: 'core.transactions', + hosts: ['*.internal'], // <-- agent took a shortcut here + methods: ['GET'], + reason: 'Used by triage.load_alert to read the flagged transaction.', + }, + // ... + ], + actions: [/* ... */], + implementation: { type: 'module', entry: './impl.js' }, +}; +``` ---- +### Beat 2 — Submit, and watch it fail -## The five pillars +```ts +const lifecycle = new ApprovalLifecycle(new MemoryCapabilityStore()); +lifecycle.submit(manifest); +// throws ApprovalError { +// code: 'permission.network.host_wildcard', +// where: '$.permissions[0].hosts[0]', +// expected: 'exact hostname (no wildcards)', +// actual: '"*.internal"', +// fixHint: 'List each allowed hostname explicitly; wildcards make the call surface impossible to audit.', +// } +``` -Each pillar is one file in `src/`. Together they cover the lifecycle from "agent drafts a feature" to "audit reproduces it six months later." +The agent reads the structured error — `code`, `where`, `expected`, `actual`, `fixHint` — and edits the manifest. It is the same shape for every failure Writmint can produce. No string-parsing, no judgment call about what went wrong. -### 1. Feature Manifest — the declarative contract +### Beat 3 — Fix, resubmit, approve -A feature is a `FeatureManifest`: a typed object that names the feature, lists every capability it needs, declares its config schema, and describes its actions. The agent authors the manifest first; the runtime only executes what the manifest declares. +```ts +manifest.permissions[0].hosts = ['core-banking.internal']; + +const submitted = lifecycle.submit(manifest); +// submitted.warnings === [] — hardening clean +// submitted.versionHash === 'sha256:…' — bound to this exact manifest + +const approved = lifecycle.approve({ + capabilityId: manifest.id, + versionHash: submitted.versionHash, + approvedBy: 'reviewer@you.example', +}); +``` + +Approval is bound to the SHA-256 of the manifest. Change one byte after this and the hash no longer matches — `assertRunnable` refuses to execute and asks for re-approval. + +### Beat 4 — Run it; every call is brokered ```ts -const manifest: FeatureManifest = { - schemaVersion: 1, - id: 'ops.fraud.suspicious_transaction_triage', - version: '0.1.0', - title: 'Suspicious Transaction Triage', - capabilities: [ - { type: 'network', id: 'core.transactions', - hosts: ['core-banking.internal'], methods: ['GET'], - reason: 'Read the flagged transaction and customer record.' }, - { type: 'network', id: 'cases.write', - hosts: ['cases.internal'], methods: ['POST'], - reason: 'Write the analyst decision. Destructive; gated separately.' }, - // ...storage, ui, audit, clock capabilities - ], - // ...config schema, actions -}; +const active = lifecycle.activate(manifest.id); +const sink = new MemoryAuditSink(); +const transports = buildAuditingTransports({ + base: hostTransports, + manifest, + record: active, + sink, +}); + +const registry = createPermissionRegistry(manifest, transports); +const scope = registry.forAction('triage.load_alert'); +const net = scope.cap('core.transactions') as NetworkBroker; + +await net.request({ url: 'https://core-banking.internal/tx/42', method: 'GET' }); +// allowed. + +await net.request({ url: 'https://evil.example.com/x', method: 'GET' }); +// throws PermissionError { +// code: 'permission.network.host_denied', +// ... +// } ``` -Source: [`src/feature-manifest.ts`](./src/feature-manifest.ts). +Every brokered call lands in `sink.events`. The recording is enough to replay the whole run later, deterministically, against the same inputs — and detect divergence in strict call order. + +That is the entire loop: **declare → submit (hardened) → approve (hashed) → run (brokered) → replay (recorded)**. An agent who cannot read these errors cannot ship a capability past Writmint. + +--- + +## The five pillars + +Each pillar is one file in `src/`. + +### 1. Capability manifest — the declarative contract + +A `CapabilityManifest` names the capability, lists every permission it needs (network hosts, storage scopes, ui, clock, audit), and declares its actions. The runtime only executes what the manifest declares. -### 2. Capabilities — the broker boundary +`hardenManifest()` runs after structural validation. It enforces strictness checks that an approver would otherwise have to enforce by eye: reasons must be ≥5 words, action descriptions must be ≥5 words, no wildcards in network hosts or storage scopes, and (as a warning) every permission's reason should name an action that uses it. -A feature cannot make a network call, write to storage, render UI, read the clock, or emit audit events except through a *broker* the runtime hands it. Brokers are scoped to the capabilities the manifest declared. A feature that wants to POST to a host it didn't declare gets a `CapabilityError` with a structured payload pointing at the exact violation. +Source: [`src/capability-manifest.ts`](./src/capability-manifest.ts). -This is the line: **the manifest is the only surface area the feature has on the host system.** +### 2. Permissions — the broker boundary -Source: [`src/capabilities.ts`](./src/capabilities.ts). +A capability cannot make a network call, write to storage, render UI, read the clock, or emit audit events except through a *broker* the runtime hands it. Brokers are scoped to the permissions the manifest declared. Anything else throws a `PermissionError` with a structured payload pointing at the exact violation. + +This is the line: **the manifest is the only surface area the capability has on the host system.** + +Source: [`src/permissions.ts`](./src/permissions.ts). ### 3. Structured errors — every failure has a fix-hint -Every error Writmint throws carries a structured payload: +Every error Writmint throws carries the same shape: ```ts { - code: 'CAPABILITY_HOST_NOT_DECLARED', + code: 'permission.network.host_denied', where: 'NetworkBroker.request', expected: ['core-banking.internal'], actual: 'evil.example.com', - fixHint: 'Add evil.example.com to capability core.transactions.hosts, or route this call through a different capability.' + fixHint: 'Add evil.example.com to permission core.transactions.hosts, or route this call through a different permission.', } ``` -The shape is the same for every error, so an agent reading a failure has a deterministic place to look for what went wrong and what to change. No string-parsing. +An agent reading a failure has a deterministic place to look for what went wrong and what to change. Source: [`src/errors.ts`](./src/errors.ts). ### 4. Replay — every execution is reproducible -Writmint records execution at the transport seam: every network response, storage read, clock value, and emitted audit event. The recording is enough to replay the feature deterministically against the same inputs. Replays detect divergence in strict order — if the recorded run made calls A → B → C and the replay tries to make A → C, it stops at C with a structured error pointing at the missing B. +Writmint records execution at the transport seam: every network response, storage read, clock value, and emitted audit event. The recording is enough to replay the capability deterministically against the same inputs. Replays detect divergence in strict order — if the recorded run made calls A → B → C and the replay tries to make A → C, it stops at C with a structured error pointing at the missing B. Source: [`src/replay.ts`](./src/replay.ts). ### 5. Approval — hash-bound, lifecycle-tracked, audited -A feature moves through a lifecycle: `draft → submitted → approved → active → revoked`. The approval is bound to a SHA-256 hash of the manifest. Modify the manifest by one byte after approval, the hash no longer matches, and the runtime refuses to execute. Every transition emits an audit event; sensitive paths declared in the manifest are redacted before they reach the audit sink. +A capability moves through a lifecycle: `draft → submitted → approved → active → revoked`. The approval is bound to a SHA-256 hash of the manifest. Modify the manifest by one byte after approval and the runtime refuses to execute. Every transition emits an audit event; sensitive paths declared in the manifest are redacted before they reach the audit sink. Source: [`src/approval.ts`](./src/approval.ts). @@ -96,7 +160,7 @@ Source: [`src/approval.ts`](./src/approval.ts). - **Phase H — failure-path correctness:** chaos-transport induces a timeout mid-flow. The runtime throws a structured error, the audit trail captures the pre-failure work, and a replay against the recording reproduces the failure deterministically. -737 tests pass across `tests/{unit,integration,property}`. +750 tests pass across `tests/{unit,integration,property}`. --- @@ -112,79 +176,80 @@ Requires Node ≥ 22. ## Run the demo -The triage demo exercises every pillar against in-memory transports, so it runs anywhere Node runs — no banking system required. - ```bash git clone https://github.com/razukc/writmint cd writmint npm install -npm run demo:smoke # capability enforcement, ~12 assertions +npm run demo:smoke # permission enforcement, ~12 assertions npm run demo:approval # approval lifecycle: draft → submitted → approved → revoked npm run demo:replay # record a run, then replay it deterministically npm run demo:e2e # full end-to-end (phases A–H, including failure-path correctness) npm run demo # all four, in order ``` -Each script is a single TypeScript file in `fixtures/suspicious-transaction-triage/`. Read them alongside the output — they're the most concrete documentation Writmint has at v0.1. +Each script is a single TypeScript file in `fixtures/suspicious-transaction-triage/`. Read them alongside the output — they are the most concrete documentation Writmint has at v0.2. --- ## Quickstart -This is the smallest end-to-end exercise of the five pillars: declare a -manifest, submit it, approve it against its hash, activate it, and execute -an action through capability-scoped brokers with audit and replay attached. - ```ts import { ApprovalLifecycle, - MemoryFeatureStore, + MemoryCapabilityStore, MemoryAuditSink, buildAuditingTransports, - createFeatureCapabilityRegistry, - type FeatureManifest, + createPermissionRegistry, + type CapabilityManifest, type HostTransports, type NetworkBroker, type AuditBroker, } from 'writmint'; -// 1. Declare what the feature is and what it's allowed to touch. -const manifest: FeatureManifest = { +// 1. Declare what the capability is and what it's allowed to touch. +const manifest: CapabilityManifest = { schemaVersion: 1, id: 'example.greet', version: '0.1.0', title: 'Greet', description: 'Fetch a greeting and emit an audit event.', - capabilities: [ + permissions: [ + { + type: 'network', + id: 'greetings.api', + hosts: ['greet.example.com'], + methods: ['GET'], + reason: 'Used by greet.fetch to read the greeting of the day.', + }, { - type: 'network', id: 'greetings.api', - hosts: ['greet.example.com'], methods: ['GET'], - reason: 'Read the greeting of the day.', + type: 'audit', + id: 'audit.greet', + reason: 'Used by greet.fetch to record the greeting that was served.', }, - { type: 'audit', id: 'audit.greet', reason: 'Record the greeting.' }, ], actions: [ { id: 'greet.fetch', - description: 'Fetch the greeting of the day.', + description: 'Fetch the greeting of the day from upstream API.', input: { type: 'object', properties: {} }, output: { type: 'object', properties: { text: { type: 'string' } } }, - capabilities: ['greetings.api', 'audit.greet'], + permissions: ['greetings.api', 'audit.greet'], + handler: 'fetch', }, ], implementation: { type: 'module', entry: 'greet.ts' }, }; // 2. Submit, approve (binding to the manifest's SHA-256), activate. -const store = new ApprovalLifecycle(new MemoryFeatureStore()); -const submitted = store.submit(manifest); -const approved = store.approve({ - featureId: manifest.id, - versionHash: submitted.versionHash, // change one byte → hash mismatch → rejected +const lifecycle = new ApprovalLifecycle(new MemoryCapabilityStore()); +const submitted = lifecycle.submit(manifest); // throws on hardening errors; surfaces warnings +const approved = lifecycle.approve({ + capabilityId: manifest.id, + versionHash: submitted.versionHash, // change one byte → hash mismatch → rejected approvedBy: 'reviewer@you.example', }); -const active = store.activate(manifest.id); +const active = lifecycle.activate(manifest.id); // 3. Wire host transports. In production these hit your real systems. const sink = new MemoryAuditSink(); @@ -195,30 +260,26 @@ const transports: HostTransports = { }, }, storage: { async get() { return null; }, async put() {}, async delete() {}, async list() { return []; } }, - audit: { emit() {} }, // wrapped below + audit: { emit() {} }, clock: { now: () => Date.now() }, }; -// 4. Wrap transports so every capability call is audited and bound to this approval. -const auditing = buildAuditingTransports({ - base: transports, manifest, record: active, sink, -}); +// 4. Wrap transports so every brokered call is audited and bound to this approval. +const auditing = buildAuditingTransports({ base: transports, manifest, record: active, sink }); -// 5. Get capability-scoped brokers for one action. Calls outside the manifest throw. -const reg = createFeatureCapabilityRegistry(manifest, auditing); -const scope = reg.forAction('greet.fetch'); +// 5. Get permission-scoped brokers for one action. Calls outside the manifest throw. +const registry = createPermissionRegistry(manifest, auditing); +const scope = registry.forAction('greet.fetch'); const net = scope.cap('greetings.api') as NetworkBroker; -const auditBroker = scope.cap('audit.greet') as AuditBroker; +const audit = scope.cap('audit.greet') as AuditBroker; const res = await net.request({ url: 'https://greet.example.com/today', method: 'GET' }); -auditBroker.emit('greet.fetched', { text: (res.body as { text: string }).text }); +audit.emit('greet.fetched', { text: (res.body as { text: string }).text }); console.log(sink.events.length, 'audit events captured'); ``` -For a full demo — including replay, destructive-action gating, redaction, -and chaos-transport failure-path correctness — read -[`fixtures/suspicious-transaction-triage/`](./fixtures/suspicious-transaction-triage/). +For the full picture — including replay, destructive-action gating, redaction, and chaos-transport failure-path correctness — read [`fixtures/suspicious-transaction-triage/`](./fixtures/suspicious-transaction-triage/). --- @@ -226,33 +287,17 @@ and chaos-transport failure-path correctness — read ``` src/ - feature-manifest.ts Pillar 1 — the declarative contract - capabilities.ts Pillar 2 — broker boundary, scoped enforcement + capability-manifest.ts Pillar 1 — declarative contract + hardenManifest() + permissions.ts Pillar 2 — broker boundary, scoped enforcement errors.ts Pillar 3 — structured errors with fix-hints replay.ts Pillar 4 — record/replay over the transport seam approval.ts Pillar 5 — hash-bound approval lifecycle + audit - runtime.ts internal: feature/plugin runtime the pillars stand on - action-engine.ts internal: action dispatch, retries, timeouts - event-bus.ts internal: typed pub/sub - execution-recorder.ts internal: in-process execution traces - plugin-loader.ts internal: filesystem-based plugin discovery - plugin-registry.ts internal: plugin lifecycle (register, dispose, hot-swap) - runtime-context.ts internal: per-feature context passed to handlers - screen-registry.ts internal: UI screen registration surface - service-registry.ts internal: service-locator registry - ui-bridge.ts internal: UI-provider abstraction (terminal, web, etc.) - performance.ts internal: timing/instrumentation primitives - test-utils.ts internal: in-tree test helpers - types.ts internal: shared types (Logger, ConsoleLogger, etc.) - index.ts, index.browser.ts public entry points (Node and browser builds) - plugins/ internal: bundled plugins (Config, FeatureFlag) - fixtures/ suspicious-transaction-triage/ the canonical end-to-end demo - manifest.ts the FeatureManifest under test + manifest.ts the CapabilityManifest under test end-to-end.ts full demo, phases A–H (npm run demo:e2e) - smoke.ts capability enforcement only (npm run demo:smoke) + smoke.ts permission enforcement (npm run demo:smoke) approval-smoke.ts approval lifecycle (npm run demo:approval) replay-smoke.ts record + replay (npm run demo:replay) chaos-transport.ts fault-injection wrapper used by phase H @@ -260,10 +305,10 @@ fixtures/ tests/ unit/ per-file unit tests integration/ cross-subsystem behavior - property/ fast-check property tests (12 invariants) + property/ fast-check property tests ``` -737 tests across 47 files, all passing. +750 tests across 48 files, all passing. --- @@ -277,6 +322,6 @@ Writmint targets enterprise environments; the patent grant in Apache-2.0 is inte ## Status and roadmap -v0.1 ships the five pillars and the triage demo. What it does not yet ship: stable public API guarantees, broad documentation, additional demos, packaging for non-Node hosts. Those land in v0.x as the API surface settles. +v0.2 ships the five pillars, manifest hardening, and the triage demo. What it does not yet ship: stable public API guarantees, broad documentation, additional demos, packaging for non-Node hosts. Those land in v0.x as the API surface settles. If you are evaluating Writmint for a regulated-ops use case, open an issue — the demo is the best current answer to "is this real?" diff --git a/fixtures/suspicious-transaction-triage/approval-smoke.ts b/fixtures/suspicious-transaction-triage/approval-smoke.ts index 06aa4e2..d8dd2e6 100644 --- a/fixtures/suspicious-transaction-triage/approval-smoke.ts +++ b/fixtures/suspicious-transaction-triage/approval-smoke.ts @@ -2,19 +2,19 @@ import { manifest } from './manifest.js'; import { ApprovalLifecycle, ApprovalError, - MemoryFeatureStore, + MemoryCapabilityStore, MemoryAuditSink, buildAuditingTransports, emitLifecycleEvent, hashManifest, } from '../../src/approval.js'; import { - createFeatureCapabilityRegistry, + createPermissionRegistry, type HostTransports, type NetworkBroker, type StorageBroker, type AuditBroker, -} from '../../src/capabilities.js'; +} from '../../src/permissions.js'; type Result = { name: string; ok: boolean; detail: string }; const results: Result[] = []; @@ -44,7 +44,7 @@ const baseTransports: HostTransports = { async function run(): Promise { // 1. submit - const store = new MemoryFeatureStore(); + const store = new MemoryCapabilityStore(); const sink = new MemoryAuditSink(); const lifecycle = new ApprovalLifecycle(store); const submitted = lifecycle.submit(manifest); @@ -69,7 +69,7 @@ async function run(): Promise { // 3. approve without destructiveApprovedBy fails (manifest has destructive action) try { lifecycle.approve({ - featureId: manifest.id, + capabilityId: manifest.id, versionHash: submitted.versionHash, approvedBy: 'reviewer@bank', }); @@ -85,7 +85,7 @@ async function run(): Promise { // 4. approve with mismatched hash is rejected try { lifecycle.approve({ - featureId: manifest.id, + capabilityId: manifest.id, versionHash: 'hdeadbeef', approvedBy: 'reviewer@bank', destructiveApprovedBy: 'compliance@bank', @@ -101,7 +101,7 @@ async function run(): Promise { // 5. approve correctly, then activate const approved = lifecycle.approve({ - featureId: manifest.id, + capabilityId: manifest.id, versionHash: submitted.versionHash, approvedBy: 'reviewer@bank', destructiveApprovedBy: 'compliance@bank', @@ -110,7 +110,7 @@ async function run(): Promise { const active = lifecycle.activate(manifest.id); emitLifecycleEvent(sink, active, 'active', 'reviewer@bank'); check( - 'feature is now active', + 'capability is now active', active.status === 'active' && active.approvedBy === 'reviewer@bank', `status=${active.status}` ); @@ -136,12 +136,12 @@ async function run(): Promise { lifecycle.revoke(manifest.id); try { lifecycle.assertRunnable(manifest.id, manifest.actions[0]); - check('revoked feature cannot run', false, 'no throw'); + check('revoked capability cannot run', false, 'no throw'); } catch (e) { if (e instanceof ApprovalError && e.structured.code === 'approval.not_runnable') { - check('revoked feature cannot run', true, e.structured.code); + check('revoked capability cannot run', true, e.structured.code); } else { - check('revoked feature cannot run', false, String(e)); + check('revoked capability cannot run', false, String(e)); } } @@ -155,13 +155,13 @@ async function run(): Promise { `${reSubmitted.versionHash} !== ${submitted.versionHash}` ); - // 10. audit sink: capability calls are emitted with feature id and version hash, redaction is applied. + // 10. audit sink: capability calls are emitted with capability id and version hash, redaction is applied. const sink2 = new MemoryAuditSink(); - const store2 = new MemoryFeatureStore(); + const store2 = new MemoryCapabilityStore(); const lifecycle2 = new ApprovalLifecycle(store2); const sub2 = lifecycle2.submit(manifest); const apr2 = lifecycle2.approve({ - featureId: manifest.id, + capabilityId: manifest.id, versionHash: sub2.versionHash, approvedBy: 'reviewer@bank', destructiveApprovedBy: 'compliance@bank', @@ -174,7 +174,7 @@ async function run(): Promise { record: apr2, sink: sink2, }); - const reg = createFeatureCapabilityRegistry(manifest, auditing); + const reg = createPermissionRegistry(manifest, auditing); const load = reg.forAction('triage.load_alert'); const txNet = load.cap('core.transactions') as NetworkBroker; await txNet.request({ url: 'https://core-banking.internal/tx/abc', method: 'GET' }); @@ -186,15 +186,15 @@ async function run(): Promise { customer: { taxId: '123-45-6789', name: 'Jane Doe', email: 'jane@example.com' }, }); - const allHaveFeatureId = sink2.events.every((e) => e.featureId === manifest.id); + const allHaveCapabilityId = sink2.events.every((e) => e.capabilityId === manifest.id); check( - 'every audit event carries featureId and versionHash', - allHaveFeatureId && sink2.events.every((e) => e.featureVersionHash === sub2.versionHash), + 'every audit event carries capabilityId and versionHash', + allHaveCapabilityId && sink2.events.every((e) => e.capabilityVersionHash === sub2.versionHash), `${sink2.events.length} events` ); - const featureEmit = sink2.events.find((e) => e.kind === 'feature_emit'); - const payload = featureEmit?.payload as { payload: { customer: { taxId: string; email: string } } }; + const capabilityEmit = sink2.events.find((e) => e.kind === 'capability_emit'); + const payload = capabilityEmit?.payload as { payload: { customer: { taxId: string; email: string } } }; check( 'redact paths replace declared PII fields with [REDACTED]', payload?.payload?.customer?.taxId === '[REDACTED]' && diff --git a/fixtures/suspicious-transaction-triage/chaos-transport.ts b/fixtures/suspicious-transaction-triage/chaos-transport.ts index c138e39..8b44c9a 100644 --- a/fixtures/suspicious-transaction-triage/chaos-transport.ts +++ b/fixtures/suspicious-transaction-triage/chaos-transport.ts @@ -3,7 +3,7 @@ import type { NetworkRequest, NetworkResponse, NetworkTransport, -} from '../../src/capabilities.js'; +} from '../../src/permissions.js'; export type ChaosFault = | { kind: 'timeout'; afterMs: number } diff --git a/fixtures/suspicious-transaction-triage/end-to-end.ts b/fixtures/suspicious-transaction-triage/end-to-end.ts index a3d957c..a076635 100644 --- a/fixtures/suspicious-transaction-triage/end-to-end.ts +++ b/fixtures/suspicious-transaction-triage/end-to-end.ts @@ -1,24 +1,24 @@ import { manifest } from './manifest.js'; -import { validateFeatureManifest } from '../../src/feature-manifest.js'; +import { validateCapabilityManifest } from '../../src/capability-manifest.js'; import { - createFeatureCapabilityRegistry, - CapabilityError, + createPermissionRegistry, + PermissionError, type HostTransports, type NetworkBroker, type StorageBroker, type AuditBroker, -} from '../../src/capabilities.js'; +} from '../../src/permissions.js'; import { record, replay, ReplayDivergenceError } from '../../src/replay.js'; import { withChaos, ChaosTimeoutError } from './chaos-transport.js'; import { ApprovalLifecycle, ApprovalError, - MemoryFeatureStore, + MemoryCapabilityStore, MemoryAuditSink, buildAuditingTransports, emitLifecycleEvent, } from '../../src/approval.js'; -import type { FeatureRecord } from '../../src/approval.js'; +import type { CapabilityRecord } from '../../src/approval.js'; interface Step { name: string; @@ -103,7 +103,7 @@ async function runFeature( alertId: string, decision: 'clear' | 'escalate' | 'block' ): Promise<{ caseId: string; watchlistScore: number }> { - const reg = createFeatureCapabilityRegistry(manifest, transports); + const reg = createPermissionRegistry(manifest, transports); const load = reg.forAction('triage.load_alert'); const txNet = load.cap('core.transactions') as NetworkBroker; @@ -156,7 +156,7 @@ async function runFeatureMutated( alertId: string ): Promise { // Same start, but skips the watchlist step entirely — must surface as a divergence on replay. - const reg = createFeatureCapabilityRegistry(manifest, transports); + const reg = createPermissionRegistry(manifest, transports); const load = reg.forAction('triage.load_alert'); const txNet = load.cap('core.transactions') as NetworkBroker; await txNet.request({ url: `https://core-banking.internal/tx/${alertId}`, method: 'GET' }); @@ -174,11 +174,11 @@ async function runFeatureMutated( } async function withFreshState(): Promise<{ - store: MemoryFeatureStore; + store: MemoryCapabilityStore; sink: MemoryAuditSink; lifecycle: ApprovalLifecycle; }> { - const store = new MemoryFeatureStore(); + const store = new MemoryCapabilityStore(); const sink = new MemoryAuditSink(); const lifecycle = new ApprovalLifecycle(store); return { store, sink, lifecycle }; @@ -188,7 +188,7 @@ async function main(): Promise { const banner = (s: string): void => console.log('\n=== ' + s + ' ==='); banner('PHASE A — Manifest validation (Pillar 1)'); - const validation = validateFeatureManifest(manifest); + const validation = validateCapabilityManifest(manifest); log( 'manifest is valid (Pillar 1)', validation.valid && validation.errors.length === 0, @@ -198,14 +198,14 @@ async function main(): Promise { // negative: a tampered manifest must fail with structured errors. const broken = JSON.parse(JSON.stringify(manifest)); - broken.actions[0].capabilities.push('does.not.exist'); + broken.actions[0].permissions.push('does.not.exist'); delete broken.implementation; - const brokenResult = validateFeatureManifest(broken); + const brokenResult = validateCapabilityManifest(broken); const codes = new Set(brokenResult.errors.map((e) => e.code)); log( 'tampered manifest rejected with structured errors (Pillars 1+3)', !brokenResult.valid && - codes.has('action.capability_ref.unknown') && + codes.has('action.permission_ref.unknown') && codes.has('manifest.implementation.type'), `codes: ${[...codes].join(', ')}`, [1, 3] @@ -221,7 +221,7 @@ async function main(): Promise { } catch (e) { log( 'unsubmitted feature cannot run', - e instanceof ApprovalError && e.structured.code === 'approval.unknown_feature', + e instanceof ApprovalError && e.structured.code === 'approval.unknown_capability', e instanceof ApprovalError ? e.structured.code : String(e), [5] ); @@ -233,7 +233,7 @@ async function main(): Promise { // approver tries with wrong hash try { lifecycle.approve({ - featureId: manifest.id, + capabilityId: manifest.id, versionHash: 'h00000000', approvedBy: 'reviewer@bank', destructiveApprovedBy: 'compliance@bank', @@ -251,7 +251,7 @@ async function main(): Promise { // approver forgets destructive lane try { lifecycle.approve({ - featureId: manifest.id, + capabilityId: manifest.id, versionHash: submitted.versionHash, approvedBy: 'reviewer@bank', }); @@ -266,7 +266,7 @@ async function main(): Promise { } const approved = lifecycle.approve({ - featureId: manifest.id, + capabilityId: manifest.id, versionHash: submitted.versionHash, approvedBy: 'reviewer@bank', destructiveApprovedBy: 'compliance@bank', @@ -318,7 +318,7 @@ async function main(): Promise { ); // capability denial must be live and structured - const reg = createFeatureCapabilityRegistry(manifest, auditing); + const reg = createPermissionRegistry(manifest, auditing); const load = reg.forAction('triage.load_alert'); try { load.cap('cases.write'); @@ -326,15 +326,15 @@ async function main(): Promise { } catch (e) { log( 'cross-action capability bleed is denied (Pillars 2+3)', - e instanceof CapabilityError && e.structured.code === 'capability.denied', - e instanceof CapabilityError ? e.structured.code : String(e), + e instanceof PermissionError && e.structured.code === 'permission.denied', + e instanceof PermissionError ? e.structured.code : String(e), [2, 3] ); } banner('PHASE D — Audit redaction (Pillar 5)'); - const featureEmits = sink.events.filter((e) => e.kind === 'feature_emit'); - const alertLoaded = featureEmits.find( + const capabilityEmits = sink.events.filter((e) => e.kind === 'capability_emit'); + const alertLoaded = capabilityEmits.find( (e) => (e.payload as { name: string }).name === 'triage.alert_loaded' ); const aPayload = alertLoaded?.payload as @@ -351,12 +351,12 @@ async function main(): Promise { const allTagged = sink.events.every( (e) => - e.featureId === manifest.id && - e.featureVersionHash === active.versionHash && + e.capabilityId === manifest.id && + e.capabilityVersionHash === active.versionHash && e.approvedBy === 'reviewer@bank' || e.kind === 'lifecycle' ); log( - 'every audit event carries featureId + versionHash (and approver post-approval)', + 'every audit event carries capabilityId + versionHash (and approver post-approval)', allTagged, `${sink.events.length} events`, [5] @@ -430,7 +430,7 @@ async function main(): Promise { ); // Old approval is now stale; trying to run any action throws. - let staleRecord: FeatureRecord | null = null; + let staleRecord: CapabilityRecord | null = null; try { staleRecord = lifecycle.assertRunnable(manifest.id, manifest.actions[0]); log('prior approval becomes stale after manifest change', false, 'no throw', [5]); @@ -448,7 +448,7 @@ async function main(): Promise { banner('PHASE G — Revoke kills further runs (Pillar 5)'); const reApproved = lifecycle.approve({ - featureId: manifest.id, + capabilityId: manifest.id, versionHash: reSub.versionHash, approvedBy: 'reviewer@bank', destructiveApprovedBy: 'compliance@bank', @@ -471,12 +471,12 @@ async function main(): Promise { banner('PHASE H — Failure-path correctness with chaos transport (Pillars 2+3+4+5)'); // Set up: a fresh active feature record + a fresh sink so audit assertions are scoped. - const phaseHStore = new MemoryFeatureStore(); + const phaseHStore = new MemoryCapabilityStore(); const phaseHSink = new MemoryAuditSink(); const phaseHLifecycle = new ApprovalLifecycle(phaseHStore); const phaseHSubmitted = phaseHLifecycle.submit(manifest); phaseHLifecycle.approve({ - featureId: manifest.id, + capabilityId: manifest.id, versionHash: phaseHSubmitted.versionHash, approvedBy: 'reviewer@bank', destructiveApprovedBy: 'compliance@bank', @@ -518,7 +518,7 @@ async function main(): Promise { ); // (b) Auditability — the run that failed produced an audit trail up to the failure point. - const phaseHEmits = phaseHSink.events.filter((e) => e.kind === 'feature_emit'); + const phaseHEmits = phaseHSink.events.filter((e) => e.kind === 'capability_emit'); const sawAlertLoaded = phaseHEmits.some( (e) => (e.payload as { name: string }).name === 'triage.alert_loaded' ); @@ -589,7 +589,7 @@ async function main(): Promise { captured.push({ index: idx, kind: 'audit.emit', - input: { capabilityId: event.capabilityId, name: event.name, payload: event.payload }, + input: { permissionId: event.permissionId, name: event.name, payload: event.payload }, output: undefined, threw: false, }); diff --git a/fixtures/suspicious-transaction-triage/manifest.ts b/fixtures/suspicious-transaction-triage/manifest.ts index 848fd27..384ccca 100644 --- a/fixtures/suspicious-transaction-triage/manifest.ts +++ b/fixtures/suspicious-transaction-triage/manifest.ts @@ -1,6 +1,6 @@ -import type { FeatureManifest } from '../../src/feature-manifest.js'; +import type { CapabilityManifest } from '../../src/capability-manifest.js'; -export const manifest: FeatureManifest = { +export const manifest: CapabilityManifest = { schemaVersion: 1, id: 'ops.fraud.suspicious_transaction_triage', version: '0.1.0', @@ -8,14 +8,14 @@ export const manifest: FeatureManifest = { description: 'Triage workflow for an analyst reviewing a flagged transaction: pulls the transaction and customer record, account history, and a sanctions/watchlist check; presents a multi-step review screen; writes the analyst decision back to the case management system.', - capabilities: [ + permissions: [ { type: 'network', id: 'core.transactions', hosts: ['core-banking.internal'], methods: ['GET'], reason: - 'Read the flagged transaction and the customer record needed to evaluate it.', + 'Used by triage.load_alert to read the flagged transaction and the customer record needed to evaluate it.', }, { type: 'network', @@ -23,7 +23,7 @@ export const manifest: FeatureManifest = { hosts: ['core-banking.internal'], methods: ['GET'], reason: - 'Read recent account history for the customer to assess whether the flagged transaction is anomalous.', + 'Used by triage.load_alert to read recent account history and judge whether the flagged transaction is anomalous.', }, { type: 'network', @@ -31,7 +31,7 @@ export const manifest: FeatureManifest = { hosts: ['watchlist.vendor.example.com'], methods: ['POST'], reason: - 'Submit the counterparty for a sanctions/watchlist check. Third-party vendor; isolated capability so the call surface is auditable.', + 'Used by triage.run_watchlist_check to submit the counterparty to a third-party sanctions vendor; isolated so the call surface is auditable.', }, { type: 'network', @@ -39,7 +39,7 @@ export const manifest: FeatureManifest = { hosts: ['cases.internal'], methods: ['POST'], reason: - 'Write the analyst decision back to the case-management system of record. Destructive; gated separately at approval time.', + 'Used by triage.submit_decision to write the analyst decision back to the case-management system; destructive and gated separately at approval time.', }, { type: 'storage', @@ -47,24 +47,24 @@ export const manifest: FeatureManifest = { scope: 'tenant/risk-thresholds', mode: 'read', reason: - 'Read tenant-scoped risk thresholds (e.g. high-value cutoff, watchlist score floor) used to label the alert.', + 'Used by triage.load_alert to read tenant-scoped risk thresholds (high-value cutoff, watchlist score floor) used to label the alert.', }, { type: 'ui', id: 'review.screen', - reason: 'Render the multi-step review screen for the analyst.', + reason: 'Render the multi-step review screen for the analyst to walk through.', }, { type: 'audit', id: 'audit.triage', reason: - 'Emit structured audit events for compliance: every external read, the decision, and the write-back. Required by the regulated-ops compliance regime.', + 'Used by triage.load_alert, triage.run_watchlist_check, and triage.submit_decision to emit structured audit events for the regulated-ops compliance regime.', }, { type: 'clock', id: 'clock.deterministic', reason: - 'Stamp decisions with a wall-clock time. Routed through the runtime clock capability so replays are deterministic.', + 'Used by triage.submit_decision to stamp the analyst decision with a deterministic wall-clock time so replays remain reproducible.', }, ], @@ -123,7 +123,7 @@ export const manifest: FeatureManifest = { }, }, }, - capabilities: ['core.transactions', 'core.account_history', 'tenant.thresholds', 'audit.triage'], + permissions: ['core.transactions', 'core.account_history', 'tenant.thresholds', 'audit.triage'], handler: 'loadAlert', redact: ['customer.taxId', 'customer.dob', 'customer.address', 'customer.email'], }, @@ -161,7 +161,7 @@ export const manifest: FeatureManifest = { }, }, }, - capabilities: ['sanctions.watchlist', 'audit.triage'], + permissions: ['sanctions.watchlist', 'audit.triage'], handler: 'runWatchlistCheck', redact: ['counterparty.accountRef'], }, @@ -200,7 +200,7 @@ export const manifest: FeatureManifest = { }, }, }, - capabilities: ['cases.write', 'clock.deterministic', 'audit.triage'], + permissions: ['cases.write', 'clock.deterministic', 'audit.triage'], destructive: true, handler: 'submitDecision', }, diff --git a/fixtures/suspicious-transaction-triage/replay-smoke.ts b/fixtures/suspicious-transaction-triage/replay-smoke.ts index 444db59..d1aca7d 100644 --- a/fixtures/suspicious-transaction-triage/replay-smoke.ts +++ b/fixtures/suspicious-transaction-triage/replay-smoke.ts @@ -1,11 +1,11 @@ import { manifest } from './manifest.js'; import { - createFeatureCapabilityRegistry, + createPermissionRegistry, type HostTransports, type NetworkBroker, type StorageBroker, type AuditBroker, -} from '../../src/capabilities.js'; +} from '../../src/permissions.js'; import { record, replay, @@ -60,7 +60,7 @@ const baseTransports: HostTransports = { }; async function runFeatureV1(transports: HostTransports): Promise<{ decision: string }> { - const reg = createFeatureCapabilityRegistry(manifest, transports); + const reg = createPermissionRegistry(manifest, transports); const load = reg.forAction('triage.load_alert'); const txNet = load.cap('core.transactions') as NetworkBroker; @@ -100,7 +100,7 @@ async function runFeatureV1(transports: HostTransports): Promise<{ decision: str async function runFeatureV2_extraCall(transports: HostTransports): Promise<{ decision: string }> { // Replays v1 fully, then makes one more call past the end of the recording. const result = await runFeatureV1(transports); - const reg = createFeatureCapabilityRegistry(manifest, transports); + const reg = createPermissionRegistry(manifest, transports); const sub = reg.forAction('triage.submit_decision'); const auditX = sub.cap('audit.triage') as AuditBroker; auditX.emit('triage.extra_event', { note: 'beyond-recording' }); @@ -108,7 +108,7 @@ async function runFeatureV2_extraCall(transports: HostTransports): Promise<{ dec } async function runFeatureV2_inputDrift(transports: HostTransports): Promise<{ decision: string }> { - const reg = createFeatureCapabilityRegistry(manifest, transports); + const reg = createPermissionRegistry(manifest, transports); const load = reg.forAction('triage.load_alert'); const txNet = load.cap('core.transactions') as NetworkBroker; await txNet.request({ url: 'https://core-banking.internal/tx/DIFFERENT', method: 'GET' }); @@ -116,7 +116,7 @@ async function runFeatureV2_inputDrift(transports: HostTransports): Promise<{ de } async function runFeatureV2_fewerCalls(transports: HostTransports): Promise<{ decision: string }> { - const reg = createFeatureCapabilityRegistry(manifest, transports); + const reg = createPermissionRegistry(manifest, transports); const load = reg.forAction('triage.load_alert'); const txNet = load.cap('core.transactions') as NetworkBroker; await txNet.request({ url: 'https://core-banking.internal/tx/abc', method: 'GET' }); diff --git a/fixtures/suspicious-transaction-triage/smoke.ts b/fixtures/suspicious-transaction-triage/smoke.ts index a5679ea..d1a58d1 100644 --- a/fixtures/suspicious-transaction-triage/smoke.ts +++ b/fixtures/suspicious-transaction-triage/smoke.ts @@ -1,12 +1,12 @@ import { manifest } from './manifest.js'; import { - createFeatureCapabilityRegistry, - CapabilityError, + createPermissionRegistry, + PermissionError, type HostTransports, type NetworkBroker, type StorageBroker, type AuditBroker, -} from '../../src/capabilities.js'; +} from '../../src/permissions.js'; type Result = { name: string; ok: boolean; detail: string }; const results: Result[] = []; @@ -20,10 +20,10 @@ async function expectThrows(name: string, fn: () => Promise | unknown, await fn(); check(name, false, `expected throw [${code}], got success`); } catch (e) { - if (e instanceof CapabilityError && e.structured.code === code) { + if (e instanceof PermissionError && e.structured.code === code) { check(name, true, `threw [${code}] as expected`); } else { - const got = e instanceof CapabilityError ? e.structured.code : String(e); + const got = e instanceof PermissionError ? e.structured.code : String(e); check(name, false, `expected [${code}], got ${got}`); } } @@ -34,13 +34,13 @@ async function expectOk(name: string, fn: () => Promise | unknown): Pro await fn(); check(name, true, 'ok'); } catch (e) { - const got = e instanceof CapabilityError ? e.structured.code : String(e); + const got = e instanceof PermissionError ? e.structured.code : String(e); check(name, false, `unexpected throw: ${got}`); } } const networkLog: { capabilityHint: string; url: string; method: string }[] = []; -const auditLog: { capabilityId: string; name: string }[] = []; +const auditLog: { permissionId: string; name: string }[] = []; const transports: HostTransports = { network: { @@ -65,7 +65,7 @@ const transports: HostTransports = { }, audit: { emit(ev) { - auditLog.push({ capabilityId: ev.capabilityId, name: ev.name }); + auditLog.push({ permissionId: ev.permissionId, name: ev.name }); }, }, clock: { @@ -73,7 +73,7 @@ const transports: HostTransports = { }, }; -const reg = createFeatureCapabilityRegistry(manifest, transports); +const reg = createPermissionRegistry(manifest, transports); async function run(): Promise { const loadAlert = reg.forAction('triage.load_alert'); @@ -90,7 +90,7 @@ async function run(): Promise { async () => { loadAlert.cap('sanctions.watchlist'); }, - 'capability.denied' + 'permission.denied' ); await expectThrows( @@ -98,7 +98,7 @@ async function run(): Promise { async () => { loadAlert.cap('cases.write'); }, - 'capability.denied' + 'permission.denied' ); await expectThrows( @@ -107,7 +107,7 @@ async function run(): Promise { const net = loadAlert.cap('core.account_history') as NetworkBroker; await net.request({ url: 'https://elsewhere.internal/anything', method: 'GET' }); }, - 'capability.network.host_denied' + 'permission.network.host_denied' ); await expectThrows( @@ -116,7 +116,7 @@ async function run(): Promise { const net = watchlist.cap('sanctions.watchlist') as NetworkBroker; await net.request({ url: 'https://watchlist.vendor.example.com/check', method: 'GET' }); }, - 'capability.network.method_denied' + 'permission.network.method_denied' ); await expectOk('watchlist POST allowed', async () => { @@ -134,7 +134,7 @@ async function run(): Promise { const store = loadAlert.cap('tenant.thresholds') as StorageBroker; await store.put('high-value', 10000); }, - 'capability.storage.write_denied' + 'permission.storage.write_denied' ); await expectOk('storage read-only capability allows reads', async () => { @@ -152,7 +152,7 @@ async function run(): Promise { async () => { loadAlert.cap('made.up'); }, - 'capability.undeclared' + 'permission.undeclared' ); await expectThrows( @@ -160,7 +160,7 @@ async function run(): Promise { async () => { reg.forAction('not.a.real.action'); }, - 'capability.action.unknown' + 'permission.action.unknown' ); await expectOk('submit_decision can write back via cases.write', async () => { diff --git a/package-lock.json b/package-lock.json index dda67c3..eb9b7cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "writmint", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "writmint", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { "fast-glob": "^3.3.3" diff --git a/package.json b/package.json index ac4cea8..f239553 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "writmint", - "version": "0.1.0", - "description": "An issuance authority for AI-authored features. Declare a feature manifest, scope its capabilities, replay its execution, and require human approval before it runs in production.", + "version": "0.2.0", + "description": "A verifier for capabilities an author can't author past. Declare a capability manifest, scope its permissions, harden it before approval, replay its execution, and require a hash-bound human approval before it runs.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -33,14 +33,14 @@ }, "keywords": [ "ai-agents", - "feature-manifest", - "capability-scoping", + "capability-manifest", + "permission-scoping", "approval-workflow", + "manifest-hardening", "audit", "replay", "governance", "enterprise", - "browser-extension", "typescript" ], "author": { diff --git a/src/approval.ts b/src/approval.ts index 661f129..1e687a8 100644 --- a/src/approval.ts +++ b/src/approval.ts @@ -1,4 +1,9 @@ -import type { FeatureManifest, ActionManifest } from './feature-manifest.js'; +import { + hardenManifest, + type CapabilityManifest, + type ActionManifest, + type ManifestWarning, +} from './capability-manifest.js'; import type { HostTransports, AuditTransport, @@ -7,45 +12,45 @@ import type { ClockTransport, NetworkRequest, NetworkResponse, -} from './capabilities.js'; +} from './permissions.js'; import { formatStructuredError, type StructuredError } from './errors.js'; -export type FeatureStatus = +export type CapabilityStatus = | 'draft' | 'submitted' | 'approved' | 'active' | 'revoked'; -export interface FeatureRecord { - manifest: FeatureManifest; +export interface CapabilityRecord { + manifest: CapabilityManifest; versionHash: string; - status: FeatureStatus; + status: CapabilityStatus; approvedBy: string | null; destructiveApprovedBy: string | null; approvedAtHash: string | null; } -export interface FeatureStore { - put(record: FeatureRecord): void; - get(featureId: string): FeatureRecord | null; - list(): FeatureRecord[]; - remove(featureId: string): void; +export interface CapabilityStore { + put(record: CapabilityRecord): void; + get(capabilityId: string): CapabilityRecord | null; + list(): CapabilityRecord[]; + remove(capabilityId: string): void; } -export class MemoryFeatureStore implements FeatureStore { - private byId = new Map(); - put(record: FeatureRecord): void { +export class MemoryCapabilityStore implements CapabilityStore { + private byId = new Map(); + put(record: CapabilityRecord): void { this.byId.set(record.manifest.id, record); } - get(featureId: string): FeatureRecord | null { - return this.byId.get(featureId) ?? null; + get(capabilityId: string): CapabilityRecord | null { + return this.byId.get(capabilityId) ?? null; } - list(): FeatureRecord[] { + list(): CapabilityRecord[] { return [...this.byId.values()]; } - remove(featureId: string): void { - this.byId.delete(featureId); + remove(capabilityId: string): void { + this.byId.delete(capabilityId); } } @@ -59,21 +64,29 @@ export class ApprovalError extends Error { } export interface ApproveInput { - featureId: string; + capabilityId: string; versionHash: string; approvedBy: string; destructiveApprovedBy?: string; } +export interface SubmitResult extends CapabilityRecord { + warnings: ManifestWarning[]; +} + export class ApprovalLifecycle { - constructor(private store: FeatureStore) {} + constructor(private store: CapabilityStore) {} - submit(manifest: FeatureManifest): FeatureRecord { + submit(manifest: CapabilityManifest): SubmitResult { + const { errors, warnings } = hardenManifest(manifest); + if (errors.length > 0) { + throw new ApprovalError(errors[0]); + } const versionHash = hashManifest(manifest); const existing = this.store.get(manifest.id); if (existing && (existing.status === 'approved' || existing.status === 'active')) { if (existing.versionHash !== versionHash) { - const next: FeatureRecord = { + const next: CapabilityRecord = { manifest, versionHash, status: 'submitted', @@ -82,10 +95,10 @@ export class ApprovalLifecycle { approvedAtHash: null, }; this.store.put(next); - return next; + return { ...next, warnings }; } } - const record: FeatureRecord = { + const record: CapabilityRecord = { manifest, versionHash, status: 'submitted', @@ -94,15 +107,15 @@ export class ApprovalLifecycle { approvedAtHash: null, }; this.store.put(record); - return record; + return { ...record, warnings }; } - approve(input: ApproveInput): FeatureRecord { - const record = this.requireRecord(input.featureId); + approve(input: ApproveInput): CapabilityRecord { + const record = this.requireRecord(input.capabilityId); if (record.versionHash !== input.versionHash) { throw new ApprovalError({ code: 'approval.hash_mismatch', - where: `feature[${input.featureId}].approve`, + where: `capability[${input.capabilityId}].approve`, expected: `versionHash ${record.versionHash}`, actual: input.versionHash, fixHint: @@ -112,24 +125,24 @@ export class ApprovalLifecycle { if (record.status !== 'submitted') { throw new ApprovalError({ code: 'approval.bad_state', - where: `feature[${input.featureId}].status`, + where: `capability[${input.capabilityId}].status`, expected: 'submitted', actual: record.status, - fixHint: 'Only submitted features can be approved. Submit the manifest first.', + fixHint: 'Only submitted capabilities can be approved. Submit the manifest first.', }); } const hasDestructive = record.manifest.actions.some((a) => a.destructive === true); if (hasDestructive && !input.destructiveApprovedBy) { throw new ApprovalError({ code: 'approval.destructive_required', - where: `feature[${input.featureId}].approve`, - expected: 'destructiveApprovedBy set (feature has destructive actions)', + where: `capability[${input.capabilityId}].approve`, + expected: 'destructiveApprovedBy set (capability has destructive actions)', actual: 'destructiveApprovedBy missing', fixHint: - 'This feature has destructive actions; provide destructiveApprovedBy in addition to approvedBy.', + 'This capability has destructive actions; provide destructiveApprovedBy in addition to approvedBy.', }); } - const next: FeatureRecord = { + const next: CapabilityRecord = { ...record, status: 'approved', approvedBy: input.approvedBy, @@ -140,56 +153,56 @@ export class ApprovalLifecycle { return next; } - activate(featureId: string): FeatureRecord { - const record = this.requireRecord(featureId); + activate(capabilityId: string): CapabilityRecord { + const record = this.requireRecord(capabilityId); if (record.status !== 'approved') { throw new ApprovalError({ code: 'approval.activate_blocked', - where: `feature[${featureId}].status`, + where: `capability[${capabilityId}].status`, expected: 'approved', actual: record.status, fixHint: - 'Only approved features can be activated. Move the feature through draft → submitted → approved first.', + 'Only approved capabilities can be activated. Move the capability through draft → submitted → approved first.', }); } - const next: FeatureRecord = { ...record, status: 'active' }; + const next: CapabilityRecord = { ...record, status: 'active' }; this.store.put(next); return next; } - revoke(featureId: string): FeatureRecord { - const record = this.requireRecord(featureId); - const next: FeatureRecord = { ...record, status: 'revoked' }; + revoke(capabilityId: string): CapabilityRecord { + const record = this.requireRecord(capabilityId); + const next: CapabilityRecord = { ...record, status: 'revoked' }; this.store.put(next); return next; } - assertRunnable(featureId: string, action: ActionManifest): FeatureRecord { - const record = this.requireRecord(featureId); + assertRunnable(capabilityId: string, action: ActionManifest): CapabilityRecord { + const record = this.requireRecord(capabilityId); if (record.status !== 'active') { throw new ApprovalError({ code: 'approval.not_runnable', - where: `feature[${featureId}].status`, + where: `capability[${capabilityId}].status`, expected: 'active', actual: record.status, fixHint: - 'Feature is not active. It must be approved and activated before any action can run.', + 'Capability is not active. It must be approved and activated before any action can run.', }); } if (record.approvedAtHash !== record.versionHash) { throw new ApprovalError({ code: 'approval.stale', - where: `feature[${featureId}].approvedAtHash`, + where: `capability[${capabilityId}].approvedAtHash`, expected: record.versionHash, actual: record.approvedAtHash ?? 'null', fixHint: - 'The feature has been changed since it was approved. Re-submit and re-approve.', + 'The capability has been changed since it was approved. Re-submit and re-approve.', }); } if (action.destructive === true && !record.destructiveApprovedBy) { throw new ApprovalError({ code: 'approval.destructive_not_approved', - where: `feature[${featureId}].action[${action.id}]`, + where: `capability[${capabilityId}].action[${action.id}]`, expected: 'destructiveApprovedBy set on the approval', actual: 'destructiveApprovedBy null', fixHint: @@ -199,15 +212,15 @@ export class ApprovalLifecycle { return record; } - private requireRecord(featureId: string): FeatureRecord { - const r = this.store.get(featureId); + private requireRecord(capabilityId: string): CapabilityRecord { + const r = this.store.get(capabilityId); if (!r) { throw new ApprovalError({ - code: 'approval.unknown_feature', - where: `feature[${featureId}]`, - expected: 'a feature submitted to the store', + code: 'approval.unknown_capability', + where: `capability[${capabilityId}]`, + expected: 'a capability submitted to the store', actual: 'no record', - fixHint: 'Submit the feature manifest before referencing it.', + fixHint: 'Submit the capability manifest before referencing it.', }); } return r; @@ -218,14 +231,14 @@ export type AuditEventKind = | 'capability_call' | 'capability_denied' | 'lifecycle' - | 'feature_emit'; + | 'capability_emit'; export interface AuditEvent { at: number; - featureId: string; - featureVersionHash: string; + capabilityId: string; + capabilityVersionHash: string; actionId: string | null; - capabilityId: string | null; + permissionId: string | null; kind: AuditEventKind; payload: unknown; approvedBy: string | null; @@ -244,8 +257,8 @@ export class MemoryAuditSink implements AuditSink { export interface AuditingTransports { base: HostTransports; - manifest: FeatureManifest; - record: FeatureRecord; + manifest: CapabilityManifest; + record: CapabilityRecord; sink: AuditSink; } @@ -255,17 +268,17 @@ export function buildAuditingTransports({ record, sink, }: AuditingTransports): HostTransports { - const featureId = manifest.id; + const capabilityId = manifest.id; const versionHash = record.versionHash; const approvedBy = record.approvedBy; const actionsById = new Map(); for (const a of manifest.actions) actionsById.set(a.id, a); - const emit = (event: Omit): void => { + const emit = (event: Omit): void => { sink.write({ ...event, - featureId, - featureVersionHash: versionHash, + capabilityId, + capabilityVersionHash: versionHash, approvedBy, }); }; @@ -277,7 +290,7 @@ export function buildAuditingTransports({ emit({ at: Date.now(), actionId: null, - capabilityId: null, + permissionId: null, kind: 'capability_call', payload: { kind: 'network.request', input: redactNetworkInput(input), status: out.status }, }); @@ -286,7 +299,7 @@ export function buildAuditingTransports({ emit({ at: Date.now(), actionId: null, - capabilityId: null, + permissionId: null, kind: 'capability_denied', payload: { kind: 'network.request', input: redactNetworkInput(input), error: String(e) }, }); @@ -301,7 +314,7 @@ export function buildAuditingTransports({ emit({ at: Date.now(), actionId: null, - capabilityId: null, + permissionId: null, kind: 'capability_call', payload: { kind: 'storage.get', scope, key }, }); @@ -312,7 +325,7 @@ export function buildAuditingTransports({ emit({ at: Date.now(), actionId: null, - capabilityId: null, + permissionId: null, kind: 'capability_call', payload: { kind: 'storage.put', scope, key }, }); @@ -322,7 +335,7 @@ export function buildAuditingTransports({ emit({ at: Date.now(), actionId: null, - capabilityId: null, + permissionId: null, kind: 'capability_call', payload: { kind: 'storage.delete', scope, key }, }); @@ -332,7 +345,7 @@ export function buildAuditingTransports({ emit({ at: Date.now(), actionId: null, - capabilityId: null, + permissionId: null, kind: 'capability_call', payload: { kind: 'storage.list', scope, prefix }, }); @@ -350,11 +363,11 @@ export function buildAuditingTransports({ const redacted = redactPayload(ev.payload, action ? actionsById.get(action) : undefined); sink.write({ at: ev.at, - featureId, - featureVersionHash: versionHash, + capabilityId, + capabilityVersionHash: versionHash, actionId: action, - capabilityId: ev.capabilityId, - kind: 'feature_emit', + permissionId: ev.permissionId, + kind: 'capability_emit', payload: { name: ev.name, payload: redacted }, approvedBy, }); @@ -367,16 +380,16 @@ export function buildAuditingTransports({ export function emitLifecycleEvent( sink: AuditSink, - record: FeatureRecord, - to: FeatureStatus, + record: CapabilityRecord, + to: CapabilityStatus, actor: string | null ): void { sink.write({ at: Date.now(), - featureId: record.manifest.id, - featureVersionHash: record.versionHash, + capabilityId: record.manifest.id, + capabilityVersionHash: record.versionHash, actionId: null, - capabilityId: null, + permissionId: null, kind: 'lifecycle', payload: { transitionedTo: to, actor }, approvedBy: record.approvedBy, @@ -423,7 +436,7 @@ function redactNetworkInput(input: NetworkRequest): unknown { return { url, method }; } -export function hashManifest(manifest: FeatureManifest): string { +export function hashManifest(manifest: CapabilityManifest): string { const canonical = canonicalize(manifest); return 'sha256:' + sha256Hex(canonical); } diff --git a/src/feature-manifest.ts b/src/capability-manifest.ts similarity index 64% rename from src/feature-manifest.ts rename to src/capability-manifest.ts index 3a5478a..bfd1143 100644 --- a/src/feature-manifest.ts +++ b/src/capability-manifest.ts @@ -1,10 +1,10 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; -export type CapabilityId = string; +export type PermissionId = string; -export type NetworkCapability = { +export type NetworkPermission = { type: 'network'; - id: CapabilityId; + id: PermissionId; hosts: string[]; methods?: HttpMethod[]; reason: string; @@ -12,40 +12,40 @@ export type NetworkCapability = { export type StorageMode = 'read' | 'write' | 'readwrite'; -export type StorageCapability = { +export type StoragePermission = { type: 'storage'; - id: CapabilityId; + id: PermissionId; scope: string; mode: StorageMode; reason: string; }; -export type UiCapability = { +export type UiPermission = { type: 'ui'; - id: CapabilityId; + id: PermissionId; reason: string; }; -export type ClockCapability = { +export type ClockPermission = { type: 'clock'; - id: CapabilityId; + id: PermissionId; reason: string; }; -export type AuditCapability = { +export type AuditPermission = { type: 'audit'; - id: CapabilityId; + id: PermissionId; reason: string; }; -export type Capability = - | NetworkCapability - | StorageCapability - | UiCapability - | ClockCapability - | AuditCapability; +export type Permission = + | NetworkPermission + | StoragePermission + | UiPermission + | ClockPermission + | AuditPermission; -export type CapabilityType = Capability['type']; +export type PermissionType = Permission['type']; export type JSONSchema = { type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'; @@ -71,7 +71,7 @@ export interface ActionManifest { description: string; input: JSONSchema; output: JSONSchema; - capabilities: CapabilityId[]; + permissions: PermissionId[]; destructive?: boolean; handler: string; redact?: string[]; @@ -84,33 +84,33 @@ export interface ScreenManifest { steps?: string[]; } -export interface FeatureEvents { +export interface CapabilityEvents { emits?: string[]; subscribes?: string[]; } -export interface FeatureImplementation { +export interface CapabilityImplementation { type: 'module'; entry: string; } -export interface FeatureManifest { +export interface CapabilityManifest { schemaVersion: 1; id: string; version: string; title: string; description: string; - capabilities: Capability[]; + permissions: Permission[]; config?: Record; actions: ActionManifest[]; screens?: ScreenManifest[]; - events?: FeatureEvents; - implementation: FeatureImplementation; + events?: CapabilityEvents; + implementation: CapabilityImplementation; } -export const FEATURE_MANIFEST_SCHEMA_VERSION = 1 as const; +export const CAPABILITY_MANIFEST_SCHEMA_VERSION = 1 as const; -export const CAPABILITY_TYPES: readonly CapabilityType[] = [ +export const PERMISSION_TYPES: readonly PermissionType[] = [ 'network', 'storage', 'ui', @@ -133,16 +133,119 @@ export const STORAGE_MODES: readonly StorageMode[] = ['read', 'write', 'readwrit import type { StructuredError } from './errors.js'; export type ManifestError = StructuredError; +export type ManifestWarning = StructuredError; export interface ManifestValidationResult { valid: boolean; errors: ManifestError[]; } +export interface HardeningResult { + errors: ManifestError[]; + warnings: ManifestWarning[]; +} + +const MIN_REASON_WORDS = 5; +const MIN_DESCRIPTION_WORDS = 5; + +function wordCount(s: string): number { + return s.split(/\s+/).filter((w) => w.length > 0).length; +} + +export function hardenManifest(m: CapabilityManifest): HardeningResult { + const errors: ManifestError[] = []; + const warnings: ManifestWarning[] = []; + + m.permissions.forEach((perm, i) => { + const where = `$.permissions[${i}]`; + + if (typeof perm.reason === 'string' && wordCount(perm.reason) < MIN_REASON_WORDS) { + errors.push({ + code: 'permission.reason.too_short', + where: `${where}.reason`, + expected: `reason with at least ${MIN_REASON_WORDS} words`, + actual: `${wordCount(perm.reason)} word(s)`, + fixHint: + 'Expand the reason to explain what this permission is used for and which action needs it.', + }); + } + + if (perm.type === 'network') { + perm.hosts.forEach((h, hi) => { + if (typeof h === 'string' && h.includes('*')) { + errors.push({ + code: 'permission.network.host_wildcard', + where: `${where}.hosts[${hi}]`, + expected: 'exact hostname (no wildcards)', + actual: `"${h}"`, + fixHint: + 'List each allowed hostname explicitly; wildcards make the call surface impossible to audit.', + }); + } + }); + } + + if (perm.type === 'storage') { + if (typeof perm.scope === 'string' && perm.scope.includes('*')) { + errors.push({ + code: 'permission.storage.scope_wildcard', + where: `${where}.scope`, + expected: 'exact scope (no wildcards)', + actual: `"${perm.scope}"`, + fixHint: + 'Name the storage scope explicitly; wildcards expand the blast radius beyond what the manifest declares.', + }); + } + } + }); + + m.actions.forEach((action, i) => { + if (typeof action.description === 'string' && wordCount(action.description) < MIN_DESCRIPTION_WORDS) { + errors.push({ + code: 'action.description.too_short', + where: `$.actions[${i}].description`, + expected: `description with at least ${MIN_DESCRIPTION_WORDS} words`, + actual: `${wordCount(action.description)} word(s)`, + fixHint: + 'Describe what the action does, what it touches, and any side effects worth flagging to an approver.', + }); + } + }); + + const referencedBy = new Map(); + for (const action of m.actions) { + for (const permId of action.permissions) { + const list = referencedBy.get(permId) ?? []; + list.push(action.id); + referencedBy.set(permId, list); + } + } + + m.permissions.forEach((perm, i) => { + const refs = referencedBy.get(perm.id); + if (!refs || refs.length === 0) return; + if (typeof perm.reason !== 'string') return; + + const mentionsAny = refs.some((actionId) => perm.reason.includes(actionId)); + if (!mentionsAny) { + warnings.push({ + code: 'permission.reason.no_action_ref', + where: `$.permissions[${i}].reason`, + expected: `reason mentions at least one of: ${refs.join(', ')}`, + actual: `"${perm.reason}"`, + fixHint: + 'Name the action(s) that use this permission so an approver can trace each grant to a caller.', + }); + } + }); + + return { errors, warnings }; +} + const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/; const ID_RE = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; -export function validateFeatureManifest(input: unknown): ManifestValidationResult { +export function validateCapabilityManifest(input: unknown): ManifestValidationResult { const errors: ManifestError[] = []; const push = (e: ManifestError) => errors.push(e); @@ -155,7 +258,7 @@ export function validateFeatureManifest(input: unknown): ManifestValidationResul where: '$', expected: 'object', actual: typeOf(input), - fixHint: 'Provide a FeatureManifest object.', + fixHint: 'Provide a CapabilityManifest object.', }, ], }; @@ -163,13 +266,13 @@ export function validateFeatureManifest(input: unknown): ManifestValidationResul const m = input as Record; - if (m.schemaVersion !== FEATURE_MANIFEST_SCHEMA_VERSION) { + if (m.schemaVersion !== CAPABILITY_MANIFEST_SCHEMA_VERSION) { push({ code: 'manifest.schema_version', where: '$.schemaVersion', - expected: String(FEATURE_MANIFEST_SCHEMA_VERSION), + expected: String(CAPABILITY_MANIFEST_SCHEMA_VERSION), actual: String(m.schemaVersion), - fixHint: `Set schemaVersion to ${FEATURE_MANIFEST_SCHEMA_VERSION}.`, + fixHint: `Set schemaVersion to ${CAPABILITY_MANIFEST_SCHEMA_VERSION}.`, }); } @@ -178,17 +281,17 @@ export function validateFeatureManifest(input: unknown): ManifestValidationResul requireString(m, 'title', '$.title', push); requireString(m, 'description', '$.description', push); - const capabilityIds = new Set(); - if (!Array.isArray(m.capabilities)) { + const permissionIds = new Set(); + if (!Array.isArray(m.permissions)) { push({ - code: 'manifest.capabilities.type', - where: '$.capabilities', + code: 'manifest.permissions.type', + where: '$.permissions', expected: 'array', - actual: typeOf(m.capabilities), - fixHint: 'Set capabilities to an array (use [] if none, but actions cannot reference any).', + actual: typeOf(m.permissions), + fixHint: 'Set permissions to an array (use [] if none, but actions cannot reference any).', }); } else { - m.capabilities.forEach((cap, i) => validateCapability(cap, `$.capabilities[${i}]`, capabilityIds, push)); + m.permissions.forEach((p, i) => validatePermission(p, `$.permissions[${i}]`, permissionIds, push)); } if (m.config !== undefined) { @@ -222,11 +325,11 @@ export function validateFeatureManifest(input: unknown): ManifestValidationResul where: '$.actions', expected: 'at least one action', actual: 'empty array', - fixHint: 'A feature must declare at least one action; otherwise it has no behavior.', + fixHint: 'A capability must declare at least one action; otherwise it has no behavior.', }); } else { m.actions.forEach((a, i) => - validateAction(a, `$.actions[${i}]`, actionIds, capabilityIds, push) + validateAction(a, `$.actions[${i}]`, actionIds, permissionIds, push) ); } @@ -286,67 +389,67 @@ export function validateFeatureManifest(input: unknown): ManifestValidationResul return { valid: errors.length === 0, errors }; } -function validateCapability( - cap: unknown, +function validatePermission( + perm: unknown, where: string, seen: Set, push: (e: ManifestError) => void ): void { - if (!isPlainObject(cap)) { + if (!isPlainObject(perm)) { push({ - code: 'capability.not_object', + code: 'permission.not_object', where, expected: 'object', - actual: typeOf(cap), - fixHint: 'Each capability must be an object with type, id, reason.', + actual: typeOf(perm), + fixHint: 'Each permission must be an object with type, id, reason.', }); return; } - const c = cap as Record; - const type = c.type; - if (typeof type !== 'string' || !CAPABILITY_TYPES.includes(type as CapabilityType)) { + const p = perm as Record; + const type = p.type; + if (typeof type !== 'string' || !PERMISSION_TYPES.includes(type as PermissionType)) { push({ - code: 'capability.type', + code: 'permission.type', where: `${where}.type`, - expected: `one of ${CAPABILITY_TYPES.join(', ')}`, + expected: `one of ${PERMISSION_TYPES.join(', ')}`, actual: String(type), - fixHint: 'Use a supported capability type.', + fixHint: 'Use a supported permission type.', }); return; } - const idOk = requireString(c, 'id', `${where}.id`, push, ID_RE); - if (idOk && typeof c.id === 'string') { - if (seen.has(c.id)) { + const idOk = requireString(p, 'id', `${where}.id`, push, ID_RE); + if (idOk && typeof p.id === 'string') { + if (seen.has(p.id)) { push({ - code: 'capability.duplicate_id', + code: 'permission.duplicate_id', where: `${where}.id`, - expected: 'unique capability id', - actual: `duplicate "${c.id}"`, - fixHint: 'Each capability id must be unique within the manifest.', + expected: 'unique permission id', + actual: `duplicate "${p.id}"`, + fixHint: 'Each permission id must be unique within the manifest.', }); } else { - seen.add(c.id); + seen.add(p.id); } } - requireString(c, 'reason', `${where}.reason`, push); + requireString(p, 'reason', `${where}.reason`, push); if (type === 'network') { - if (!Array.isArray(c.hosts) || c.hosts.length === 0) { + if (!Array.isArray(p.hosts) || p.hosts.length === 0) { push({ - code: 'capability.network.hosts', + code: 'permission.network.hosts', where: `${where}.hosts`, expected: 'non-empty string array', - actual: typeOf(c.hosts), - fixHint: 'List the hostnames this capability is allowed to reach.', + actual: typeOf(p.hosts), + fixHint: 'List the hostnames this permission is allowed to reach.', }); } else { - c.hosts.forEach((h, i) => { + p.hosts.forEach((h, i) => { if (typeof h !== 'string' || h.length === 0) { push({ - code: 'capability.network.host_value', + code: 'permission.network.host_value', where: `${where}.hosts[${i}]`, expected: 'non-empty string', actual: typeOf(h), @@ -355,20 +458,20 @@ function validateCapability( } }); } - if (c.methods !== undefined) { - if (!Array.isArray(c.methods)) { + if (p.methods !== undefined) { + if (!Array.isArray(p.methods)) { push({ - code: 'capability.network.methods', + code: 'permission.network.methods', where: `${where}.methods`, expected: 'array of HTTP methods', - actual: typeOf(c.methods), + actual: typeOf(p.methods), fixHint: `Use a subset of ${HTTP_METHODS.join(', ')}.`, }); } else { - c.methods.forEach((mm, i) => { + p.methods.forEach((mm, i) => { if (typeof mm !== 'string' || !HTTP_METHODS.includes(mm as HttpMethod)) { push({ - code: 'capability.network.method_value', + code: 'permission.network.method_value', where: `${where}.methods[${i}]`, expected: `one of ${HTTP_METHODS.join(', ')}`, actual: String(mm), @@ -381,13 +484,13 @@ function validateCapability( } if (type === 'storage') { - requireString(c, 'scope', `${where}.scope`, push); - if (typeof c.mode !== 'string' || !STORAGE_MODES.includes(c.mode as StorageMode)) { + requireString(p, 'scope', `${where}.scope`, push); + if (typeof p.mode !== 'string' || !STORAGE_MODES.includes(p.mode as StorageMode)) { push({ - code: 'capability.storage.mode', + code: 'permission.storage.mode', where: `${where}.mode`, expected: `one of ${STORAGE_MODES.join(', ')}`, - actual: String(c.mode), + actual: String(p.mode), fixHint: 'Storage mode must be read, write, or readwrite.', }); } @@ -398,7 +501,7 @@ function validateAction( action: unknown, where: string, seen: Set, - capabilityIds: Set, + permissionIds: Set, push: (e: ManifestError) => void ): void { if (!isPlainObject(action)) { @@ -450,33 +553,33 @@ function validateAction( }); } - if (!Array.isArray(a.capabilities)) { + if (!Array.isArray(a.permissions)) { push({ - code: 'action.capabilities.type', - where: `${where}.capabilities`, - expected: 'array of capability ids', - actual: typeOf(a.capabilities), - fixHint: 'List the capability ids this action may use (use [] for pure actions).', + code: 'action.permissions.type', + where: `${where}.permissions`, + expected: 'array of permission ids', + actual: typeOf(a.permissions), + fixHint: 'List the permission ids this action may use (use [] for pure actions).', }); } else { - a.capabilities.forEach((capId, i) => { - if (typeof capId !== 'string') { + a.permissions.forEach((permId, i) => { + if (typeof permId !== 'string') { push({ - code: 'action.capability_ref.type', - where: `${where}.capabilities[${i}]`, + code: 'action.permission_ref.type', + where: `${where}.permissions[${i}]`, expected: 'string', - actual: typeOf(capId), - fixHint: 'Each entry must be a capability id declared in $.capabilities.', + actual: typeOf(permId), + fixHint: 'Each entry must be a permission id declared in $.permissions.', }); return; } - if (!capabilityIds.has(capId)) { + if (!permissionIds.has(permId)) { push({ - code: 'action.capability_ref.unknown', - where: `${where}.capabilities[${i}]`, - expected: 'a capability id declared in $.capabilities', - actual: `"${capId}"`, - fixHint: `Declare a capability with id "${capId}" or remove this reference.`, + code: 'action.permission_ref.unknown', + where: `${where}.permissions[${i}]`, + expected: 'a permission id declared in $.permissions', + actual: `"${permId}"`, + fixHint: `Declare a permission with id "${permId}" or remove this reference.`, }); } }); diff --git a/src/index.ts b/src/index.ts index 3d795b5..c3663c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,8 +70,8 @@ export type { } from './performance.js'; // The five pillars -export * from './feature-manifest.js'; -export * from './capabilities.js'; +export * from './capability-manifest.js'; +export * from './permissions.js'; export * from './approval.js'; export * from './replay.js'; export { diff --git a/src/capabilities.ts b/src/permissions.ts similarity index 60% rename from src/capabilities.ts rename to src/permissions.ts index 170be40..9ffd3a3 100644 --- a/src/capabilities.ts +++ b/src/permissions.ts @@ -1,22 +1,22 @@ import type { - Capability, - CapabilityId, + Permission, + PermissionId, HttpMethod, - NetworkCapability, - StorageCapability, + NetworkPermission, + StoragePermission, StorageMode, ActionManifest, - FeatureManifest, -} from './feature-manifest.js'; + CapabilityManifest, +} from './capability-manifest.js'; import { formatStructuredError, type StructuredError } from './errors.js'; export type { StructuredError }; -export class CapabilityError extends Error { +export class PermissionError extends Error { readonly structured: StructuredError; constructor(structured: StructuredError) { super(formatStructuredError(structured)); - this.name = 'CapabilityError'; + this.name = 'PermissionError'; this.structured = structured; } } @@ -35,12 +35,12 @@ export interface NetworkResponse { } export interface NetworkBroker { - readonly capabilityId: CapabilityId; + readonly permissionId: PermissionId; request(input: NetworkRequest): Promise; } export interface StorageBroker { - readonly capabilityId: CapabilityId; + readonly permissionId: PermissionId; readonly mode: StorageMode; get(key: string): Promise; put(key: string, value: unknown): Promise; @@ -49,18 +49,18 @@ export interface StorageBroker { } export interface ClockBroker { - readonly capabilityId: CapabilityId; + readonly permissionId: PermissionId; now(): number; iso(): string; } export interface AuditBroker { - readonly capabilityId: CapabilityId; + readonly permissionId: PermissionId; emit(event: string, payload?: unknown): void; } export interface UiBroker { - readonly capabilityId: CapabilityId; + readonly permissionId: PermissionId; } export type Broker = @@ -86,7 +86,7 @@ export interface ClockTransport { } export interface AuditTransport { - emit(event: { capabilityId: CapabilityId; name: string; payload?: unknown; at: number }): void; + emit(event: { permissionId: PermissionId; name: string; payload?: unknown; at: number }): void; } export interface HostTransports { @@ -96,88 +96,88 @@ export interface HostTransports { audit?: AuditTransport; } -export interface ActionCapabilityScope { - cap(id: CapabilityId): Broker; - has(id: CapabilityId): boolean; +export interface ActionPermissionScope { + cap(id: PermissionId): Broker; + has(id: PermissionId): boolean; } -export interface FeatureCapabilityRegistry { - forAction(actionId: string): ActionCapabilityScope; +export interface PermissionRegistry { + forAction(actionId: string): ActionPermissionScope; } -export function createFeatureCapabilityRegistry( - manifest: FeatureManifest, +export function createPermissionRegistry( + manifest: CapabilityManifest, transports: HostTransports -): FeatureCapabilityRegistry { - const byId = new Map(); - for (const cap of manifest.capabilities) { - byId.set(cap.id, cap); +): PermissionRegistry { + const byId = new Map(); + for (const perm of manifest.permissions) { + byId.set(perm.id, perm); } - const brokers = new Map(); - for (const cap of manifest.capabilities) { - brokers.set(cap.id, buildBroker(cap, transports)); + const brokers = new Map(); + for (const perm of manifest.permissions) { + brokers.set(perm.id, buildBroker(perm, transports)); } const actionsById = new Map(); for (const a of manifest.actions) actionsById.set(a.id, a); return { - forAction(actionId: string): ActionCapabilityScope { + forAction(actionId: string): ActionPermissionScope { const action = actionsById.get(actionId); if (!action) { - throw new CapabilityError({ - code: 'capability.action.unknown', + throw new PermissionError({ + code: 'permission.action.unknown', where: `manifest.actions[id=${actionId}]`, expected: 'a declared action id', actual: actionId, - fixHint: 'Only actions declared in the manifest can request capabilities.', + fixHint: 'Only actions declared in the manifest can request permissions.', }); } - const allowed = new Set(action.capabilities); + const allowed = new Set(action.permissions); for (const ref of allowed) { if (!byId.has(ref)) { - throw new CapabilityError({ - code: 'capability.action.unknown_ref', - where: `manifest.actions[id=${actionId}].capabilities`, - expected: 'a capability id declared in $.capabilities', + throw new PermissionError({ + code: 'permission.action.unknown_ref', + where: `manifest.actions[id=${actionId}].permissions`, + expected: 'a permission id declared in $.permissions', actual: ref, - fixHint: `Declare a capability with id "${ref}" or remove the reference.`, + fixHint: `Declare a permission with id "${ref}" or remove the reference.`, }); } } return { - has(id: CapabilityId): boolean { + has(id: PermissionId): boolean { return allowed.has(id) && brokers.has(id); }, - cap(id: CapabilityId): Broker { + cap(id: PermissionId): Broker { if (!byId.has(id)) { - throw new CapabilityError({ - code: 'capability.undeclared', + throw new PermissionError({ + code: 'permission.undeclared', where: `action[${actionId}].cap("${id}")`, - expected: 'a capability declared on the manifest', + expected: 'a permission declared on the manifest', actual: `unknown id "${id}"`, - fixHint: `Add a capability with id "${id}" to the manifest, or use a declared one.`, + fixHint: `Add a permission with id "${id}" to the manifest, or use a declared one.`, }); } if (!allowed.has(id)) { - throw new CapabilityError({ - code: 'capability.denied', + throw new PermissionError({ + code: 'permission.denied', where: `action[${actionId}].cap("${id}")`, - expected: `capability "${id}" declared on this action`, - actual: `not in action.capabilities = [${[...allowed].join(', ') || 'none'}]`, - fixHint: `Add "${id}" to action "${actionId}".capabilities, or call this from a different action.`, + expected: `permission "${id}" declared on this action`, + actual: `not in action.permissions = [${[...allowed].join(', ') || 'none'}]`, + fixHint: `Add "${id}" to action "${actionId}".permissions, or call this from a different action.`, }); } const broker = brokers.get(id); if (!broker) { - throw new CapabilityError({ - code: 'capability.no_broker', + throw new PermissionError({ + code: 'permission.no_broker', where: `action[${actionId}].cap("${id}")`, - expected: 'a broker for this capability', + expected: 'a broker for this permission', actual: 'no broker registered', - fixHint: 'A host transport for this capability type was not provided to the runtime.', + fixHint: 'A host transport for this permission type was not provided to the runtime.', }); } return broker; @@ -187,31 +187,31 @@ export function createFeatureCapabilityRegistry( }; } -function buildBroker(cap: Capability, transports: HostTransports): Broker { - switch (cap.type) { +function buildBroker(perm: Permission, transports: HostTransports): Broker { + switch (perm.type) { case 'network': - return buildNetworkBroker(cap, transports.network); + return buildNetworkBroker(perm, transports.network); case 'storage': - return buildStorageBroker(cap, transports.storage); + return buildStorageBroker(perm, transports.storage); case 'clock': - return buildClockBroker(cap, transports.clock); + return buildClockBroker(perm, transports.clock); case 'audit': - return buildAuditBroker(cap, transports.audit); + return buildAuditBroker(perm, transports.audit); case 'ui': - return { capabilityId: cap.id } satisfies UiBroker; + return { permissionId: perm.id } satisfies UiBroker; } } -function buildNetworkBroker(cap: NetworkCapability, transport?: NetworkTransport): NetworkBroker { - const allowedHosts = new Set(cap.hosts); - const allowedMethods = cap.methods ? new Set(cap.methods) : null; - const id = cap.id; +function buildNetworkBroker(perm: NetworkPermission, transport?: NetworkTransport): NetworkBroker { + const allowedHosts = new Set(perm.hosts); + const allowedMethods = perm.methods ? new Set(perm.methods) : null; + const id = perm.id; return { - capabilityId: id, + permissionId: id, async request(input: NetworkRequest): Promise { if (!transport) { - throw new CapabilityError({ - code: 'capability.network.no_transport', + throw new PermissionError({ + code: 'permission.network.no_transport', where: `cap("${id}").request`, expected: 'a network transport provided to the runtime', actual: 'undefined', @@ -220,30 +220,30 @@ function buildNetworkBroker(cap: NetworkCapability, transport?: NetworkTransport } const host = parseHost(input.url); if (host === null) { - throw new CapabilityError({ - code: 'capability.network.bad_url', + throw new PermissionError({ + code: 'permission.network.bad_url', where: `cap("${id}").request.url`, expected: 'an absolute URL with a host (https://host/path)', actual: input.url, - fixHint: 'Pass an absolute URL whose host appears in the capability host list.', + fixHint: 'Pass an absolute URL whose host appears in the permission host list.', }); } if (!allowedHosts.has(host)) { - throw new CapabilityError({ - code: 'capability.network.host_denied', + throw new PermissionError({ + code: 'permission.network.host_denied', where: `cap("${id}").request.url`, expected: `host in [${[...allowedHosts].join(', ')}]`, actual: host, - fixHint: `Either change the URL host to a declared one, or add "${host}" to capability "${id}".hosts.`, + fixHint: `Either change the URL host to a declared one, or add "${host}" to permission "${id}".hosts.`, }); } if (allowedMethods && !allowedMethods.has(input.method)) { - throw new CapabilityError({ - code: 'capability.network.method_denied', + throw new PermissionError({ + code: 'permission.network.method_denied', where: `cap("${id}").request.method`, expected: `method in [${[...allowedMethods].join(', ')}]`, actual: input.method, - fixHint: `Use a declared method, or add "${input.method}" to capability "${id}".methods.`, + fixHint: `Use a declared method, or add "${input.method}" to permission "${id}".methods.`, }); } return transport.request(input); @@ -251,17 +251,17 @@ function buildNetworkBroker(cap: NetworkCapability, transport?: NetworkTransport }; } -function buildStorageBroker(cap: StorageCapability, transport?: StorageTransport): StorageBroker { - const id = cap.id; - const scope = cap.scope; - const mode = cap.mode; +function buildStorageBroker(perm: StoragePermission, transport?: StorageTransport): StorageBroker { + const id = perm.id; + const scope = perm.scope; + const mode = perm.mode; const canRead = mode === 'read' || mode === 'readwrite'; const canWrite = mode === 'write' || mode === 'readwrite'; const requireTransport = (): StorageTransport => { if (!transport) { - throw new CapabilityError({ - code: 'capability.storage.no_transport', + throw new PermissionError({ + code: 'permission.storage.no_transport', where: `cap("${id}")`, expected: 'a storage transport provided to the runtime', actual: 'undefined', @@ -272,27 +272,27 @@ function buildStorageBroker(cap: StorageCapability, transport?: StorageTransport }; const denyRead = (op: string) => { - throw new CapabilityError({ - code: 'capability.storage.read_denied', + throw new PermissionError({ + code: 'permission.storage.read_denied', where: `cap("${id}").${op}`, expected: `mode "read" or "readwrite"`, actual: `mode "${mode}"`, - fixHint: `Capability "${id}" was declared with mode "${mode}"; reads are not permitted.`, + fixHint: `Permission "${id}" was declared with mode "${mode}"; reads are not permitted.`, }); }; const denyWrite = (op: string) => { - throw new CapabilityError({ - code: 'capability.storage.write_denied', + throw new PermissionError({ + code: 'permission.storage.write_denied', where: `cap("${id}").${op}`, expected: `mode "write" or "readwrite"`, actual: `mode "${mode}"`, - fixHint: `Capability "${id}" was declared with mode "${mode}"; writes are not permitted.`, + fixHint: `Permission "${id}" was declared with mode "${mode}"; writes are not permitted.`, }); }; return { - capabilityId: id, + permissionId: id, mode, async get(key: string): Promise { if (!canRead) denyRead('get'); @@ -313,10 +313,10 @@ function buildStorageBroker(cap: StorageCapability, transport?: StorageTransport }; } -function buildClockBroker(cap: Capability, transport?: ClockTransport): ClockBroker { - const id = cap.id; +function buildClockBroker(perm: Permission, transport?: ClockTransport): ClockBroker { + const id = perm.id; return { - capabilityId: id, + permissionId: id, now(): number { if (transport) return transport.now(); return Date.now(); @@ -328,21 +328,21 @@ function buildClockBroker(cap: Capability, transport?: ClockTransport): ClockBro }; } -function buildAuditBroker(cap: Capability, transport?: AuditTransport): AuditBroker { - const id = cap.id; +function buildAuditBroker(perm: Permission, transport?: AuditTransport): AuditBroker { + const id = perm.id; return { - capabilityId: id, + permissionId: id, emit(event: string, payload?: unknown): void { if (!transport) { - throw new CapabilityError({ - code: 'capability.audit.no_transport', + throw new PermissionError({ + code: 'permission.audit.no_transport', where: `cap("${id}").emit`, expected: 'an audit transport provided to the runtime', actual: 'undefined', fixHint: 'Provide HostTransports.audit when constructing the runtime; audit must not be silently dropped.', }); } - transport.emit({ capabilityId: id, name: event, payload, at: Date.now() }); + transport.emit({ permissionId: id, name: event, payload, at: Date.now() }); }, }; } diff --git a/src/replay.ts b/src/replay.ts index be7f9a6..bc81bef 100644 --- a/src/replay.ts +++ b/src/replay.ts @@ -6,7 +6,7 @@ import type { AuditTransport, NetworkRequest, NetworkResponse, -} from './capabilities.js'; +} from './permissions.js'; import { formatStructuredError, type StructuredError } from './errors.js'; export type BrokerCallKind = @@ -76,9 +76,9 @@ export async function replay( code: 'replay.unconsumed_entry', where: `recording.entries[${cursor.i}]`, expected: 'all recorded entries consumed', - actual: `feature stopped after ${cursor.i}/${recording.entries.length} entries; next was ${next.kind}`, + actual: `capability stopped after ${cursor.i}/${recording.entries.length} entries; next was ${next.kind}`, fixHint: - 'The feature made fewer broker calls than the recording. Re-record after the change, or revert the change that removed calls.', + 'The capability made fewer broker calls than the recording. Re-record after the change, or revert the change that removed calls.', }); } return { output, entries: recording.entries.slice(0, cursor.i) }; @@ -208,7 +208,7 @@ function wrapForRecord(base: HostTransports, entries: BrokerCallEntry[]): HostTr entries.push({ index: idx, kind: 'audit.emit', - input: { capabilityId: event.capabilityId, name: event.name, payload: event.payload }, + input: { permissionId: event.permissionId, name: event.name, payload: event.payload }, output: undefined, threw: false, }); @@ -230,7 +230,7 @@ function buildReplayTransports( expected: `no more calls (recording length ${recording.entries.length})`, actual: `${kind} ${stringify(expectedInput)}`, fixHint: - 'The feature made more broker calls than the recording. Re-record after the change, or revert the change that added calls.', + 'The capability made more broker calls than the recording. Re-record after the change, or revert the change that added calls.', }); } const entry = recording.entries[cursor.i]; @@ -241,7 +241,7 @@ function buildReplayTransports( expected: `${entry.kind} ${stringify(entry.input)}`, actual: `${kind} ${stringify(expectedInput)}`, fixHint: - 'Broker call kind does not match the recording at this position. The feature changed; re-record or revert.', + 'Broker call kind does not match the recording at this position. The capability changed; re-record or revert.', }); } if (!deepEqual(entry.input, expectedInput)) { @@ -251,7 +251,7 @@ function buildReplayTransports( expected: stringify(entry.input), actual: stringify(expectedInput), fixHint: - 'Broker input differs from the recording at this position. The feature changed; re-record or revert.', + 'Broker input differs from the recording at this position. The capability changed; re-record or revert.', }); } cursor.i++; @@ -297,7 +297,7 @@ function buildReplayTransports( const audit: AuditTransport = { emit(event) { next('audit.emit', { - capabilityId: event.capabilityId, + permissionId: event.permissionId, name: event.name, payload: event.payload, }); diff --git a/tests/unit/manifest-hardening.test.ts b/tests/unit/manifest-hardening.test.ts new file mode 100644 index 0000000..d4af226 --- /dev/null +++ b/tests/unit/manifest-hardening.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect } from 'vitest'; +import { hardenManifest, type CapabilityManifest } from '../../src/capability-manifest.js'; + +function baseManifest(overrides: Partial = {}): CapabilityManifest { + return { + schemaVersion: 1, + id: 'ops.example', + version: '0.1.0', + title: 'Example', + description: 'An example capability for hardening tests.', + permissions: [ + { + type: 'network', + id: 'net.read', + hosts: ['api.example.com'], + methods: ['GET'], + reason: 'Read example records needed by load action.', + }, + ], + actions: [ + { + id: 'example.load', + description: 'Load example records from the upstream API.', + input: { type: 'object' }, + output: { type: 'object' }, + permissions: ['net.read'], + handler: 'load', + }, + ], + implementation: { type: 'module', entry: './impl.js' }, + ...overrides, + }; +} + +describe('hardenManifest — permission.reason.too_short', () => { + it('flags a reason with fewer than 5 words', () => { + const m = baseManifest({ + permissions: [ + { + type: 'network', + id: 'net.read', + hosts: ['api.example.com'], + methods: ['GET'], + reason: 'read records', + }, + ], + }); + const { errors } = hardenManifest(m); + const e = errors.find((x) => x.code === 'permission.reason.too_short'); + expect(e).toBeDefined(); + expect(e!.where).toBe('$.permissions[0].reason'); + }); + + it('accepts a reason with exactly 5 words', () => { + const m = baseManifest({ + permissions: [ + { + type: 'network', + id: 'net.read', + hosts: ['api.example.com'], + methods: ['GET'], + reason: 'one two three four five', + }, + ], + }); + const { errors } = hardenManifest(m); + expect(errors.find((x) => x.code === 'permission.reason.too_short')).toBeUndefined(); + }); +}); + +describe('hardenManifest — action.description.too_short', () => { + it('flags an action description with fewer than 5 words', () => { + const m = baseManifest({ + actions: [ + { + id: 'example.load', + description: 'load it', + input: { type: 'object' }, + output: { type: 'object' }, + permissions: ['net.read'], + handler: 'load', + }, + ], + }); + const { errors } = hardenManifest(m); + const e = errors.find((x) => x.code === 'action.description.too_short'); + expect(e).toBeDefined(); + expect(e!.where).toBe('$.actions[0].description'); + }); + + it('accepts a 5-word action description', () => { + const m = baseManifest({ + actions: [ + { + id: 'example.load', + description: 'one two three four five', + input: { type: 'object' }, + output: { type: 'object' }, + permissions: ['net.read'], + handler: 'load', + }, + ], + }); + const { errors } = hardenManifest(m); + expect(errors.find((x) => x.code === 'action.description.too_short')).toBeUndefined(); + }); +}); + +describe('hardenManifest — permission.network.host_wildcard', () => { + it('flags wildcards in host entries', () => { + const m = baseManifest({ + permissions: [ + { + type: 'network', + id: 'net.read', + hosts: ['*.example.com'], + methods: ['GET'], + reason: 'Read records needed by load action.', + }, + ], + }); + const { errors } = hardenManifest(m); + const e = errors.find((x) => x.code === 'permission.network.host_wildcard'); + expect(e).toBeDefined(); + expect(e!.where).toBe('$.permissions[0].hosts[0]'); + }); + + it('accepts hosts without wildcards', () => { + const m = baseManifest(); + const { errors } = hardenManifest(m); + expect(errors.find((x) => x.code === 'permission.network.host_wildcard')).toBeUndefined(); + }); +}); + +describe('hardenManifest — permission.storage.scope_wildcard', () => { + it('flags wildcards in storage scope', () => { + const m = baseManifest({ + permissions: [ + { + type: 'storage', + id: 'st.read', + scope: 'tenant/*', + mode: 'read', + reason: 'Read tenant-scoped data for the load action.', + }, + ], + actions: [ + { + id: 'example.load', + description: 'Load tenant data from storage tier.', + input: { type: 'object' }, + output: { type: 'object' }, + permissions: ['st.read'], + handler: 'load', + }, + ], + }); + const { errors } = hardenManifest(m); + const e = errors.find((x) => x.code === 'permission.storage.scope_wildcard'); + expect(e).toBeDefined(); + expect(e!.where).toBe('$.permissions[0].scope'); + }); + + it('accepts storage scope without wildcards', () => { + const m = baseManifest({ + permissions: [ + { + type: 'storage', + id: 'st.read', + scope: 'tenant/risk-thresholds', + mode: 'read', + reason: 'Read tenant-scoped data for the load action.', + }, + ], + actions: [ + { + id: 'example.load', + description: 'Load tenant data from storage tier.', + input: { type: 'object' }, + output: { type: 'object' }, + permissions: ['st.read'], + handler: 'load', + }, + ], + }); + const { errors } = hardenManifest(m); + expect(errors.find((x) => x.code === 'permission.storage.scope_wildcard')).toBeUndefined(); + }); +}); + +describe('hardenManifest — permission.reason.no_action_ref (warning)', () => { + it('warns when reason does not mention any referencing action id', () => { + const m = baseManifest({ + permissions: [ + { + type: 'network', + id: 'net.read', + hosts: ['api.example.com'], + methods: ['GET'], + reason: 'Read records from upstream API system.', + }, + ], + actions: [ + { + id: 'example.load', + description: 'Load example records from upstream API.', + input: { type: 'object' }, + output: { type: 'object' }, + permissions: ['net.read'], + handler: 'load', + }, + ], + }); + const { errors, warnings } = hardenManifest(m); + expect(errors).toEqual([]); + const w = warnings.find((x) => x.code === 'permission.reason.no_action_ref'); + expect(w).toBeDefined(); + expect(w!.where).toBe('$.permissions[0].reason'); + }); + + it('does not warn when reason mentions a referencing action id', () => { + const m = baseManifest({ + permissions: [ + { + type: 'network', + id: 'net.read', + hosts: ['api.example.com'], + methods: ['GET'], + reason: 'Used by example.load to fetch records.', + }, + ], + actions: [ + { + id: 'example.load', + description: 'Load example records from upstream API.', + input: { type: 'object' }, + output: { type: 'object' }, + permissions: ['net.read'], + handler: 'load', + }, + ], + }); + const { warnings } = hardenManifest(m); + expect(warnings.find((x) => x.code === 'permission.reason.no_action_ref')).toBeUndefined(); + }); + + it('does not warn for permissions referenced by no action', () => { + const m = baseManifest({ + permissions: [ + { + type: 'audit', + id: 'audit.orphan', + reason: 'Emit audit events for compliance purposes only.', + }, + ], + actions: [ + { + id: 'example.load', + description: 'Load example records from upstream API.', + input: { type: 'object' }, + output: { type: 'object' }, + permissions: [], + handler: 'load', + }, + ], + }); + const { warnings } = hardenManifest(m); + expect(warnings.find((x) => x.code === 'permission.reason.no_action_ref')).toBeUndefined(); + }); +}); + +describe('hardenManifest — submit() wiring', () => { + it('SubmitResult includes warnings field', async () => { + const { ApprovalLifecycle, MemoryCapabilityStore } = await import('../../src/approval.js'); + const store = new MemoryCapabilityStore(); + const lifecycle = new ApprovalLifecycle(store); + const m = baseManifest(); + const result = lifecycle.submit(m); + expect(result.warnings).toBeDefined(); + expect(Array.isArray(result.warnings)).toBe(true); + }); + + it('submit() throws ApprovalError when hardening errors are present', async () => { + const { ApprovalLifecycle, MemoryCapabilityStore, ApprovalError } = await import( + '../../src/approval.js' + ); + const store = new MemoryCapabilityStore(); + const lifecycle = new ApprovalLifecycle(store); + const m = baseManifest({ + permissions: [ + { + type: 'network', + id: 'net.read', + hosts: ['*.example.com'], + methods: ['GET'], + reason: 'Read records needed by load action.', + }, + ], + }); + expect(() => lifecycle.submit(m)).toThrow(ApprovalError); + }); +});