@metamynd/agentsafe-guard 0.2.0 → 0.3.1
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 +40 -0
- package/agentsafe-guard.mjs +143 -17
- package/cli.mjs +29 -0
- package/demo.mjs +161 -0
- package/package.json +10 -9
- package/policy-core.mjs +83 -4
package/README.md
CHANGED
|
@@ -15,6 +15,22 @@ compliance team edits in the dashboard, changeable live with no redeploy.
|
|
|
15
15
|
`policy-core` the gate runs (MAGP §9.2 cooperative mode) — identical inputs give the identical
|
|
16
16
|
verdict, with no network round-trip. See §4.
|
|
17
17
|
|
|
18
|
+
## Try it first — no account, no network
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx @metamynd/agentsafe-guard demo
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Runs the policy engine in-process against a sample bundle and prints the verdict for a
|
|
25
|
+
dozen tool calls — allow, observe, block, escalate, quarantine — including the ones that
|
|
26
|
+
matter most: an agent that lies about its own spend in unsigned context, and a quarantined
|
|
27
|
+
agent refused before any rule is read. It mints an ephemeral key, never opens a socket, and
|
|
28
|
+
needs nothing from MetaMynd. Exits non-zero if any verdict disagrees with the policy, so
|
|
29
|
+
it doubles as a smoke test of the installed package.
|
|
30
|
+
|
|
31
|
+
You need an account only for what can't be enforced client-side: live policy edits,
|
|
32
|
+
cumulative spend caps, human escalation, and anchored evidence.
|
|
33
|
+
|
|
18
34
|
## Install
|
|
19
35
|
|
|
20
36
|
```bash
|
|
@@ -217,6 +233,30 @@ Or wrap a tool to gate it against a local bundle (fails closed like `guardTool`)
|
|
|
217
233
|
const gated = guard.guardToolLocal('flight-purchase', bookFlight, mapArgs, { standards, sops, mandate });
|
|
218
234
|
```
|
|
219
235
|
|
|
236
|
+
### Execution adapters (dry-run / sandbox) — SAFR §19
|
|
237
|
+
|
|
238
|
+
By default a permitted (`allow`/`observe`) tool runs its real handler. An **ExecutionAdapter**
|
|
239
|
+
interposes between the verdict and the side-effect, so the *same* governed decision can be run
|
|
240
|
+
live, **simulated (dry-run)**, or routed to a sandbox — without changing the handler or the gate.
|
|
241
|
+
A blocked/escalated action still throws `GovernanceBlocked` before any adapter is consulted.
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
import { dryRunExecutionAdapter } from '@metamynd/agentsafe-guard';
|
|
245
|
+
|
|
246
|
+
// Guard-wide (or set AGENTSAFE_EXECUTION_MODE=dry-run):
|
|
247
|
+
const guard = createGuard({ api, agentDid, agentKey, executionAdapter: dryRunExecutionAdapter });
|
|
248
|
+
|
|
249
|
+
// …or per tool (overrides the guard default):
|
|
250
|
+
const preview = guard.guardTool('flight-purchase', bookFlight, mapArgs, { executionAdapter: dryRunExecutionAdapter });
|
|
251
|
+
await preview({ amount: 100 }); // → { dryRun: true, action, decision, authorizationId, args } — bookFlight NEVER runs
|
|
252
|
+
|
|
253
|
+
// Custom adapter: run the real handler, or substitute it. `proceed()` invokes handler(args, decision).
|
|
254
|
+
const sandboxed = (ctx) => ctx.action === 'flight-purchase' ? sandboxBook(ctx.args) : ctx.proceed();
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Contract: `async ({ action, args, decision, proceed }) => result`. Call `proceed()` to execute for
|
|
258
|
+
real; return without it to substitute the side-effect. Self-check: `node execution-adapter.smoke.mjs`.
|
|
259
|
+
|
|
220
260
|
Signed request fields (`amount`, `merchant`) are always applied over the unsigned `context`, so a
|
|
221
261
|
forged context key can never shadow them (MAGP §6.4.2). Run the self-check:
|
|
222
262
|
|
package/agentsafe-guard.mjs
CHANGED
|
@@ -10,10 +10,52 @@
|
|
|
10
10
|
// The agent's private key is a Hedera Ed25519 DER key (the AGENT_KEY the seed prints).
|
|
11
11
|
import crypto from 'node:crypto';
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
|
-
import { evaluate, buildAuthMessage, applySignedLast } from './policy-core.mjs';
|
|
13
|
+
import { evaluate, buildAuthMessage, applySignedLast, operatingModeGate } from './policy-core.mjs';
|
|
14
14
|
import { verifyDidSignature } from './magp-did.mjs';
|
|
15
15
|
import { checkSettlementBinding } from './x402.mjs';
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* ExecutionAdapter (SAFR §19, Phase-4 PR-4) — the seam between a PERMITTING verdict
|
|
19
|
+
* (allow / observe) and the real side-effect. Before this, a guarded tool called its
|
|
20
|
+
* handler directly, so the only outcomes were "execute for real" or "throw". An adapter
|
|
21
|
+
* interposes so the SAME governed decision can be run live, SIMULATED (dry-run), or routed
|
|
22
|
+
* to a sandbox — without touching the tool handler or the gate.
|
|
23
|
+
*
|
|
24
|
+
* Contract: `async (execCtx) => result`, where
|
|
25
|
+
* execCtx = { action, args, decision, proceed }
|
|
26
|
+
* proceed() runs the real handler (handler(args, decision)) and returns its result.
|
|
27
|
+
* An adapter that calls `proceed()` executes for real; one that returns WITHOUT calling it
|
|
28
|
+
* substitutes the side-effect. Adapters run ONLY after the guard has permitted the action —
|
|
29
|
+
* a block/escalate still throws GovernanceBlocked before any adapter is consulted.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The default: execute the real handler unchanged. */
|
|
33
|
+
export const liveExecutionAdapter = (ctx) => ctx.proceed();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Simulate the side-effect: do NOT call the handler, return a describe-only result. Lets an
|
|
37
|
+
* agent exercise a fully-governed flow (identity → mandate → controls → verdict) with no real
|
|
38
|
+
* booking/payment/write — for staging, canaries, and OBSERVE-mode dry-runs.
|
|
39
|
+
*/
|
|
40
|
+
export const dryRunExecutionAdapter = (ctx) => ({
|
|
41
|
+
dryRun: true,
|
|
42
|
+
action: ctx.action,
|
|
43
|
+
decision: ctx.decision?.decision ?? null,
|
|
44
|
+
reasonCode: ctx.decision?.reasonCode ?? null,
|
|
45
|
+
authorizationId: ctx.decision?.authorizationId ?? null,
|
|
46
|
+
args: ctx.args,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Process-default adapter from `AGENTSAFE_EXECUTION_MODE` ('live' | 'dry-run'). Returns null
|
|
51
|
+
* when unset/live so the caller's own default (live) applies — behavior-neutral by default.
|
|
52
|
+
*/
|
|
53
|
+
export function executionAdapterFromEnv(env = (typeof process !== 'undefined' ? process.env : {})) {
|
|
54
|
+
const mode = String(env.AGENTSAFE_EXECUTION_MODE ?? '').toLowerCase().trim();
|
|
55
|
+
if (mode === 'dry-run' || mode === 'dryrun') return dryRunExecutionAdapter;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
17
59
|
/**
|
|
18
60
|
* @param {{ api: string, agentDid: string, agentKey: string }} cfg
|
|
19
61
|
* api e.g. "http://localhost:9926/api/v1" or "https://metamynd.ai/api/v1"
|
|
@@ -48,6 +90,9 @@ export function createGuard(opts = {}) {
|
|
|
48
90
|
const agentKey = opts.agentKey ?? cfg?.agentKey;
|
|
49
91
|
if (!api || !agentDid || !agentKey) throw new Error('createGuard requires { api, agentDid, agentKey } — directly, or via { config } / { configPath } / createGuardFromConfig()');
|
|
50
92
|
const base = api.replace(/\/$/, '');
|
|
93
|
+
// ExecutionAdapter seam (SAFR §19): an explicit opt wins, else the AGENTSAFE_EXECUTION_MODE env,
|
|
94
|
+
// else live. Applies to every guarded tool unless a tool passes its own adapter.
|
|
95
|
+
const defaultExecutionAdapter = opts.executionAdapter ?? executionAdapterFromEnv() ?? liveExecutionAdapter;
|
|
51
96
|
const privateKey = crypto.createPrivateKey({ key: Buffer.from(agentKey, 'hex'), format: 'der', type: 'pkcs8' });
|
|
52
97
|
|
|
53
98
|
// Ed25519 over the exact canonical message the backend verifies.
|
|
@@ -87,11 +132,13 @@ export function createGuard(opts = {}) {
|
|
|
87
132
|
* agent's authorization trustlessly against the agent's policy bundle (§9.3). Same shape
|
|
88
133
|
* `authorize()` posts to the gate; a fresh nonce each call.
|
|
89
134
|
*/
|
|
90
|
-
function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
|
|
135
|
+
function buildSignedRequest({ action, amount = 0, currency = 'USD', merchant = '', context = {}, trace, materiality }) {
|
|
91
136
|
const nonce = crypto.randomUUID();
|
|
92
137
|
const issuedAt = new Date().toISOString();
|
|
93
138
|
const message = buildAuthMessage({ agentDid, action, amount, currency, merchant, nonce, issuedAt });
|
|
94
|
-
|
|
139
|
+
// trace/materiality are GovernanceEnvelope fields (SAFR §5) — unsigned metadata; the
|
|
140
|
+
// signed message stays the action subset, so verification is unchanged.
|
|
141
|
+
return { agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: sign(message) };
|
|
95
142
|
}
|
|
96
143
|
|
|
97
144
|
/**
|
|
@@ -99,7 +146,7 @@ export function createGuard(opts = {}) {
|
|
|
99
146
|
* returns { decision:'allow'|'block'|'escalate', reasonCode, authorizationId, remaining }.
|
|
100
147
|
* A network/gate failure returns a fail-CLOSED block so the agent can't proceed blind.
|
|
101
148
|
*/
|
|
102
|
-
async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {} }) {
|
|
149
|
+
async function authorize({ action, amount = 0, currency = 'USD', merchant = '', context = {}, trace, materiality }) {
|
|
103
150
|
const nonce = crypto.randomUUID();
|
|
104
151
|
const issuedAt = new Date().toISOString();
|
|
105
152
|
// Build the canonical signed message with policy-core so the guard and the
|
|
@@ -109,7 +156,9 @@ export function createGuard(opts = {}) {
|
|
|
109
156
|
const res = await fetch(`${base}/policy/mandate/authorize`, {
|
|
110
157
|
method: 'POST',
|
|
111
158
|
headers: { 'Content-Type': 'application/json' },
|
|
112
|
-
|
|
159
|
+
// trace/materiality (SAFR §5 envelope) ride as unsigned metadata; JSON.stringify
|
|
160
|
+
// drops them when undefined, so an agent that omits them sends the legacy body.
|
|
161
|
+
body: JSON.stringify({ agentDid, action, amount, currency, merchant, itinerary: context, trace, materiality, nonce, issuedAt, signature: sign(message) }),
|
|
113
162
|
});
|
|
114
163
|
const body = await res.json().catch(() => null);
|
|
115
164
|
return body?.data ?? { decision: 'block', reasonCode: `GATE_HTTP_${res.status}` };
|
|
@@ -147,12 +196,29 @@ export function createGuard(opts = {}) {
|
|
|
147
196
|
* @param {{action:string,amount?:number,merchant?:string,context?:object,cumulativeSpend?:number,now?:string}} p.request
|
|
148
197
|
* @returns {{decision:'allow'|'block'|'escalate',reasonCode:string|null,authorizationId:null,remaining:null,proofRef:null}}
|
|
149
198
|
*/
|
|
150
|
-
function evaluateLocally({ standards = [], sops = [], mandate, request }) {
|
|
199
|
+
function evaluateLocally({ contained = null, operatingMode = null, standards = [], sops = [], mandate, request }) {
|
|
200
|
+
// Push containment (Phase 2.3): a server-CONTAINED agent is denied at the EDGE,
|
|
201
|
+
// before any rule eval. `contained` rides alongside the signed bundle as a SIBLING
|
|
202
|
+
// response field (never inside the signed payload, so the bundle signature stays
|
|
203
|
+
// valid) and is refreshed on the `policy:changed` push, reaching the guard in ~1s.
|
|
204
|
+
if (contained && contained.status) {
|
|
205
|
+
const decision = contained.status === 'quarantined' ? 'quarantine' : 'suspend';
|
|
206
|
+
const reasonCode = contained.status === 'quarantined' ? 'AGENT_QUARANTINED' : 'AGENT_SUSPENDED';
|
|
207
|
+
return { decision, reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
208
|
+
}
|
|
151
209
|
const { action, amount = 0, merchant = '', context = {}, cumulativeSpend = amount, now } = request;
|
|
210
|
+
// Operating-mode autonomy ladder (Phase 2.5b): the trust-driven posture rides as a
|
|
211
|
+
// SIBLING (like `contained`) and biases the edge verdict identically to the gate.
|
|
212
|
+
// READ_ONLY denies a value-bearing action up-front; SUPERVISED/RESTRICTED only
|
|
213
|
+
// ESCALATE, applied to the verdict below so a rule block/escalate still outranks it.
|
|
214
|
+
const modeGate = operatingModeGate(operatingMode?.mode, { amount, riskLevel: context?.riskLevel });
|
|
215
|
+
if (modeGate.decision === 'block') {
|
|
216
|
+
return { decision: 'block', reasonCode: modeGate.reasonCode, authorizationId: null, remaining: null, proofRef: null };
|
|
217
|
+
}
|
|
152
218
|
// Signed fields (action/agentDid/amount, mm:* operands) are applied LAST so an
|
|
153
219
|
// unsigned context key can never shadow them (spec §6.4.2) — the same invariant
|
|
154
220
|
// the gate enforces, via the same policy-core helper.
|
|
155
|
-
|
|
221
|
+
const verdict = evaluate({
|
|
156
222
|
standards,
|
|
157
223
|
sops,
|
|
158
224
|
mandate,
|
|
@@ -169,6 +235,13 @@ export function createGuard(opts = {}) {
|
|
|
169
235
|
}
|
|
170
236
|
: undefined,
|
|
171
237
|
});
|
|
238
|
+
// Mode ESCALATE floor: only lifts an otherwise-PERMIT (allow or observe) to human
|
|
239
|
+
// review (never softens a stricter verdict) — most-restrictive-wins, mirroring the
|
|
240
|
+
// backend gate exactly (escalate outranks observe, so a flag never masks it).
|
|
241
|
+
if ((verdict.decision === 'allow' || verdict.decision === 'observe') && modeGate.decision === 'escalate') {
|
|
242
|
+
return { ...verdict, decision: 'escalate', reasonCode: modeGate.reasonCode };
|
|
243
|
+
}
|
|
244
|
+
return verdict;
|
|
172
245
|
}
|
|
173
246
|
|
|
174
247
|
/** Fetch + cache the agent's signed policy bundle (refreshed per its maxStaleness). */
|
|
@@ -179,6 +252,10 @@ export function createGuard(opts = {}) {
|
|
|
179
252
|
const body = await res.json().catch(() => null);
|
|
180
253
|
const b = body?.data ?? body;
|
|
181
254
|
if (!b || (!b.mandates && !b.sops && !b.standards)) throw new Error(`invalid policy bundle from ${bundleUrl}`);
|
|
255
|
+
// Live containment + operating mode ride as SIBLINGS of the signed bundle (never
|
|
256
|
+
// inside it, so the signature stays valid); stash them on the in-memory copy.
|
|
257
|
+
b.contained = body?.contained ?? null;
|
|
258
|
+
b.operatingMode = body?.operatingMode ?? null;
|
|
182
259
|
_bundle = b;
|
|
183
260
|
_bundleAt = now;
|
|
184
261
|
_bundleMaxAgeMs = _durationMs(b.maxStaleness) ?? _bundleMaxAgeMs;
|
|
@@ -188,6 +265,8 @@ export function createGuard(opts = {}) {
|
|
|
188
265
|
/** Map a fetched bundle into the shape evaluateLocally expects, for one action. */
|
|
189
266
|
function _bundleFor(b, action) {
|
|
190
267
|
return {
|
|
268
|
+
contained: b.contained ?? null,
|
|
269
|
+
operatingMode: b.operatingMode ?? null,
|
|
191
270
|
standards: (b.standards ?? []).map((s) => ({ standardKey: s.id ?? s.standardKey ?? 'standard', document: s.document })).filter((s) => s.document),
|
|
192
271
|
sops: (b.sops ?? []).map((s) => ({ standardKey: s.id ?? s.sopId ?? 'sop', document: s.document })).filter((s) => s.document),
|
|
193
272
|
mandate: ((b.mandates ?? []).find((m) => m.action === action) ?? (b.mandates ?? [])[0])?.document,
|
|
@@ -251,9 +330,11 @@ export function createGuard(opts = {}) {
|
|
|
251
330
|
if (!anchor?.sigDigest || !sig || _sha256(sig) !== anchor.sigDigest) return authorize(input);
|
|
252
331
|
}
|
|
253
332
|
const local = evaluateLocally({ ..._bundleFor(b, action), request: input });
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return local; //
|
|
333
|
+
// allow/observe both PERMIT; block/escalate/contain are decided locally with no network.
|
|
334
|
+
const permits = local.decision === 'allow' || local.decision === 'observe';
|
|
335
|
+
if (!permits) return local; // denied/escalated locally, no network
|
|
336
|
+
if (amount > 0 && sealValueActions) return authorize(input); // seal value action remotely (allow or observe)
|
|
337
|
+
return local; // non-value permit — local is sufficient
|
|
257
338
|
}
|
|
258
339
|
|
|
259
340
|
/** Mode-aware decision used by guardTool: 'local' (default) or 'remote'. */
|
|
@@ -319,7 +400,8 @@ export function createGuard(opts = {}) {
|
|
|
319
400
|
* @param {(args:any)=>{amount?:number,merchant?:string,context?:object}} mapArgs
|
|
320
401
|
* @param {object|((args:any)=>object|Promise<object>)} getBundle { standards, sops, mandate } (or a resolver)
|
|
321
402
|
*/
|
|
322
|
-
function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}) {
|
|
403
|
+
function guardToolLocal(action, handler, mapArgs = (a) => a, getBundle = {}, toolOpts = {}) {
|
|
404
|
+
const adapter = toolOpts.executionAdapter ?? defaultExecutionAdapter;
|
|
323
405
|
return async (args) => {
|
|
324
406
|
let decision;
|
|
325
407
|
try {
|
|
@@ -329,13 +411,19 @@ export function createGuard(opts = {}) {
|
|
|
329
411
|
} catch (err) {
|
|
330
412
|
decision = { decision: 'block', reasonCode: 'LOCAL_EVAL_ERROR', error: String(err?.message ?? err) };
|
|
331
413
|
}
|
|
332
|
-
|
|
414
|
+
// allow/observe both PERMIT execution; observe is permit-but-flag (SAFR §11) — the
|
|
415
|
+
// handler receives the `decision` so a caller can surface/log the observation.
|
|
416
|
+
if (decision.decision !== 'allow' && decision.decision !== 'observe') {
|
|
333
417
|
const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
|
|
334
418
|
err.name = 'GovernanceBlocked';
|
|
335
419
|
err.governance = decision;
|
|
336
420
|
throw err;
|
|
337
421
|
}
|
|
338
|
-
|
|
422
|
+
if (decision.decision === 'observe') {
|
|
423
|
+
console.warn(`[agentsafe] OBSERVE "${action}": ${decision.reasonCode} — permitted under monitoring`);
|
|
424
|
+
}
|
|
425
|
+
// ExecutionAdapter seam (§19): the adapter runs the real handler (proceed) or substitutes it.
|
|
426
|
+
return adapter({ action, args, decision, proceed: () => handler(args, decision) });
|
|
339
427
|
};
|
|
340
428
|
}
|
|
341
429
|
|
|
@@ -350,16 +438,23 @@ export function createGuard(opts = {}) {
|
|
|
350
438
|
* @param {(args:any)=>{amount?:number,currency?:string,merchant?:string,context?:object}} mapArgs
|
|
351
439
|
* maps the tool's call args to the gate inputs (amount/merchant + the context the rules need)
|
|
352
440
|
*/
|
|
353
|
-
function guardTool(action, handler, mapArgs = (a) => a) {
|
|
441
|
+
function guardTool(action, handler, mapArgs = (a) => a, toolOpts = {}) {
|
|
442
|
+
const adapter = toolOpts.executionAdapter ?? defaultExecutionAdapter;
|
|
354
443
|
return async (args) => {
|
|
355
444
|
const decision = await check({ action, ...mapArgs(args) });
|
|
356
|
-
|
|
445
|
+
// allow/observe both PERMIT execution; observe is permit-but-flag (SAFR §11) — the
|
|
446
|
+
// handler receives the `decision` so a caller can surface/log the observation.
|
|
447
|
+
if (decision.decision !== 'allow' && decision.decision !== 'observe') {
|
|
357
448
|
const err = new Error(`AgentSafe ${decision.decision.toUpperCase()} "${action}": ${decision.reasonCode}`);
|
|
358
449
|
err.name = 'GovernanceBlocked';
|
|
359
450
|
err.governance = decision;
|
|
360
451
|
throw err;
|
|
361
452
|
}
|
|
362
|
-
|
|
453
|
+
if (decision.decision === 'observe') {
|
|
454
|
+
console.warn(`[agentsafe] OBSERVE "${action}": ${decision.reasonCode} — permitted under monitoring`);
|
|
455
|
+
}
|
|
456
|
+
// ExecutionAdapter seam (§19): the adapter runs the real handler (proceed) or substitutes it.
|
|
457
|
+
return adapter({ action, args, decision, proceed: () => handler(args, decision) });
|
|
363
458
|
};
|
|
364
459
|
}
|
|
365
460
|
|
|
@@ -443,6 +538,37 @@ export function createGuard(opts = {}) {
|
|
|
443
538
|
}
|
|
444
539
|
}
|
|
445
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Effect-safety runtime (E2): report the external-effect lifecycle so an AMBIGUOUS
|
|
543
|
+
* connector outcome never becomes a blind capture/void. Call effectDispatching() just
|
|
544
|
+
* before the side-effecting call, effectDispatched() when the connector accepts, and —
|
|
545
|
+
* critically — effectUnknown() when the response is lost/timed out (instead of guessing).
|
|
546
|
+
* Once UNKNOWN, capture/void are refused by the gate until the effect is reconciled.
|
|
547
|
+
*/
|
|
548
|
+
async function _effectPost(authorizationId, kind, payload = {}) {
|
|
549
|
+
try {
|
|
550
|
+
const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect/${kind}`, {
|
|
551
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
|
552
|
+
});
|
|
553
|
+
const body = await res.json().catch(() => null);
|
|
554
|
+
return body?.data ?? { ok: false, reasonCode: `GATE_HTTP_${res.status}` };
|
|
555
|
+
} catch (err) {
|
|
556
|
+
return { ok: false, reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const effectDispatching = (authorizationId) => _effectPost(authorizationId, 'dispatching');
|
|
560
|
+
const effectDispatched = (authorizationId, remoteRef) => _effectPost(authorizationId, 'dispatched', { remoteRef });
|
|
561
|
+
const effectUnknown = (authorizationId, reason) => _effectPost(authorizationId, 'unknown', { reason });
|
|
562
|
+
async function effectStatus(authorizationId) {
|
|
563
|
+
try {
|
|
564
|
+
const res = await fetch(`${base}/policy/mandate/authorize/${encodeURIComponent(authorizationId)}/effect`);
|
|
565
|
+
const body = await res.json().catch(() => null);
|
|
566
|
+
return body?.data ?? { effectState: null, reasonCode: `GATE_HTTP_${res.status}` };
|
|
567
|
+
} catch (err) {
|
|
568
|
+
return { effectState: 'unreachable', reasonCode: 'GATE_UNREACHABLE', error: String(err?.message ?? err) };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
446
572
|
/**
|
|
447
573
|
* BYOK proof-of-possession (onboarding proposal #4). For an agent that brought its OWN key,
|
|
448
574
|
* MetaMynd issued the identity with a one-time `challenge` and left the key UNVERIFIED — the gate
|
|
@@ -476,5 +602,5 @@ export function createGuard(opts = {}) {
|
|
|
476
602
|
return sign(challenge);
|
|
477
603
|
}
|
|
478
604
|
|
|
479
|
-
return { authorize, authorizeLocal, check, loadBundle, policyAnchor: _currentAnchor, watchPolicy, mode, verifyOnChain, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, verifyKey, signChallenge, agentDid };
|
|
605
|
+
return { authorize, authorizeLocal, check, loadBundle, policyAnchor: _currentAnchor, watchPolicy, mode, verifyOnChain, buildSignedRequest, capture, guardTool, evaluateLocally, guardToolLocal, handshake, preparePayment, escalationStatus, effectDispatching, effectDispatched, effectUnknown, effectStatus, verifyKey, signChallenge, agentDid, executionAdapter: defaultExecutionAdapter };
|
|
480
606
|
}
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cli.mjs — the package's only executable. Its whole job is to make the offline
|
|
3
|
+
// demo reachable without cloning anything:
|
|
4
|
+
//
|
|
5
|
+
// npx @metamynd/agentsafe-guard demo
|
|
6
|
+
//
|
|
7
|
+
// Kept deliberately thin: no argument parser, no dependencies, no network.
|
|
8
|
+
const [, , cmd] = process.argv;
|
|
9
|
+
|
|
10
|
+
if (cmd === 'demo') {
|
|
11
|
+
await import('./demo.mjs');
|
|
12
|
+
} else if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
13
|
+
console.log(`
|
|
14
|
+
@metamynd/agentsafe-guard — runtime governance for Node AI agents
|
|
15
|
+
|
|
16
|
+
Usage
|
|
17
|
+
npx @metamynd/agentsafe-guard demo Run the offline policy demo
|
|
18
|
+
(no account, no API key, no network)
|
|
19
|
+
|
|
20
|
+
In your agent
|
|
21
|
+
import { createGuard } from '@metamynd/agentsafe-guard';
|
|
22
|
+
|
|
23
|
+
Docs https://www.npmjs.com/package/@metamynd/agentsafe-guard
|
|
24
|
+
Hosted https://metamynd.ai
|
|
25
|
+
`);
|
|
26
|
+
} else {
|
|
27
|
+
console.error(`Unknown command: ${cmd}\nTry: npx @metamynd/agentsafe-guard demo`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
package/demo.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// demo.mjs — the no-account, no-network tour of the guard.
|
|
2
|
+
//
|
|
3
|
+
// npx @metamynd/agentsafe-guard demo
|
|
4
|
+
//
|
|
5
|
+
// Everything here runs locally: an ephemeral Ed25519 key, an inline policy bundle,
|
|
6
|
+
// and `policy-core` — the same evaluator bytes the hosted gate runs. No MetaMynd
|
|
7
|
+
// account, no API key, no Hedera, no outbound request (the `api` URL below is
|
|
8
|
+
// deliberately unroutable, and nothing ever calls it).
|
|
9
|
+
//
|
|
10
|
+
// Exits 0 when every verdict matches what the policy says it should be, 1 otherwise —
|
|
11
|
+
// so this doubles as a smoke test of the shipped package.
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { createGuard } from './agentsafe-guard.mjs';
|
|
14
|
+
|
|
15
|
+
const c = (code, s) => (process.stdout.isTTY ? `\u001b[${code}m${s}\u001b[0m` : s);
|
|
16
|
+
const bold = (s) => c('1', s);
|
|
17
|
+
const dim = (s) => c('2', s);
|
|
18
|
+
const head = (s) => console.log(`\n${bold(s)}\n${dim('─'.repeat(s.length))}`);
|
|
19
|
+
|
|
20
|
+
// Colour per decision — but never colour ALONE: the verdict is always spelled out.
|
|
21
|
+
const paint = { allow: '32', observe: '36', escalate: '33', block: '31', quarantine: '35' };
|
|
22
|
+
const verdict = (d) => c(paint[d] ?? '0', d.toUpperCase().padEnd(10));
|
|
23
|
+
|
|
24
|
+
const { privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
25
|
+
const guard = createGuard({
|
|
26
|
+
api: 'http://unused.local/api/v1', // never contacted — this demo is entirely local
|
|
27
|
+
agentDid: 'did:hedera:testnet:z6MkDemo_0.0.1',
|
|
28
|
+
agentKey: privateKey.export({ format: 'der', type: 'pkcs8' }).toString('hex'),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// The policy an owner would author in the dashboard, as the agent receives it:
|
|
32
|
+
// one enforced Standard, one SOP, and an ODRL mandate.
|
|
33
|
+
const bundle = {
|
|
34
|
+
standards: [
|
|
35
|
+
{
|
|
36
|
+
standardKey: 'eu-ai-act',
|
|
37
|
+
document: {
|
|
38
|
+
molecules: [
|
|
39
|
+
{
|
|
40
|
+
id: 'risk',
|
|
41
|
+
combinator: 'any',
|
|
42
|
+
atoms: [{ id: 'r', predicate: 'risk-at-or-above', config: { level: 'high' } }],
|
|
43
|
+
decision: 'escalate',
|
|
44
|
+
reasonCode: 'RISK_REVIEW',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
sops: [
|
|
51
|
+
{
|
|
52
|
+
standardKey: 'sop:travel',
|
|
53
|
+
document: {
|
|
54
|
+
molecules: [
|
|
55
|
+
{
|
|
56
|
+
id: 'watch',
|
|
57
|
+
combinator: 'any',
|
|
58
|
+
atoms: [{ id: 'w', predicate: 'amount-over', config: { limit: 200 } }],
|
|
59
|
+
decision: 'observe',
|
|
60
|
+
reasonCode: 'WATCH_LARGE',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'cap',
|
|
64
|
+
combinator: 'any',
|
|
65
|
+
atoms: [{ id: 'a', predicate: 'amount-over', config: { limit: 500 } }],
|
|
66
|
+
decision: 'block',
|
|
67
|
+
reasonCode: 'SOP_SPEND_CAP',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'contain',
|
|
71
|
+
combinator: 'any',
|
|
72
|
+
atoms: [{ id: 'q', predicate: 'amount-over', config: { limit: 5000 } }],
|
|
73
|
+
decision: 'quarantine',
|
|
74
|
+
reasonCode: 'GROSS_OVERSPEND',
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
mandate: {
|
|
81
|
+
permission: [
|
|
82
|
+
{
|
|
83
|
+
target: 'flight-purchase',
|
|
84
|
+
constraint: [
|
|
85
|
+
{ leftOperand: 'mm:payAmount', operator: 'lteq', rightOperand: 1000 },
|
|
86
|
+
{ leftOperand: 'mm:cumulativeSpend', operator: 'lteq', rightOperand: 1000 },
|
|
87
|
+
{ leftOperand: 'mm:merchant', operator: 'isAnyOf', rightOperand: ['amadeus'] },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let failed = 0;
|
|
95
|
+
const check = (ok) => { if (!ok) failed++; return ok ? dim('ok') : c('31', 'FAIL'); };
|
|
96
|
+
|
|
97
|
+
const run = (label, request, expect, extra = {}) => {
|
|
98
|
+
const v = guard.evaluateLocally({ ...bundle, ...extra, request });
|
|
99
|
+
const ok = v.decision === expect[0] && v.reasonCode === expect[1];
|
|
100
|
+
console.log(` ${verdict(v.decision)} ${String(v.reasonCode).padEnd(22)} ${label} ${check(ok)}`);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
console.log(bold('\nAgentSafe Guard — local policy evaluation'));
|
|
104
|
+
console.log(dim('No account, no API key, no network. Same policy-core the hosted gate runs.'));
|
|
105
|
+
|
|
106
|
+
head('The policy this agent is under');
|
|
107
|
+
console.log(` Standard ${dim('eu-ai-act')} risk ≥ high → escalate`);
|
|
108
|
+
console.log(` SOP ${dim('sop:travel')} amount > 200 → observe ${dim('(permit, but flag)')}`);
|
|
109
|
+
console.log(` amount > 500 → block`);
|
|
110
|
+
console.log(` amount > 5000 → quarantine ${dim('(contain the agent)')}`);
|
|
111
|
+
console.log(` Mandate ${dim('ODRL')} merchant ∈ [amadeus], ≤ 1000 per action and in total`);
|
|
112
|
+
|
|
113
|
+
head('A tool call, evaluated against it');
|
|
114
|
+
run('$100 to amadeus, low risk', { action: 'flight-purchase', amount: 100, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['allow', 'AUTHORIZED']);
|
|
115
|
+
run('$300 — over the watch line', { action: 'flight-purchase', amount: 300, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['observe', 'WATCH_LARGE']);
|
|
116
|
+
run('$600 — over the SOP cap', { action: 'flight-purchase', amount: 600, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['block', 'SOP_SPEND_CAP']);
|
|
117
|
+
run('$100 but flagged high risk', { action: 'flight-purchase', amount: 100, merchant: 'amadeus', context: { riskLevel: 'high' } }, ['escalate', 'RISK_REVIEW']);
|
|
118
|
+
run('$100 to a merchant off the mandate', { action: 'flight-purchase', amount: 100, merchant: 'sabre', context: { riskLevel: 'low' } }, ['block', 'MERCHANT_NOT_ALLOWED']);
|
|
119
|
+
run('$6000 — gross overspend', { action: 'flight-purchase', amount: 6000, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['quarantine', 'GROSS_OVERSPEND']);
|
|
120
|
+
|
|
121
|
+
head('The agent cannot argue its way out');
|
|
122
|
+
console.log(dim(' Unsigned context is untrusted input — it never shadows the signed amount.'));
|
|
123
|
+
run('$600 claiming "payAmount: 1"', { action: 'flight-purchase', amount: 600, merchant: 'amadeus', context: { 'mm:payAmount': 1, riskLevel: 'low' } }, ['block', 'SOP_SPEND_CAP']);
|
|
124
|
+
console.log(dim(' A quarantined agent is refused at the edge, before any rule is read.'));
|
|
125
|
+
run('$1 while quarantined', { action: 'flight-purchase', amount: 1, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['quarantine', 'AGENT_QUARANTINED'], { contained: { status: 'quarantined', reason: 'GROSS_OVERSPEND' } });
|
|
126
|
+
|
|
127
|
+
head('Operating mode narrows the ladder further');
|
|
128
|
+
const mode = (m) => ({ operatingMode: { mode: m } });
|
|
129
|
+
run('read_only — value action refused', { action: 'flight-purchase', amount: 100, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['block', 'MODE_READ_ONLY'], mode('read_only'));
|
|
130
|
+
run('read_only — a $0 read still passes', { action: 'flight-purchase', amount: 0, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['allow', 'AUTHORIZED'], mode('read_only'));
|
|
131
|
+
run('restricted — spend needs a human', { action: 'flight-purchase', amount: 100, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['escalate', 'MODE_RESTRICTED_REVIEW'], mode('restricted'));
|
|
132
|
+
run('restricted — but a block stays a block', { action: 'flight-purchase', amount: 600, merchant: 'amadeus', context: { riskLevel: 'low' } }, ['block', 'SOP_SPEND_CAP'], mode('restricted'));
|
|
133
|
+
|
|
134
|
+
head('What that means for your tool');
|
|
135
|
+
{
|
|
136
|
+
let ran = false;
|
|
137
|
+
const book = guard.guardToolLocal('flight-purchase', async () => { ran = true; return 'booked'; }, (a) => a, bundle);
|
|
138
|
+
const out = await book({ amount: 300, merchant: 'amadeus', context: { riskLevel: 'low' } });
|
|
139
|
+
console.log(` observe → handler RAN, returned ${JSON.stringify(out)} ${check(ran === true && out === 'booked')}`);
|
|
140
|
+
}
|
|
141
|
+
{
|
|
142
|
+
let ran = false;
|
|
143
|
+
const book = guard.guardToolLocal('flight-purchase', async () => { ran = true; return 'booked'; }, (a) => a, bundle);
|
|
144
|
+
let name = null;
|
|
145
|
+
try { await book({ amount: 600, merchant: 'amadeus', context: { riskLevel: 'low' } }); } catch (e) { name = e?.name; }
|
|
146
|
+
console.log(` block → threw ${name}, handler never ran ${check(name === 'GovernanceBlocked' && ran === false)}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (failed) {
|
|
150
|
+
console.error(c('31', `\n${failed} case(s) FAILED — that is a bug, please report it.`));
|
|
151
|
+
console.error(dim('Please report it — https://metamynd.ai\n'));
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(bold('\nPASS') + dim(' — every verdict matched the policy, decided locally in-process.\n'));
|
|
156
|
+
console.log('Wire it into your own agent:');
|
|
157
|
+
console.log(dim(" import { createGuard } from '@metamynd/agentsafe-guard';"));
|
|
158
|
+
console.log(dim(' const safeBooking = guard.guardToolLocal(\'flight-purchase\', bookFlight, mapArgs, bundle);'));
|
|
159
|
+
console.log(`\n${dim('Local evaluation needs nothing from us. The hosted platform adds live policy')}`);
|
|
160
|
+
console.log(`${dim('editing, cumulative spend caps, human escalation, and anchored evidence:')}`);
|
|
161
|
+
console.log(`${dim('https://metamynd.ai')}\n`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Zero-dependency runtime governance for any Node AI agent — gate tool calls through MetaMynd/AgentSafe (allow / block / escalate) against the agent's mandate, enforced Standards, and SOPs. Ed25519-signed, deterministic, fail-closed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./agentsafe-guard.mjs",
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"./package.json": "./package.json"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
|
+
"demo.mjs",
|
|
15
|
+
"cli.mjs",
|
|
14
16
|
"agentsafe-guard.mjs",
|
|
15
17
|
"policy-core.mjs",
|
|
16
18
|
"magp-did.mjs",
|
|
@@ -20,7 +22,8 @@
|
|
|
20
22
|
"LICENSE"
|
|
21
23
|
],
|
|
22
24
|
"scripts": {
|
|
23
|
-
"test": "node local-eval.smoke.mjs"
|
|
25
|
+
"test": "node demo.mjs && node local-eval.smoke.mjs && node execution-adapter.smoke.mjs",
|
|
26
|
+
"demo": "node demo.mjs"
|
|
24
27
|
},
|
|
25
28
|
"engines": {
|
|
26
29
|
"node": ">=18"
|
|
@@ -44,16 +47,14 @@
|
|
|
44
47
|
],
|
|
45
48
|
"author": "MetaMynd",
|
|
46
49
|
"license": "MIT",
|
|
47
|
-
"homepage": "https://
|
|
48
|
-
"repository": {
|
|
49
|
-
"type": "git",
|
|
50
|
-
"url": "git+https://github.com/jasimp18/AgentSafe.git",
|
|
51
|
-
"directory": "integrations/agentsafe-guard"
|
|
52
|
-
},
|
|
50
|
+
"homepage": "https://metamynd.ai",
|
|
53
51
|
"bugs": {
|
|
54
|
-
"url": "https://
|
|
52
|
+
"url": "https://metamynd.ai/en/support/contact"
|
|
55
53
|
},
|
|
56
54
|
"publishConfig": {
|
|
57
55
|
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"bin": {
|
|
58
|
+
"agentsafe-guard": "./cli.mjs"
|
|
58
59
|
}
|
|
59
60
|
}
|
package/policy-core.mjs
CHANGED
|
@@ -31,6 +31,25 @@ ${c.output ?? ""}`.toLowerCase();
|
|
|
31
31
|
"tool-not-allowed": (c, cfg) => notInAllowList(c.tool, cfg?.allowed),
|
|
32
32
|
"pii-present": (c) => c.piiPresent === true,
|
|
33
33
|
"rate-limit-exceeded": (c, cfg) => typeof c.callCount === "number" && c.callCount > Number(cfg?.max ?? 0),
|
|
34
|
+
// --- Evidence-quality atoms (SAFR §24). Unlike the allow-list atoms, these fire on ABSENCE:
|
|
35
|
+
// a REQUIRE semantic — "the action must be backed by this evidence; if it isn't, fire"
|
|
36
|
+
// (author with escalate/block). Opt-in: they only run when a rule keys them. ---
|
|
37
|
+
// Fires when any REQUIRED evidence type is not among the attested `evidenceTypes` (missing
|
|
38
|
+
// evidence — including none supplied at all → all required missing → fires).
|
|
39
|
+
"evidence-requirement": (c, cfg) => {
|
|
40
|
+
const required = (cfg?.required ?? []).map((t) => String(t).toLowerCase().trim()).filter(Boolean);
|
|
41
|
+
if (required.length === 0) return false;
|
|
42
|
+
const have = new Set((Array.isArray(c.evidenceTypes) ? c.evidenceTypes : []).map((t) => String(t).toLowerCase().trim()));
|
|
43
|
+
return required.some((r) => !have.has(r));
|
|
44
|
+
},
|
|
45
|
+
// Fires when a required minimum confidence (min > 0) is not met — the attested confidence is
|
|
46
|
+
// below it, or absent (a required confidence that was never supplied fails the bar). A min of
|
|
47
|
+
// 0 / unset is no requirement and never fires.
|
|
48
|
+
"evidence-confidence-below": (c, cfg) => {
|
|
49
|
+
const min = Number(cfg?.min ?? 0);
|
|
50
|
+
if (!(min > 0)) return false;
|
|
51
|
+
return typeof c.evidenceConfidence !== "number" || c.evidenceConfidence < min;
|
|
52
|
+
},
|
|
34
53
|
// Trust guidance (MetaMynd Trust Index / HCS-28). Fires when the counterparty's trust score is
|
|
35
54
|
// below a soft REVIEW line — intended to author an ESCALATE (route to a human), NOT a hard block.
|
|
36
55
|
// The score is server-derived (signed-last) so the agent's itinerary can't fake it; when no score
|
|
@@ -145,6 +164,20 @@ var ATOM_SPECS = [
|
|
|
145
164
|
description: "Routes to human review when the counterparty's MetaMynd Trust Index (HCS-28) score is below a soft review line. Guidance, not a hard block \u2014 author it with an ESCALATE decision. The score is resolved server-side; no counterparty score \u2192 the atom does not fire.",
|
|
146
165
|
config: [{ key: "reviewBelow", type: "number", required: true, description: "Trust score (0\u2013100) below which a human is asked to decide" }],
|
|
147
166
|
requiredContext: ["holTrustScore"]
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
predicate: "evidence-requirement",
|
|
170
|
+
label: "Required evidence missing",
|
|
171
|
+
description: "Fires when the action is not backed by every REQUIRED evidence type the agent attests to in `evidenceTypes` (missing evidence \u2014 including none supplied). A REQUIRE control (SAFR \xA724): author it with ESCALATE or BLOCK so an under-evidenced action is stopped or reviewed.",
|
|
172
|
+
config: [{ key: "required", type: "string[]", required: true, description: "Evidence types that must all be present (e.g. kyc, source-doc, signature)" }],
|
|
173
|
+
requiredContext: ["evidenceTypes"]
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
predicate: "evidence-confidence-below",
|
|
177
|
+
label: "Evidence confidence below minimum",
|
|
178
|
+
description: "Fires when the attested evidence confidence is below a required minimum \u2014 or absent (SAFR \xA724). A min of 0 / unset is no requirement. Author with ESCALATE to route low-confidence actions to review.",
|
|
179
|
+
config: [{ key: "min", type: "number", required: true, description: "Minimum evidence confidence (0\u20131) required" }],
|
|
180
|
+
requiredContext: ["evidenceConfidence"]
|
|
148
181
|
}
|
|
149
182
|
];
|
|
150
183
|
var CATALOGUED_ATOMS = ATOM_SPECS.filter((s) => !!ATOM_REGISTRY[s.predicate]);
|
|
@@ -158,7 +191,7 @@ function requiredContextFor(predicates) {
|
|
|
158
191
|
}
|
|
159
192
|
|
|
160
193
|
// src/policy-core/standards-rules.ts
|
|
161
|
-
var PRECEDENCE = { allow: 0,
|
|
194
|
+
var PRECEDENCE = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5 };
|
|
162
195
|
function atomFires(atom, ctx) {
|
|
163
196
|
const pred = ATOM_REGISTRY[atom.predicate];
|
|
164
197
|
if (!pred) return false;
|
|
@@ -244,8 +277,8 @@ function validateMolecules(molecules) {
|
|
|
244
277
|
if (!["all", "any", "none"].includes(m.combinator)) {
|
|
245
278
|
issues.push({ moleculeId: m.id, message: `invalid combinator '${m.combinator}' (all|any|none)` });
|
|
246
279
|
}
|
|
247
|
-
if (!["block", "escalate"].includes(m.decision)) {
|
|
248
|
-
issues.push({ moleculeId: m.id, message: `invalid decision '${m.decision}' (block|escalate)` });
|
|
280
|
+
if (!["observe", "block", "escalate", "suspend", "quarantine"].includes(m.decision)) {
|
|
281
|
+
issues.push({ moleculeId: m.id, message: `invalid decision '${m.decision}' (observe|block|escalate|suspend|quarantine)` });
|
|
249
282
|
}
|
|
250
283
|
if (!m.reasonCode) issues.push({ moleculeId: m.id, message: "molecule is missing a reasonCode" });
|
|
251
284
|
if (!m.atoms || m.atoms.length === 0) {
|
|
@@ -359,7 +392,7 @@ function sumEventField(events, type, field) {
|
|
|
359
392
|
}
|
|
360
393
|
|
|
361
394
|
// src/policy-core/evaluate.ts
|
|
362
|
-
var PRECEDENCE2 = { allow: 0,
|
|
395
|
+
var PRECEDENCE2 = { allow: 0, observe: 1, escalate: 2, block: 3, suspend: 4, quarantine: 5 };
|
|
363
396
|
function evaluate(input) {
|
|
364
397
|
let decision = "allow";
|
|
365
398
|
let reasonCode = "AUTHORIZED";
|
|
@@ -389,20 +422,66 @@ function buildAuthMessage(f) {
|
|
|
389
422
|
function applySignedLast(unsigned, signed) {
|
|
390
423
|
return { ...unsigned ?? {}, ...signed };
|
|
391
424
|
}
|
|
425
|
+
|
|
426
|
+
// src/policy-core/operating-mode.ts
|
|
427
|
+
var MODE_RANK = {
|
|
428
|
+
read_only: 0,
|
|
429
|
+
restricted: 1,
|
|
430
|
+
supervised: 2,
|
|
431
|
+
autonomous: 3
|
|
432
|
+
};
|
|
433
|
+
var MODES_BY_RANK = ["read_only", "restricted", "supervised", "autonomous"];
|
|
434
|
+
function isOperatingMode(v) {
|
|
435
|
+
return typeof v === "string" && Object.prototype.hasOwnProperty.call(MODE_RANK, v);
|
|
436
|
+
}
|
|
437
|
+
function asOperatingMode(v) {
|
|
438
|
+
return isOperatingMode(v) ? v : "autonomous";
|
|
439
|
+
}
|
|
440
|
+
function moreRestrictive(a, b) {
|
|
441
|
+
return MODE_RANK[a] <= MODE_RANK[b] ? a : b;
|
|
442
|
+
}
|
|
443
|
+
var SUPERVISED_AMOUNT_CAP = 100;
|
|
444
|
+
var RISK_RANK2 = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
445
|
+
function riskAtOrAboveHigh(riskLevel) {
|
|
446
|
+
const r = typeof riskLevel === "string" ? RISK_RANK2[riskLevel.toLowerCase()] : void 0;
|
|
447
|
+
return r !== void 0 && r >= RISK_RANK2.high;
|
|
448
|
+
}
|
|
449
|
+
function operatingModeGate(mode, ctx) {
|
|
450
|
+
const m = asOperatingMode(mode);
|
|
451
|
+
const valueBearing = (ctx.amount ?? 0) > 0;
|
|
452
|
+
if (!valueBearing || m === "autonomous") return { decision: "allow", reasonCode: null };
|
|
453
|
+
switch (m) {
|
|
454
|
+
case "read_only":
|
|
455
|
+
return { decision: "block", reasonCode: "MODE_READ_ONLY" };
|
|
456
|
+
case "restricted":
|
|
457
|
+
return { decision: "escalate", reasonCode: "MODE_RESTRICTED_REVIEW" };
|
|
458
|
+
case "supervised":
|
|
459
|
+
return riskAtOrAboveHigh(ctx.riskLevel) || (ctx.amount ?? 0) >= SUPERVISED_AMOUNT_CAP ? { decision: "escalate", reasonCode: "MODE_SUPERVISED_REVIEW" } : { decision: "allow", reasonCode: null };
|
|
460
|
+
default:
|
|
461
|
+
return { decision: "allow", reasonCode: null };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
392
464
|
export {
|
|
393
465
|
ATOM_REGISTRY,
|
|
394
466
|
ATOM_SPECS,
|
|
395
467
|
CATALOGUED_ATOMS,
|
|
468
|
+
MODES_BY_RANK,
|
|
469
|
+
MODE_RANK,
|
|
470
|
+
SUPERVISED_AMOUNT_CAP,
|
|
396
471
|
applyCapture,
|
|
397
472
|
applyHold,
|
|
398
473
|
applySignedLast,
|
|
474
|
+
asOperatingMode,
|
|
399
475
|
buildAuthMessage,
|
|
400
476
|
canAuthorize,
|
|
401
477
|
evaluate,
|
|
402
478
|
evaluateBoundStandards,
|
|
403
479
|
evaluateMandate,
|
|
404
480
|
evaluateStandardRules,
|
|
481
|
+
isOperatingMode,
|
|
405
482
|
moleculeFires,
|
|
483
|
+
moreRestrictive,
|
|
484
|
+
operatingModeGate,
|
|
406
485
|
releaseHold,
|
|
407
486
|
remainingBudget,
|
|
408
487
|
requiredContextFor,
|