@emilia-protocol/gate 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,9 +83,12 @@ passes. A bare `@emilia-protocol/require-receipt` gate binds the action type/tar
83
83
  this package when parameter drift (amount, beneficiary, commit, role, …) must be caught.
84
84
 
85
85
  Prefer `gate.run(...)` for mutations: it reserves the receipt, runs the side effect, commits
86
- one-time consumption only after success, releases the reservation if the action fails before
87
- mutation, and emits the execution receipt + reliance packet. Use lower-level `gate.check(...)` only
88
- when your framework has to separate authorization from execution.
86
+ one-time consumption after success, and emits the execution receipt + reliance packet. Once the
87
+ executor is invoked, a thrown error is an **indeterminate effect**, not proof that nothing happened:
88
+ the approval is burned (or its no-TTL reservation remains frozen if the store is unavailable) so a
89
+ blind retry cannot duplicate the side effect. Retryable integrations should make the downstream
90
+ operation idempotent under `receipt_id` and reconcile the result. Use lower-level `gate.check(...)`
91
+ only when your framework has to separate authorization from execution.
89
92
 
90
93
  Use your own manifest when you need custom policy:
91
94
 
@@ -223,8 +226,10 @@ challenge. The Gate composes that and adds the three things a firewall needs:
223
226
  verifier in `@emilia-protocol/verify`.
224
227
  - **One-time consumption** — a receipt authorizes one action, once. Replays are refused
225
228
  (`replay_refused`). Default store is in-memory; swap in Redis/DB for a fleet.
226
- - **Evidence log** — every decision is hash-chained (`evidence.verify()` detects any alteration).
227
- This is the compliance / insurance artifact.
229
+ - **Evidence log** — the local logger hash-chains decisions and detects alteration when given its
230
+ complete process history. It is not a fleet ledger: a sink cannot prevent restart-from-genesis or
231
+ cross-replica forks. Safety-critical deployments use `createAtomicEvidenceLog()` over a durable
232
+ backend whose compare-and-append transaction advances one shared head across replicas.
228
233
  - **Execution-field binding** — for high-risk packs, the signed claim must match the executor's
229
234
  observed mutation fields (`amount_usd`, `commit_sha`, `principal_id`, `record_id`, etc.). This
230
235
  closes "approved harmless X, executed dangerous Y."
@@ -235,6 +240,20 @@ challenge. The Gate composes that and adds the three things a firewall needs:
235
240
 
236
241
  The three things a serious buyer (CISO, auditor, insurer) asks after the demo:
237
242
 
243
+ **AEC execution custody.** `createAECExecutionGate()` requires a relying-party requirement,
244
+ executor-owned action, explicit human floor, and constructor-pinned custom verifier and key
245
+ registries. Transaction input may carry evidence, but never verifier code, trust keys, or human
246
+ acceptance profiles; attempts to do so are refused before verification. Production mode additionally refuses an expiring
247
+ consumption store or a process-local evidence logger. It consumes
248
+ `aec:action:<canonical-action-digest>` before the effect, passes the effect a frozen pre-await action
249
+ snapshot, and conservatively burns or freezes the action after an indeterminate result. Every
250
+ otherwise identical intended effect therefore needs a unique action-instance nonce inside the
251
+ signed action. Use `createAtomicEvidenceLog()` from `@emilia-protocol/gate/evidence`; its backend
252
+ must atomically compare and append against one durable shared head. The gate independently
253
+ recomputes every logger acknowledgment and requires its entry bytes to equal the requested
254
+ decision; the atomic logger also requires readback to equal the exact submitted sequence and
255
+ predecessor.
256
+
238
257
  **Issuer key rotation + revocation.** A flat `trustedKeys` list can't revoke a leaked key
239
258
  or rotate without downtime. A key registry can — a receipt is verified only against keys
240
259
  valid (and not revoked) at its issuance time. Revocation is fail-closed and immediate.
@@ -251,15 +270,24 @@ registry.revoke('issuer-1'); // compromised — refused immediately, live, no re
251
270
  ```
252
271
 
253
272
  **Fleet-safe replay defense.** The in-memory store is per-process. In production, back the
254
- consumption store with a shared key-value store whose insert-if-absent is atomic:
273
+ consumption store with a shared key-value store whose insert-if-absent, compare-and-set, and
274
+ conditional delete operations are atomic:
255
275
 
256
276
  ```js
257
277
  import { createDurableConsumptionStore } from '@emilia-protocol/gate';
258
- const store = createDurableConsumptionStore(redisBackend); // backend.addIfAbsent MUST be atomic (Redis SET NX)
278
+ const store = createDurableConsumptionStore(redisBackend); // addIfAbsent + compareAndSet + deleteIfValue + has
259
279
  const gate = createGate({ manifest, keyRegistry, store });
260
280
  // A receipt consumed on one pod cannot be replayed on another.
261
281
  ```
262
282
 
283
+ Reservations carry an opaque owner token and have no TTL. Only that owner may commit or release;
284
+ an abandoned reservation requires reconciliation because automatically reopening it after a crash
285
+ could repeat an effect whose response was lost. A TTL may apply only to committed rows.
286
+ The Postgres adapter rejects malformed or regressing clocks before expiry-bearing state changes.
287
+ The model-based fault gate runs 5,000 generated schedules across crash, lag, rollback, failover,
288
+ duplicate delivery, and before/after-linearization response loss; see
289
+ `security/CONSUMPTION_FAULT_STATUS.md`.
290
+
263
291
  **Evidence retention.** Classify the evidence log into hot/cold/expired with legal hold, and
264
292
  export the auditor/SIEM manifest (tied to the evidence head). `EP_AUDIT_HOT_DAYS` /
265
293
  `EP_AUDIT_COLD_DAYS` set the horizons.
@@ -0,0 +1,301 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Stateful execution boundary for EP-AEC.
4
+ *
5
+ * verifyAuthorizationChain is a pure evidence-composition decision. This
6
+ * wrapper adds the stateful properties an executor needs: independently bound
7
+ * action bytes, a mandatory human-assurance floor, atomic one-time reservation,
8
+ * tamper-evident decision records, and conservative crash semantics.
9
+ */
10
+ import { createEvidenceLog, verifyEvidenceRecord } from './evidence.js';
11
+ import { MemoryConsumptionStore } from './store.js';
12
+
13
+ const { verifyAuthorizationChain } = await import('@emilia-protocol/verify/evidence-chain')
14
+ .catch(() => import('../verify/evidence-chain.js'));
15
+
16
+ const HUMAN_FLOORS = new Set(['class_a', 'quorum', 'class_a_or_quorum']);
17
+ const HEX_256 = /^[0-9a-f]{64}$/;
18
+ const COMPONENT_TYPE = /^[A-Za-z0-9_.:-]+$/;
19
+ const RESERVED_COMPONENT_TYPES = new Set(['ep-receipt', 'ep-quorum']);
20
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
21
+
22
+ function deepFreeze(value) {
23
+ if (!value || typeof value !== 'object') return value;
24
+ const stack = [value];
25
+ const seen = new WeakSet();
26
+ while (stack.length) {
27
+ const current = stack.pop();
28
+ if (!current || typeof current !== 'object' || seen.has(current)) continue;
29
+ seen.add(current);
30
+ for (const child of Object.values(current)) stack.push(child);
31
+ Object.freeze(current);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function validLogRecord(record, atomicRequired, expectedEntry) {
37
+ return verifyEvidenceRecord(record, { atomicRequired, expectedEntry });
38
+ }
39
+
40
+ function validComponent(result, type) {
41
+ return Array.isArray(result?.components)
42
+ && result.components.some((component) => component.type === type && component.valid === true && component.bound === true);
43
+ }
44
+
45
+ function humanFloorSatisfied(result, floor) {
46
+ const classA = validComponent(result, 'ep-receipt');
47
+ const quorum = validComponent(result, 'ep-quorum');
48
+ if (floor === 'class_a') return classA;
49
+ if (floor === 'quorum') return quorum;
50
+ return classA || quorum;
51
+ }
52
+
53
+ function consumptionKey(result) {
54
+ // Consume the executor-owned action instance, not a presenter-selected
55
+ // component identifier. Otherwise an invalid decoy component or an alternate
56
+ // valid human proof can create a fresh key for the same physical effect.
57
+ // Repeated intended effects therefore need a unique nonce/id inside the
58
+ // canonical action before authorization is collected.
59
+ return HEX_256.test(result?.action_digest)
60
+ ? `aec:action:${result.action_digest}`
61
+ : null;
62
+ }
63
+
64
+ function instant(now) {
65
+ try {
66
+ const value = typeof now === 'function' ? now() : now;
67
+ const date = value instanceof Date ? value : new Date(value);
68
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param {object} config
76
+ * @param {string} config.requirement relying-party AEC requirement
77
+ * @param {object} config.policiesByType relying-party human acceptance profiles
78
+ * @param {object} [config.verifiers] relying-party-pinned custom component verifiers
79
+ * @param {object} [config.keysByType] relying-party-pinned custom verifier keys
80
+ * @param {'class_a'|'quorum'|'class_a_or_quorum'} config.humanFloor
81
+ * @param {object} [config.store] ownership-fenced consumption store
82
+ * @param {object} [config.log] tamper-evident evidence log
83
+ * @param {boolean} [config.allowEphemeralState=false] test/demo opt-in only
84
+ * @param {Function|number|Date} [config.now=Date.now]
85
+ */
86
+ export function createAECExecutionGate({
87
+ requirement,
88
+ policiesByType,
89
+ verifiers = {},
90
+ keysByType = {},
91
+ humanFloor,
92
+ store,
93
+ log,
94
+ allowEphemeralState = false,
95
+ now = Date.now,
96
+ } = {}) {
97
+ if (typeof requirement !== 'string' || !requirement.trim()) {
98
+ throw new Error('AEC execution gate requires a relying-party requirement');
99
+ }
100
+ if (!policiesByType || typeof policiesByType !== 'object' || Array.isArray(policiesByType)) {
101
+ throw new Error('AEC execution gate requires relying-party policiesByType');
102
+ }
103
+ let pinnedPolicies;
104
+ try { pinnedPolicies = deepFreeze(structuredClone(policiesByType)); }
105
+ catch { throw new Error('AEC execution gate policiesByType must be cloneable canonical data'); }
106
+ if (!verifiers || typeof verifiers !== 'object' || Array.isArray(verifiers)) {
107
+ throw new Error('AEC execution gate verifiers must be a relying-party-owned object');
108
+ }
109
+ const pinnedVerifiers = Object.create(null);
110
+ try {
111
+ for (const [type, verifier] of Object.entries(verifiers)) {
112
+ if (!COMPONENT_TYPE.test(type) || type.length > 128 || RESERVED_COMPONENT_TYPES.has(type)
113
+ || typeof verifier !== 'function') {
114
+ throw new Error('invalid verifier registry member');
115
+ }
116
+ pinnedVerifiers[type] = verifier;
117
+ }
118
+ Object.freeze(pinnedVerifiers);
119
+ } catch {
120
+ throw new Error('AEC execution gate verifiers must contain only named custom verifier functions');
121
+ }
122
+ if (!keysByType || typeof keysByType !== 'object' || Array.isArray(keysByType)) {
123
+ throw new Error('AEC execution gate keysByType must be a relying-party-owned object');
124
+ }
125
+ let pinnedKeysByType;
126
+ try { pinnedKeysByType = deepFreeze(structuredClone(keysByType)); }
127
+ catch { throw new Error('AEC execution gate keysByType must be cloneable canonical data'); }
128
+ if (!HUMAN_FLOORS.has(humanFloor)) {
129
+ throw new Error('AEC execution gate requires humanFloor class_a, quorum, or class_a_or_quorum');
130
+ }
131
+ if (!store && !allowEphemeralState) {
132
+ throw new Error('AEC execution gate requires a durable consumption store');
133
+ }
134
+ if (!log && !allowEphemeralState) {
135
+ throw new Error('AEC execution gate requires a durable strict evidence log');
136
+ }
137
+
138
+ const consumption = store || new MemoryConsumptionStore();
139
+ const evidence = log || createEvidenceLog({ strict: true });
140
+ if (!allowEphemeralState && (consumption.durable !== true || consumption.ownershipFenced !== true
141
+ || consumption.permanentConsumption !== true)) {
142
+ throw new Error('AEC execution gate requires a capability-marked, ownership-fenced durable store with non-expiring committed keys');
143
+ }
144
+ if (!allowEphemeralState && (evidence.durable !== true || evidence.strict !== true
145
+ || evidence.forkAware !== true || evidence.atomicAppend !== true)) {
146
+ throw new Error('AEC execution gate requires a durable strict evidence log with atomic shared-head append and fork detection');
147
+ }
148
+ for (const method of ['reserve', 'commit']) {
149
+ if (typeof consumption?.[method] !== 'function') {
150
+ throw new Error(`AEC execution gate consumption store requires ${method}()`);
151
+ }
152
+ }
153
+ if (typeof evidence?.record !== 'function') {
154
+ throw new Error('AEC execution gate evidence log requires record()');
155
+ }
156
+ // Capture the methods that passed construction checks. Callers may retain the
157
+ // objects for observability, but replacing a method later must not rewrite the
158
+ // gate's replay or evidence semantics.
159
+ const reserveConsumption = consumption.reserve.bind(consumption);
160
+ const commitConsumption = consumption.commit.bind(consumption);
161
+ const releaseConsumption = typeof consumption.release === 'function'
162
+ ? consumption.release.bind(consumption) : null;
163
+ const recordEvidence = evidence.record.bind(evidence);
164
+
165
+ async function deny(reason, result = null, extra = {}) {
166
+ let decision = null;
167
+ try {
168
+ const entry = {
169
+ type: 'aec.execution.decision',
170
+ at: instant(now),
171
+ allow: false,
172
+ reason,
173
+ action_digest: result?.action_digest ?? null,
174
+ requirement,
175
+ human_floor: humanFloor,
176
+ ...extra,
177
+ };
178
+ decision = await recordEvidence(entry);
179
+ if (!validLogRecord(decision, !allowEphemeralState, entry)) throw new Error('malformed evidence record');
180
+ } catch {
181
+ return { ok: false, allow: false, reason: 'evidence_log_failed', result, decision: null };
182
+ }
183
+ return { ok: false, allow: false, reason, result, decision };
184
+ }
185
+
186
+ async function run(request = {}, effect) {
187
+ if (typeof effect !== 'function') throw new Error('AEC execution gate run() requires an effect function');
188
+ let chain;
189
+ let expectedAction;
190
+ try {
191
+ if (!request || typeof request !== 'object' || Array.isArray(request)) {
192
+ return deny('invalid_execution_request');
193
+ }
194
+ // Trust configuration belongs to the relying party at gate construction.
195
+ // Accepting verifier code or trust keys beside presenter evidence lets the
196
+ // presenter define the proof that its own evidence must pass.
197
+ if (own(request, 'verifiers') || own(request, 'keysByType') || own(request, 'policiesByType')) {
198
+ return deny('runtime_trust_configuration_refused');
199
+ }
200
+ chain = request.chain;
201
+ expectedAction = request.expectedAction;
202
+ } catch {
203
+ return deny('invalid_execution_request');
204
+ }
205
+ const verificationTime = instant(now);
206
+ if (!verificationTime) return deny('invalid_verification_time');
207
+ let actionSnapshot;
208
+ try { actionSnapshot = deepFreeze(structuredClone(expectedAction)); }
209
+ catch { return deny('invalid_expected_action'); }
210
+
211
+ const result = verifyAuthorizationChain(chain, {
212
+ verifiers: pinnedVerifiers,
213
+ keysByType: pinnedKeysByType,
214
+ policiesByType: pinnedPolicies,
215
+ requirement,
216
+ expectedAction: actionSnapshot,
217
+ verificationTime,
218
+ });
219
+ if (!result.allow) return deny('aec_refused', result, { reasons: result.reasons });
220
+ if (!humanFloorSatisfied(result, humanFloor)) return deny('human_floor_unsatisfied', result);
221
+
222
+ const key = consumptionKey(result);
223
+ if (!key) return deny('missing_stable_consumption_key', result);
224
+
225
+ let reserved;
226
+ try { reserved = (await reserveConsumption(key)) === true; }
227
+ catch { return deny('consumption_store_unavailable', result, { consumption_key: key }); }
228
+ if (!reserved) return deny('replay_refused', result, { consumption_key: key });
229
+
230
+ let authorization;
231
+ try {
232
+ const entry = {
233
+ type: 'aec.execution.decision',
234
+ at: verificationTime,
235
+ allow: true,
236
+ reason: 'allow',
237
+ action_digest: result.action_digest,
238
+ requirement,
239
+ human_floor: humanFloor,
240
+ consumption_key: key,
241
+ };
242
+ authorization = await recordEvidence(entry);
243
+ if (!validLogRecord(authorization, !allowEphemeralState, entry)) throw new Error('malformed evidence record');
244
+ } catch {
245
+ try { if (releaseConsumption) await releaseConsumption(key); } catch { /* frozen is safe */ }
246
+ return { ok: false, allow: false, reason: 'evidence_log_failed', result, decision: null };
247
+ }
248
+
249
+ let effectStarted = false;
250
+ let committed = false;
251
+ try {
252
+ effectStarted = true;
253
+ const value = await effect({ action: actionSnapshot, result, authorization });
254
+ if ((await commitConsumption(key)) !== true) throw new Error('consumption_store_commit_refused');
255
+ committed = true;
256
+ const executionEntry = {
257
+ type: 'aec.execution.outcome',
258
+ at: instant(now),
259
+ outcome: 'executed',
260
+ authorizes_decision: authorization.hash,
261
+ action_digest: result.action_digest,
262
+ consumption_key: key,
263
+ };
264
+ const execution = await recordEvidence(executionEntry);
265
+ if (!validLogRecord(execution, !allowEphemeralState, executionEntry)) throw new Error('malformed evidence record');
266
+ return { ok: true, allow: true, value, result, authorization, execution };
267
+ } catch (error) {
268
+ if (effectStarted && !committed) {
269
+ try { committed = (await commitConsumption(key)) === true; } catch { /* reservation remains fail-closed */ }
270
+ }
271
+ try {
272
+ const indeterminateEntry = {
273
+ type: 'aec.execution.outcome',
274
+ at: instant(now),
275
+ outcome: 'indeterminate',
276
+ authorizes_decision: authorization.hash,
277
+ action_digest: result.action_digest,
278
+ consumption_key: key,
279
+ };
280
+ const indeterminate = await recordEvidence(indeterminateEntry);
281
+ if (!validLogRecord(indeterminate, !allowEphemeralState, indeterminateEntry)) {
282
+ throw new Error('malformed evidence record');
283
+ }
284
+ } catch { /* the original failure remains primary; replay is still blocked */ }
285
+ throw error;
286
+ }
287
+ }
288
+
289
+ return { run, evidence, store: consumption };
290
+ }
291
+
292
+ export const __aecExecutionSecurityInternals = Object.freeze({
293
+ deepFreeze,
294
+ validLogRecord,
295
+ validComponent,
296
+ humanFloorSatisfied,
297
+ consumptionKey,
298
+ instant,
299
+ });
300
+
301
+ export default { createAECExecutionGate };
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Durable, body-bound lifecycle store for AE-CHALLENGE-v1.
4
+ *
5
+ * Registration is atomic insert-if-absent. Consumption is an atomic transition
6
+ * from the digest of the exact registered body to a consumed state carrying
7
+ * that same digest. A presenter that changes action, profile, expiry, or any
8
+ * other challenge member cannot consume the original registration even when it
9
+ * reuses the challenge id and nonce.
10
+ */
11
+ import { hashCanonical } from './execution-binding.js';
12
+
13
+ export const DURABLE_CHALLENGE_STORE_VERSION = 'EP-DURABLE-CHALLENGE-STORE-v1';
14
+ const OPEN_PREFIX = 'challenge-open:v1:';
15
+ const CONSUMED_PREFIX = 'challenge-consumed:v1:';
16
+
17
+ function assertChallenge(challenge) {
18
+ if (!challenge || typeof challenge !== 'object' || Array.isArray(challenge)) {
19
+ throw new Error('challenge must be an object');
20
+ }
21
+ if (challenge['@version'] !== 'AE-CHALLENGE-v1') throw new Error('unsupported challenge version');
22
+ if (typeof challenge.challenge_id !== 'string' || !challenge.challenge_id.trim()) throw new Error('challenge_id is required');
23
+ if (typeof challenge.nonce !== 'string' || !challenge.nonce.trim()) throw new Error('challenge nonce is required');
24
+ }
25
+
26
+ export function challengeStorageKey(challenge) {
27
+ assertChallenge(challenge);
28
+ return `ae-challenge:${hashCanonical({ challenge_id: challenge.challenge_id, nonce: challenge.nonce })}`;
29
+ }
30
+
31
+ export function challengeBodyDigest(challenge) {
32
+ assertChallenge(challenge);
33
+ return hashCanonical(challenge);
34
+ }
35
+
36
+ export function createDurableChallengeStore(backend) {
37
+ for (const method of ['addIfAbsent', 'compareAndSet', 'has']) {
38
+ if (typeof backend?.[method] !== 'function') {
39
+ throw new Error(`createDurableChallengeStore: backend must implement atomic async ${method}()`);
40
+ }
41
+ }
42
+
43
+ return {
44
+ durable: backend.durable === true,
45
+ atomicRegistration: true,
46
+ bodyBound: true,
47
+ permanentConsumption: true,
48
+ async register(challenge) {
49
+ const key = challengeStorageKey(challenge);
50
+ const digest = challengeBodyDigest(challenge);
51
+ return (await backend.addIfAbsent(key, `${OPEN_PREFIX}${digest}`)) === true;
52
+ },
53
+
54
+ async consume(challenge) {
55
+ const key = challengeStorageKey(challenge);
56
+ const digest = challengeBodyDigest(challenge);
57
+ return (await backend.compareAndSet(
58
+ key,
59
+ `${OPEN_PREFIX}${digest}`,
60
+ `${CONSUMED_PREFIX}${digest}`,
61
+ )) === true;
62
+ },
63
+
64
+ async has(challenge) {
65
+ return (await backend.has(challengeStorageKey(challenge))) === true;
66
+ },
67
+ };
68
+ }
69
+
70
+ export default { createDurableChallengeStore, challengeStorageKey, challengeBodyDigest, DURABLE_CHALLENGE_STORE_VERSION };
package/enterprise.js CHANGED
@@ -73,7 +73,10 @@ export function mintEntitlement(privateKey, {
73
73
  if (toMs(not_before) == null) throw new Error('entitlement: not_before is required (ISO or ms)');
74
74
  if (toMs(expires_at) == null) throw new Error('entitlement: expires_at is required (ISO or ms)');
75
75
  if (!kid || typeof kid !== 'string') throw new Error('entitlement: kid is required');
76
- const payload = { org, tier, features, limits, not_before, expires_at, kid };
76
+ // Snapshot features/limits into the signed payload: embedding the caller's live
77
+ // array/object would let a licensing service mutate them after minting and
78
+ // diverge the entitlement from its signature.
79
+ const payload = { org, tier, features: structuredClone(features), limits: structuredClone(limits), not_before, expires_at, kid };
77
80
  const value = crypto.sign(null, Buffer.from(canonical(payload), 'utf8'), privateKey).toString('base64url');
78
81
  return { '@version': ENTITLEMENT_VERSION, payload, signature: { algorithm: 'Ed25519', value } };
79
82
  }
package/ep-assure.mjs ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * ep-assure — re-perform an EMILIA reliance assurance package and emit an auditor
5
+ * workpaper. The independent-assurer CLI: point it at a package (or raw decisions
6
+ * + a pinned profile) and auditor-supplied keys, and it recomputes every reliance
7
+ * verdict offline, detects drift against the runtime's stated verdicts, maps each
8
+ * to a control objective, and prints the workpaper. Exit code is non-zero when it
9
+ * finds a decision that RELIED ON INADMISSIBLE evidence (claimed rely, recomputes
10
+ * to a refusal), so it drops into CI/audit pipelines.
11
+ *
12
+ * node packages/gate/ep-assure.mjs <input.json> [--json] [--strict]
13
+ *
14
+ * input.json is one of:
15
+ * { "package": <EP-ASSURANCE-PACKAGE-v1>, "keys": {...}, "now": <iso|ms> }
16
+ * { "decisions": [...], "profile": <EP-RELIANCE-PROFILE-v1>, "keys": {...}, "now": <iso|ms> }
17
+ *
18
+ * keys (auditor-pinned, out of band): { approverKeys, logPublicKey, rpId, revokerKeys }
19
+ * --json print the full EP-ASSURANCE-REPERFORMANCE-v1 document instead of text
20
+ * --strict exit non-zero if ANY drift is found (default: only inadmissible-reliance drift)
21
+ */
22
+ import { readFileSync } from 'node:fs';
23
+ import { buildAssurancePackage, reperformAssurancePackage, renderAssuranceWorkpaper } from './reports/assurance-package.js';
24
+
25
+ function fail(msg) { process.stderr.write(`ep-assure: ${msg}\n`); process.exit(2); }
26
+
27
+ const args = process.argv.slice(2);
28
+ const flags = new Set(args.filter((a) => a.startsWith('--')));
29
+ const path = args.find((a) => !a.startsWith('--'));
30
+ if (!path) fail('usage: ep-assure <input.json> [--json] [--strict]');
31
+
32
+ let input;
33
+ try { input = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { fail(`cannot read ${path}: ${e.message}`); }
34
+
35
+ const keys = input.keys || {};
36
+ const now = input.now != null ? (typeof input.now === 'string' ? Date.parse(input.now) : input.now) : 0;
37
+
38
+ let pkg = input.package;
39
+ if (!pkg) {
40
+ if (!Array.isArray(input.decisions)) fail('input needs a `package` or a `decisions` array');
41
+ pkg = buildAssurancePackage(input.decisions, { profile: input.profile, organization: input.organization || null, now });
42
+ }
43
+
44
+ let doc;
45
+ try {
46
+ doc = reperformAssurancePackage(pkg, {
47
+ approverKeys: keys.approverKeys || {}, logPublicKey: keys.logPublicKey || null,
48
+ rpId: keys.rpId || null, revokerKeys: keys.revokerKeys || {}, now,
49
+ });
50
+ } catch (e) { fail(e.message); }
51
+
52
+ if (flags.has('--json')) {
53
+ process.stdout.write(JSON.stringify(doc, null, 2) + '\n');
54
+ } else {
55
+ process.stdout.write(renderAssuranceWorkpaper(doc) + '\n');
56
+ }
57
+
58
+ const inadmissibleReliance = doc.population.relied_on_inadmissible_evidence;
59
+ const anyDrift = doc.population.drift;
60
+ const bad = flags.has('--strict') ? anyDrift : inadmissibleReliance;
61
+ if (bad > 0) {
62
+ process.stderr.write(`ep-assure: ${bad} finding(s) — ${flags.has('--strict') ? 'drift' : 'reliance on inadmissible evidence'} detected\n`);
63
+ process.exit(1);
64
+ }
65
+ process.exit(0);