@decionis/agent-safe-pipeline 0.1.3 → 0.1.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 +318 -16
- package/dist/Index.d.ts +1 -0
- package/dist/Index.d.ts.map +1 -1
- package/dist/Index.js +1 -0
- package/dist/Index.js.map +1 -1
- package/dist/approval/PresenceApprovalCoordinator.d.ts +25 -0
- package/dist/approval/PresenceApprovalCoordinator.d.ts.map +1 -1
- package/dist/approval/PresenceApprovalCoordinator.js +54 -6
- package/dist/approval/PresenceApprovalCoordinator.js.map +1 -1
- package/dist/audit/AuditRecorder.d.ts +122 -0
- package/dist/audit/AuditRecorder.d.ts.map +1 -0
- package/dist/audit/AuditRecorder.js +312 -0
- package/dist/audit/AuditRecorder.js.map +1 -0
- package/dist/decision/DecionisGate.d.ts +91 -2
- package/dist/decision/DecionisGate.d.ts.map +1 -1
- package/dist/decision/DecionisGate.js +793 -44
- package/dist/decision/DecionisGate.js.map +1 -1
- package/dist/decision/DecisionAuthority.d.ts +50 -1
- package/dist/decision/DecisionAuthority.d.ts.map +1 -1
- package/dist/decision/DecisionAuthority.js.map +1 -1
- package/dist/decision/FixtureDecisionAuthority.d.ts +27 -1
- package/dist/decision/FixtureDecisionAuthority.d.ts.map +1 -1
- package/dist/decision/FixtureDecisionAuthority.js +47 -0
- package/dist/decision/FixtureDecisionAuthority.js.map +1 -1
- package/dist/decision/ImmutableGateDecision.d.ts.map +1 -1
- package/dist/decision/ImmutableGateDecision.js +18 -0
- package/dist/decision/ImmutableGateDecision.js.map +1 -1
- package/dist/execution/ActionRegistry.d.ts +37 -0
- package/dist/execution/ActionRegistry.d.ts.map +1 -1
- package/dist/execution/ActionRegistry.js +75 -1
- package/dist/execution/ActionRegistry.js.map +1 -1
- package/dist/execution/AuthorizationVerifier.d.ts +29 -0
- package/dist/execution/AuthorizationVerifier.d.ts.map +1 -1
- package/dist/execution/AuthorizationVerifier.js +126 -25
- package/dist/execution/AuthorizationVerifier.js.map +1 -1
- package/dist/execution/SafeExecutor.d.ts +72 -7
- package/dist/execution/SafeExecutor.d.ts.map +1 -1
- package/dist/execution/SafeExecutor.js +253 -17
- package/dist/execution/SafeExecutor.js.map +1 -1
- package/dist/intent/CanonicalIntentHasher.d.ts +1 -1
- package/dist/intent/CanonicalIntentHasher.d.ts.map +1 -1
- package/dist/intent/CanonicalIntentHasher.js +6 -2
- package/dist/intent/CanonicalIntentHasher.js.map +1 -1
- package/dist/intent/ExecutionIntent.d.ts +14 -2
- package/dist/intent/ExecutionIntent.d.ts.map +1 -1
- package/dist/intent/ExecutionIntent.js +7 -0
- package/dist/intent/ExecutionIntent.js.map +1 -1
- package/dist/intent/IntentCapture.d.ts +5 -0
- package/dist/intent/IntentCapture.d.ts.map +1 -1
- package/dist/intent/IntentCapture.js +12 -1
- package/dist/intent/IntentCapture.js.map +1 -1
- package/dist/shadow/ShadowPipeline.d.ts +75 -5
- package/dist/shadow/ShadowPipeline.d.ts.map +1 -1
- package/dist/shadow/ShadowPipeline.js +157 -7
- package/dist/shadow/ShadowPipeline.js.map +1 -1
- package/dist/testing/Index.d.ts +12 -0
- package/dist/testing/Index.d.ts.map +1 -0
- package/dist/testing/Index.js +12 -0
- package/dist/testing/Index.js.map +1 -0
- package/dist/testing/LocalAuthority.d.ts +203 -0
- package/dist/testing/LocalAuthority.d.ts.map +1 -0
- package/dist/testing/LocalAuthority.js +799 -0
- package/dist/testing/LocalAuthority.js.map +1 -0
- package/dist/testing/LocalPresence.d.ts +210 -0
- package/dist/testing/LocalPresence.d.ts.map +1 -0
- package/dist/testing/LocalPresence.js +473 -0
- package/dist/testing/LocalPresence.js.map +1 -0
- package/package.json +40 -4
package/README.md
CHANGED
|
@@ -1,28 +1,83 @@
|
|
|
1
1
|
# `@decionis/agent-safe-pipeline`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@decionis/agent-safe-pipeline)
|
|
4
|
+
[](https://github.com/decionis/agent-safe-pipeline/actions/workflows/deploy.yml)
|
|
5
|
+
[](https://scorecard.dev/viewer/?uri=github.com/decionis/agent-safe-pipeline)
|
|
6
|
+
[](https://www.bestpractices.dev/projects/14098)
|
|
7
|
+
[](https://github.com/decionis/agent-safe-pipeline/blob/master/LICENSE)
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
**Let agents propose. Let policy decide.**
|
|
10
|
+
|
|
11
|
+
`@decionis/agent-safe-pipeline` is the TypeScript reference implementation of the Execution
|
|
12
|
+
Authority architecture. An AI agent may reason, plan, and propose a consequential action. It cannot
|
|
13
|
+
authorize that action, hold the credentials that perform it, or choose which trusted code runs. The
|
|
14
|
+
exact proposal is captured as an immutable, short-lived intent, evaluated independently by Decionis,
|
|
15
|
+
escalated to a verified human when policy requires it, and executed only through a single-use grant
|
|
16
|
+
bound to that one intent. Every decision leaves a Decision Dossier, so the record of what was
|
|
17
|
+
authorized, under which policy, and on whose approval compounds over time.
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
Agent -> immutable intent -> Decionis -> ALLOW / ESCALATE / BLOCK -> SafeExecutor -> downstream API
|
|
21
|
+
|
|
|
22
|
+
+-> Presence -> verified human approval -> Decionis re-evaluation
|
|
23
|
+
|
|
|
24
|
+
+-> Decision Dossier -> compounding decision record
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The package is a library, not a hosted service. It supplies the boundary; Decionis supplies the
|
|
28
|
+
decision. Its safety claims hold only when the documented trust boundary is preserved: the agent
|
|
29
|
+
runtime never sees Decionis credentials, downstream credentials, or the handler registry.
|
|
30
|
+
|
|
31
|
+
## Why an execution boundary
|
|
32
|
+
|
|
33
|
+
Model-level controls shape what an agent says. They cannot prove, after the fact, that a specific
|
|
34
|
+
side effect was authorized by a specific policy at the moment it happened. Prompt filtering,
|
|
35
|
+
fine-tuning, and evaluation all sit before the action; the gap is at execution. This package closes
|
|
36
|
+
it structurally:
|
|
37
|
+
|
|
38
|
+
- **The agent's input is only the proposal.** Action, target, and parameters. Tenant, actor,
|
|
39
|
+
downstream system, and credentials come from trusted server configuration and cannot be injected.
|
|
40
|
+
- **The decision is made elsewhere.** Decionis evaluates the canonical intent hash and returns
|
|
41
|
+
`ALLOW`, `ESCALATE`, or `BLOCK` with a decision identifier and a Decision Dossier identifier.
|
|
42
|
+
- **Approval is evidence, never authority.** Presence proves that a real person approved that exact
|
|
43
|
+
intent. Decionis verifies the receipt and re-evaluates current policy before any grant exists.
|
|
44
|
+
- **Execution consumes a grant, not a callback.** `SafeExecutor` accepts a captured intent and a
|
|
45
|
+
decision. It claims the grant atomically, then invokes a handler from a sealed registry.
|
|
46
|
+
- **Everything else fails closed.** A network error, a malformed response, a missing grant, an
|
|
47
|
+
expired intent, a replayed token, or a binding mismatch all result in no execution.
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
6
50
|
|
|
7
|
-
|
|
51
|
+
- Node.js 22.14 or later. The package is ESM-only and ships its own TypeScript declarations.
|
|
52
|
+
- A server-side process that holds the Decionis credentials. Never load this package into a
|
|
53
|
+
browser, an agent sandbox, or any runtime the model can influence.
|
|
54
|
+
- `zod` v4 for handler parameter schemas (installed as a dependency).
|
|
55
|
+
|
|
56
|
+
## Install
|
|
8
57
|
|
|
9
58
|
```bash
|
|
10
59
|
npm install @decionis/agent-safe-pipeline
|
|
11
60
|
```
|
|
12
61
|
|
|
13
|
-
|
|
62
|
+
Stable releases publish under the `latest` tag with npm provenance. Prereleases publish under the
|
|
63
|
+
`next` tag and are never installed by default:
|
|
14
64
|
|
|
15
65
|
```bash
|
|
16
|
-
npm install @decionis/agent-safe-pipeline@
|
|
66
|
+
npm install @decionis/agent-safe-pipeline@next
|
|
17
67
|
```
|
|
18
68
|
|
|
19
|
-
|
|
69
|
+
Decionis credentials belong only in the trusted executor process:
|
|
20
70
|
|
|
21
71
|
```text
|
|
22
72
|
DECIONIS_API_URL=https://api.decionis.com
|
|
23
73
|
DECIONIS_API_KEY=server-side-secret
|
|
24
74
|
```
|
|
25
75
|
|
|
76
|
+
## Quick start: enforcement
|
|
77
|
+
|
|
78
|
+
The complete production path in one file. The proposal comes from the agent; everything else comes
|
|
79
|
+
from trusted configuration.
|
|
80
|
+
|
|
26
81
|
```ts
|
|
27
82
|
import {
|
|
28
83
|
ActionRegistry,
|
|
@@ -33,17 +88,37 @@ import {
|
|
|
33
88
|
} from "@decionis/agent-safe-pipeline";
|
|
34
89
|
import { z } from "zod";
|
|
35
90
|
|
|
91
|
+
// 1. Capture the exact proposal. The agent supplies only action, target, and parameters.
|
|
92
|
+
const captured = new IntentCapture().capture(
|
|
93
|
+
{
|
|
94
|
+
action: "refund_order",
|
|
95
|
+
target: "shopify:order:1001",
|
|
96
|
+
parameters: { orderId: "1001", amountMinor: 35_000 },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
tenantId: config.tenantId,
|
|
100
|
+
actor: { id: "refund-agent", type: "AI_AGENT" },
|
|
101
|
+
downstreamTarget: { system: "shopify", operation: "refund", environment: "production" },
|
|
102
|
+
idempotencyKey: "refund-1001-v1",
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// 2. Ask Decionis. The gate never returns an executable decision without a grant.
|
|
36
107
|
const gate = new DecionisGate({
|
|
37
108
|
baseUrl: process.env.DECIONIS_API_URL!,
|
|
38
109
|
apiKey: process.env.DECIONIS_API_KEY!,
|
|
39
110
|
});
|
|
40
|
-
const
|
|
111
|
+
const decision = await gate.evaluate(captured);
|
|
112
|
+
|
|
113
|
+
// 3. Register trusted handlers once, then seal the registry so nothing can be added at runtime.
|
|
41
114
|
const registry = new ActionRegistry()
|
|
42
115
|
.register("refund_order", {
|
|
43
116
|
parametersSchema: z.object({ orderId: z.string(), amountMinor: z.number().int() }).strict(),
|
|
44
117
|
execute: ({ parameters }) => shopify.refund(parameters),
|
|
45
118
|
})
|
|
46
119
|
.seal();
|
|
120
|
+
|
|
121
|
+
// 4. Execute only through a claimed single-use grant bound to this intent.
|
|
47
122
|
const executor = new SafeExecutor(
|
|
48
123
|
registry,
|
|
49
124
|
new DecionisGrantVerifier({
|
|
@@ -51,16 +126,243 @@ const executor = new SafeExecutor(
|
|
|
51
126
|
apiKey: process.env.DECIONIS_API_KEY!,
|
|
52
127
|
}),
|
|
53
128
|
);
|
|
54
|
-
const result = await executor.run(captured,
|
|
129
|
+
const result = await executor.run(captured, decision);
|
|
130
|
+
|
|
131
|
+
if (result.outcome === "COMPLETED") {
|
|
132
|
+
// result.result is the handler's return value.
|
|
133
|
+
// result.authorization carries the consumed { decisionId, dossierId, grantId, intentHash }.
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The executor validates parameters against the handler's schema before the grant is claimed, and the
|
|
138
|
+
grant is claimed before the handler runs. If either step fails, the handler is never invoked.
|
|
139
|
+
|
|
140
|
+
## Outcomes
|
|
141
|
+
|
|
142
|
+
Decionis returns one of three verdicts. The executor turns a verdict into exactly one execution
|
|
143
|
+
outcome.
|
|
144
|
+
|
|
145
|
+
| Verdict | Executor behavior |
|
|
146
|
+
| ------------------------------------- | ----------------------------------------------------------------------- |
|
|
147
|
+
| `ALLOW` with a valid single-use grant | Claim the grant atomically, then invoke the registered handler |
|
|
148
|
+
| `ESCALATE` | Stop. Resolve a DIRECT or MANAGED Presence escalation, then re-evaluate |
|
|
149
|
+
| `BLOCK`, any error, any mismatch | Fail closed. The handler is never invoked |
|
|
150
|
+
|
|
151
|
+
`SafeExecutor.run` resolves to a discriminated result rather than throwing:
|
|
152
|
+
|
|
153
|
+
| `outcome` | Meaning |
|
|
154
|
+
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
155
|
+
| `COMPLETED` | The grant was consumed and the handler returned. `result` holds its value. |
|
|
156
|
+
| `BLOCKED` | Nothing ran. `reason` names the refusal: a non-authoritative or non-`ALLOW` decision, a missing or invalid grant, an intent binding or conformance failure, or an unavailable audit sink. |
|
|
157
|
+
| `FAILED_BEFORE_DISPATCH` | The grant was consumed but the handler failed before reaching the provider. Safe to reason about. |
|
|
158
|
+
| `UNKNOWN_AFTER_DISPATCH` | The provider was called and its outcome is unknown. A `recovery` reference supports reconciliation. |
|
|
159
|
+
|
|
160
|
+
Every outcome that consumed a grant also reports `finalization` (`RECORDED`, `PENDING`, or
|
|
161
|
+
`UNSUPPORTED`). The executor records `COMMITTED`, `FAILED`, or `INDETERMINATE` with Decionis after
|
|
162
|
+
the attempt so commit evidence joins the Decision Dossier chain. Finalization is evidence, never
|
|
163
|
+
authority: it cannot change `outcome` or `executed`.
|
|
164
|
+
|
|
165
|
+
## Human approval through Presence
|
|
166
|
+
|
|
167
|
+
When policy escalates, a person must approve that exact intent with independently signed evidence.
|
|
168
|
+
Presence delivers the request to an enrolled device and returns a receipt bound to the intent hash.
|
|
169
|
+
Presence never authorizes execution; Decionis verifies the receipt and re-evaluates policy. The
|
|
170
|
+
package supports two integration levels, and the executor's grant path is identical in both.
|
|
171
|
+
|
|
172
|
+
| Mode | Who coordinates Presence | Credentials in the executor | Entry point |
|
|
173
|
+
| --------- | ------------------------ | --------------------------- | ---------------------------------------------------- |
|
|
174
|
+
| `DIRECT` | Your trusted executor | Decionis and Presence | `PresenceApprovalCoordinator` |
|
|
175
|
+
| `MANAGED` | Decionis | Decionis only | `DecionisGate.evaluate` with an `escalation` request |
|
|
176
|
+
|
|
177
|
+
In MANAGED mode, pass routing and ceremony constraints outside the canonical intent, then poll
|
|
178
|
+
Decionis only:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
const pending = await gate.evaluate(captured, undefined, {
|
|
182
|
+
escalation: {
|
|
183
|
+
mode: "MANAGED",
|
|
184
|
+
approver: { principal_id: approverId, role_id: "APPROVER" },
|
|
185
|
+
verification_requirements: { methods: ["WEBAUTHN"], level: "HIGH_CONFIDENCE" },
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
// pending.verdict === "ESCALATE"; pending.managedEscalation is set; no grant exists yet.
|
|
189
|
+
|
|
190
|
+
const authorized = await gate.waitForAuthorization(captured, pending, { signal });
|
|
191
|
+
const result = await executor.run(captured, authorized);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`waitForAuthorization` polls with capped exponential backoff and bounded jitter, and stops at the
|
|
195
|
+
intent or escalation expiry. It returns a normal `ALLOW` decision with a grant only after Decionis
|
|
196
|
+
has verified the Presence evidence and re-evaluated current policy. Approval cannot revive an intent
|
|
197
|
+
after it expires. Presence transport or schema failures and Decionis re-authorization failures
|
|
198
|
+
return stable fail-closed decisions; raw downstream error text never reaches the caller.
|
|
199
|
+
|
|
200
|
+
## Shadow mode
|
|
201
|
+
|
|
202
|
+
Measure before you enforce. `ShadowPipeline` wraps an execution path you already run and records
|
|
203
|
+
what Decionis would have decided, without the ability to stop, delay, or alter it.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { DecionisGate, ShadowPipeline } from "@decionis/agent-safe-pipeline";
|
|
207
|
+
|
|
208
|
+
const shadow = new ShadowPipeline(new DecionisGate({ baseUrl, apiKey, mode: "SHADOW" }), {
|
|
209
|
+
timeoutMs: 2_000,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const run = await shadow.observe(captured, () => existingRefund(order));
|
|
213
|
+
// run.production is your unchanged result, available as soon as production settles.
|
|
214
|
+
const observation = await run.observation;
|
|
215
|
+
// The observation runs under its own timeout and never rejects; it reports the verdict
|
|
216
|
+
// Decionis would have returned, the dossier identifier, and whether a grant was discarded.
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
A shadow observation carries no grant, is structurally distinct from a `GateDecision`, and is
|
|
220
|
+
rejected by `SafeExecutor` at runtime. See
|
|
221
|
+
[shadow mode](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/shadow-mode.md).
|
|
222
|
+
|
|
223
|
+
## Adoption path
|
|
224
|
+
|
|
225
|
+
The same `IntentCapture`, `ActionRegistry`, and handler code carry through every stage. Nothing is
|
|
226
|
+
rewritten between them.
|
|
227
|
+
|
|
228
|
+
| Stage | Authority | What it proves |
|
|
229
|
+
| ----------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
230
|
+
| Development | `createFixtureAuthorityPair` (refuses `NODE_ENV=production`) | The intent, registry, and executor wiring is correct |
|
|
231
|
+
| Shadow | `ShadowPipeline` over `DecionisGate` with `mode: "SHADOW"` | What Decionis would have decided about actions that already run; no grant issued |
|
|
232
|
+
| Enforcement | `DecionisGate` plus `DecionisGrantVerifier` in `SafeExecutor` | Nothing runs without an independent decision and a consumed single-use grant |
|
|
233
|
+
|
|
234
|
+
## Local testing
|
|
235
|
+
|
|
236
|
+
`@decionis/agent-safe-pipeline/testing` ships `LocalPresence` and `LocalAuthority`: loopback doubles
|
|
237
|
+
that the production clients talk to unchanged. They enforce the structural intent-hash binding,
|
|
238
|
+
verify receipts the way Decionis does, issue single-use grants, orchestrate managed escalations, and
|
|
239
|
+
record finalization. The person's ceremony becomes a method call.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import { DecionisGate, DecionisGrantVerifier } from "@decionis/agent-safe-pipeline";
|
|
243
|
+
import {
|
|
244
|
+
LocalAuthority,
|
|
245
|
+
LocalPresence,
|
|
246
|
+
LOCAL_AUTHORITY_API_KEY,
|
|
247
|
+
} from "@decionis/agent-safe-pipeline/testing";
|
|
248
|
+
|
|
249
|
+
const presence = new LocalPresence({ autoComplete: "MANUAL", roles: { "synthetic-cro": "CRO" } });
|
|
250
|
+
const authority = new LocalAuthority({ presence });
|
|
251
|
+
await presence.start();
|
|
252
|
+
await authority.start();
|
|
253
|
+
|
|
254
|
+
const gate = new DecionisGate({
|
|
255
|
+
baseUrl: authority.baseUrl,
|
|
256
|
+
apiKey: LOCAL_AUTHORITY_API_KEY,
|
|
257
|
+
allowInsecureLoopback: true,
|
|
258
|
+
});
|
|
259
|
+
// ... evaluate, then complete the ceremony with presence.approve(requestId)
|
|
260
|
+
// and assert on grants, receipts, and recorded commits.
|
|
55
261
|
```
|
|
56
262
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
263
|
+
Both doubles bind to `127.0.0.1` on an ephemeral port and refuse to construct under
|
|
264
|
+
`NODE_ENV=production`. The testing entry also exports the development fixture primitives
|
|
265
|
+
(`createFixtureAuthorityPair`, `FixtureDecisionAuthority`, `FixtureAuthorizationVerifier`,
|
|
266
|
+
`InMemoryReplayStore`). Those remain available at the package root until 1.0; new code should import
|
|
267
|
+
them from the testing entry. See
|
|
268
|
+
[local testing](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/local-testing.md).
|
|
269
|
+
|
|
270
|
+
## API overview
|
|
271
|
+
|
|
272
|
+
| Concern | Exports | Role |
|
|
273
|
+
| -------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
274
|
+
| Intent | `IntentCapture`, `CanonicalIntentHasher`, `ExecutionIntentSchema`, `AgentProposalSchema` | Build the immutable `agent-safe.intent/1` binding and its canonical SHA-256 hash |
|
|
275
|
+
| Decision | `DecionisGate`, `DecisionAuthority`, `GateDecision`, `FailClosedDecision` | Obtain an independent `ALLOW` / `ESCALATE` / `BLOCK` decision with dossier identifiers |
|
|
276
|
+
| Human approval | `PresenceApprovalCoordinator`, `ManagedEscalationRequest`, `HumanApprovalEvidence` | Coordinate DIRECT Presence ceremonies or request MANAGED orchestration by Decionis |
|
|
277
|
+
| Execution | `SafeExecutor`, `ActionRegistry`, `DecionisGrantVerifier`, `AuthorizationVerifier`, `ReplayStore` | Claim the single-use grant, validate parameters, invoke a sealed handler, finalize the attempt |
|
|
278
|
+
| Observation | `ShadowPipeline`, `ShadowObservation` | Record what the authority would have decided without granting execution |
|
|
279
|
+
| Audit | `AuditRecorder`, `AuditEventV1`, `AuditSink` | Emit immutable, redacted lifecycle records through one bounded sink call |
|
|
280
|
+
| Testing | `LocalPresence`, `LocalAuthority`, `createFixtureAuthorityPair` (from `/testing`) | Loopback doubles and fixture authorities for development and CI |
|
|
281
|
+
|
|
282
|
+
The seam between this package and Decionis is two interfaces, `DecisionAuthority` and
|
|
283
|
+
`AuthorizationVerifier`, plus a published OpenAPI contract. Anyone can implement the interfaces; the
|
|
284
|
+
library checks no plan, key, or entitlement.
|
|
285
|
+
|
|
286
|
+
## Production invariants
|
|
287
|
+
|
|
288
|
+
1. Agent input contains only the proposed action, target, and parameters. Tenant, actor, downstream
|
|
289
|
+
target, and credentials come from trusted runtime configuration.
|
|
290
|
+
2. The exact canonical intent is hashed and expires quickly.
|
|
291
|
+
3. Decionis decides independently. Network errors, malformed responses, missing grants, and binding
|
|
292
|
+
mismatches fail closed.
|
|
293
|
+
4. Presence proves a human approved that exact intent. It never authorizes execution; Decionis
|
|
294
|
+
verifies the receipt and re-evaluates policy.
|
|
295
|
+
5. The grant is bound to the intent, decision, audience, and expiry, and is claimed atomically before
|
|
296
|
+
the handler runs. The attempt is finalized afterwards as evidence, never as authority.
|
|
297
|
+
6. Downstream credentials exist only behind the trusted executor.
|
|
298
|
+
7. Every decision is evidence-bearing. An `ALLOW` without a dossier identifier or grant is refused
|
|
299
|
+
as non-executable. A dossier identifier is never an execution credential.
|
|
300
|
+
|
|
301
|
+
Read the [trust boundary](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/trust-boundary.md)
|
|
302
|
+
and the [threat model](https://github.com/decionis/agent-safe-pipeline/blob/master/THREAT-MODEL.md)
|
|
303
|
+
before integrating a real downstream API.
|
|
304
|
+
|
|
305
|
+
## Assurance and supply chain
|
|
306
|
+
|
|
307
|
+
- **Provenance.** Every release is published through npm trusted publishing with a provenance
|
|
308
|
+
attestation, from a keyless-signed release tag, and archived under Zenodo concept DOI
|
|
309
|
+
[`10.5281/zenodo.22312955`](https://doi.org/10.5281/zenodo.22312955).
|
|
310
|
+
- **Release evidence.** Each GitHub release carries the tarball, a CycloneDX SBOM, Sigstore
|
|
311
|
+
provenance and SBOM attestations, and a checksum file. See
|
|
312
|
+
[reproducible builds](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/reproducible-builds.md).
|
|
313
|
+
- **Testing.** Coverage gates of 90% lines, functions, and statements and 85% branches; mutation
|
|
314
|
+
testing on the trust boundary; deterministic property-based fuzzing of canonical intent handling;
|
|
315
|
+
a loopback wire-contract harness that exercises the packed package over real HTTP.
|
|
316
|
+
- **Adversarial proof.** The
|
|
317
|
+
[golden adversarial demo](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/golden-adversarial-demo)
|
|
318
|
+
runs one legitimate path and eight attacks against the same boundary, offline, and exits 0 only
|
|
319
|
+
when exactly one action executes.
|
|
320
|
+
- **Scanning.** CodeQL, secret scanning, OpenSSF Scorecard, and OpenSSF Best Practices, with
|
|
321
|
+
separate production and toolchain dependency audits. The control-to-artifact map is in
|
|
322
|
+
[SECURITY-EVIDENCE.md](https://github.com/decionis/agent-safe-pipeline/blob/master/SECURITY-EVIDENCE.md).
|
|
323
|
+
|
|
324
|
+
## Examples
|
|
325
|
+
|
|
326
|
+
Runnable, offline, and fixture-backed unless noted. Each one uses this package unchanged.
|
|
327
|
+
|
|
328
|
+
- [`basic-agent`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/basic-agent): the smallest `BLOCK` flow.
|
|
329
|
+
- [`shopify-refund-agent`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/shopify-refund-agent): amount-based `ALLOW` / `ESCALATE` / `BLOCK`.
|
|
330
|
+
- [`github-deploy-agent`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/github-deploy-agent): environment and force-push controls.
|
|
331
|
+
- [`procurement-agent`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/procurement-agent): an in-budget request held by policy.
|
|
332
|
+
- [`mcp-tool-gate`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/mcp-tool-gate): a real stdio MCP server with a governed tool.
|
|
333
|
+
- [`local-escalation`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/local-escalation): DIRECT and MANAGED Presence escalation against loopback doubles.
|
|
334
|
+
- [`presence-live-approval`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/presence-live-approval): DIRECT Presence enforcement against the real services with a FIDO2 or FIDO2-plus-liveness ceremony (needs credentials).
|
|
335
|
+
- [`presence-managed-approval`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/presence-managed-approval): Decionis-managed Presence orchestration against the real services (needs credentials).
|
|
336
|
+
- [`golden-adversarial-demo`](https://github.com/decionis/agent-safe-pipeline/tree/master/examples/golden-adversarial-demo): one golden path, eight attacks, zero unauthorized executions.
|
|
337
|
+
|
|
338
|
+
## Open core
|
|
339
|
+
|
|
340
|
+
Everything in the repository is Apache-2.0: the architecture, the intent contract, the execution
|
|
341
|
+
boundary, the client adapters, the audit contract, shadow mode, the conformance vectors, and the
|
|
342
|
+
examples. Decionis operates the policy control plane behind `DecionisGate`: policy evaluation, grant
|
|
343
|
+
issuance and atomic consumption, Decision Dossier signing and retention, and Presence.
|
|
344
|
+
[OPEN-CORE.md](https://github.com/decionis/agent-safe-pipeline/blob/master/OPEN-CORE.md) states the
|
|
345
|
+
boundary and the commitments that keep it stable.
|
|
346
|
+
|
|
347
|
+
## Research
|
|
348
|
+
|
|
349
|
+
Decionis Research defines the architecture, this package demonstrates it as tested code, and the
|
|
350
|
+
Decionis platform operates it as a hosted authority.
|
|
351
|
+
|
|
352
|
+
- Jejelowo, Festus. "The Execution Verifiability Gap: Why Model Governance Cannot Authorize
|
|
353
|
+
Consequential Actions." Decionis Research, version 1.0, 21 August 2026.
|
|
354
|
+
[Canonical article](https://decionis.com/research/execution-verifiability-gap) ·
|
|
355
|
+
[Archival PDF](https://decionis.com/research/execution-verifiability-gap-v1.0.pdf)
|
|
356
|
+
|
|
357
|
+
To cite the software, use the
|
|
358
|
+
[CITATION.cff](https://github.com/decionis/agent-safe-pipeline/blob/master/CITATION.cff) in the
|
|
359
|
+
repository or the Zenodo record above.
|
|
360
|
+
|
|
361
|
+
## Support and license
|
|
62
362
|
|
|
63
|
-
|
|
64
|
-
|
|
363
|
+
- Vulnerabilities: [GitHub private vulnerability reporting](https://github.com/decionis/agent-safe-pipeline/security/advisories/new) or `security@decionis.com`. Never open a public issue for a security report.
|
|
364
|
+
- Everything else: [GitHub Issues](https://github.com/decionis/agent-safe-pipeline/issues).
|
|
365
|
+
- Architecture and full documentation: [Agent-Safe Pipeline README](https://github.com/decionis/agent-safe-pipeline#readme).
|
|
65
366
|
|
|
66
|
-
|
|
367
|
+
Apache-2.0. Trademark terms in
|
|
368
|
+
[TRADEMARKS.md](https://github.com/decionis/agent-safe-pipeline/blob/master/TRADEMARKS.md).
|
package/dist/Index.d.ts
CHANGED
package/dist/Index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Index.d.ts","sourceRoot":"","sources":["../src/Index.ts"],"names":[],"mappings":"AAAA,cAAc,2CAA2C,CAAC;AAC1D,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,mCAAmC,CAAC;AAClD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC"}
|
|
1
|
+
{"version":3,"file":"Index.d.ts","sourceRoot":"","sources":["../src/Index.ts"],"names":[],"mappings":"AAAA,cAAc,2CAA2C,CAAC;AAC1D,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,mCAAmC,CAAC;AAClD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC"}
|
package/dist/Index.js
CHANGED
package/dist/Index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Index.js","sourceRoot":"","sources":["../src/Index.ts"],"names":[],"mappings":"AAAA,cAAc,2CAA2C,CAAC;AAC1D,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,mCAAmC,CAAC;AAClD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC"}
|
|
1
|
+
{"version":3,"file":"Index.js","sourceRoot":"","sources":["../src/Index.ts"],"names":[],"mappings":"AAAA,cAAc,2CAA2C,CAAC;AAC1D,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,mCAAmC,CAAC;AAClD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC"}
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import type { GateResult, HumanApprovalGate } from "@decionis/presence-node";
|
|
2
|
+
import type { AuditRecorder } from "../audit/AuditRecorder.js";
|
|
2
3
|
import type { DecisionAuthority, GateDecision } from "../decision/DecisionAuthority.js";
|
|
3
4
|
import type { CapturedIntent } from "../intent/ExecutionIntent.js";
|
|
4
5
|
export type PresenceGateResult = GateResult;
|
|
5
6
|
export type PresenceApprovalClient = Pick<HumanApprovalGate, "gate" | "outcome">;
|
|
7
|
+
/** Verification requirements Presence enforces for the approval ceremony (docs/22). */
|
|
8
|
+
export type PresenceVerificationRequirements = NonNullable<Parameters<HumanApprovalGate["gate"]>[0]["requirements"]>;
|
|
6
9
|
export interface PresenceApprovalCoordinatorOptions {
|
|
10
|
+
/** Optional bounded lifecycle audit recorder. */
|
|
11
|
+
readonly audit?: AuditRecorder;
|
|
12
|
+
/**
|
|
13
|
+
* Ceremony Presence must complete before it seals a receipt, such as WebAuthn
|
|
14
|
+
* alone or WebAuthn with active liveness. Omitted, Presence applies its
|
|
15
|
+
* default standard-confidence device proof.
|
|
16
|
+
*/
|
|
17
|
+
readonly requirements?: PresenceVerificationRequirements;
|
|
18
|
+
/** Approval-request lifetime in seconds (30-600); omitted, Presence applies its default. */
|
|
19
|
+
readonly ttlSeconds?: number;
|
|
7
20
|
/** Maximum number of outcome lookups after Presence returns HUMAN_REQUIRED. */
|
|
8
21
|
readonly maxAttempts?: number;
|
|
9
22
|
/** Initial exponential-backoff delay in milliseconds. */
|
|
@@ -34,6 +47,9 @@ export declare class PresenceApprovalCoordinator {
|
|
|
34
47
|
private readonly organization;
|
|
35
48
|
private readonly approverId;
|
|
36
49
|
private readonly polling;
|
|
50
|
+
private readonly audit;
|
|
51
|
+
private readonly requirements;
|
|
52
|
+
private readonly ttlSeconds;
|
|
37
53
|
constructor(presence: PresenceApprovalClient, authority: DecisionAuthority, organization: string, approverId: string, options?: PresenceApprovalCoordinatorOptions);
|
|
38
54
|
request(captured: CapturedIntent): Promise<PresenceGateResult>;
|
|
39
55
|
resolveAndReauthorize(captured: CapturedIntent, result: PresenceGateResult, options?: PresenceResolutionOptions): Promise<GateDecision>;
|
|
@@ -49,7 +65,16 @@ export declare class PresenceApprovalCoordinator {
|
|
|
49
65
|
private requestIdOf;
|
|
50
66
|
private evidenceOf;
|
|
51
67
|
private boundedIdentifier;
|
|
68
|
+
private recordPresence;
|
|
52
69
|
private isRecord;
|
|
70
|
+
/**
|
|
71
|
+
* Presence replays a sealed answer for a repeated idempotency key, so the key
|
|
72
|
+
* must identify exactly one captured intent for one approver: a re-captured
|
|
73
|
+
* intent has a new hash and must get a new request. The digest keeps the key
|
|
74
|
+
* inside Presence's `[A-Za-z0-9._:-]{8,128}` alphabet whatever the runtime's
|
|
75
|
+
* business key or the approver identity contain.
|
|
76
|
+
*/
|
|
77
|
+
static presenceIdempotencyKey(captured: CapturedIntent, approverId: string): string;
|
|
53
78
|
private static pollingConfiguration;
|
|
54
79
|
private static sleep;
|
|
55
80
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PresenceApprovalCoordinator.d.ts","sourceRoot":"","sources":["../../src/approval/PresenceApprovalCoordinator.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"PresenceApprovalCoordinator.d.ts","sourceRoot":"","sources":["../../src/approval/PresenceApprovalCoordinator.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { FailClosedDecision } from "../decision/DecisionAuthority.js";
|
|
3
|
+
const MIN_PRESENCE_TTL_SECONDS = 30;
|
|
4
|
+
const MAX_PRESENCE_TTL_SECONDS = 600;
|
|
2
5
|
const DEFAULT_MAX_ATTEMPTS = 20;
|
|
3
6
|
const DEFAULT_INITIAL_DELAY_MS = 250;
|
|
4
7
|
const DEFAULT_MAX_DELAY_MS = 2_000;
|
|
@@ -18,21 +21,37 @@ export class PresenceApprovalCoordinator {
|
|
|
18
21
|
organization;
|
|
19
22
|
approverId;
|
|
20
23
|
polling;
|
|
24
|
+
audit;
|
|
25
|
+
requirements;
|
|
26
|
+
ttlSeconds;
|
|
21
27
|
constructor(presence, authority, organization, approverId, options = {}) {
|
|
22
28
|
this.presence = presence;
|
|
23
29
|
this.authority = authority;
|
|
24
30
|
this.organization = organization;
|
|
25
31
|
this.approverId = approverId;
|
|
26
32
|
this.polling = PresenceApprovalCoordinator.pollingConfiguration(options);
|
|
33
|
+
this.audit = options.audit;
|
|
34
|
+
this.requirements = options.requirements;
|
|
35
|
+
if (options.ttlSeconds !== undefined &&
|
|
36
|
+
(!Number.isInteger(options.ttlSeconds) ||
|
|
37
|
+
options.ttlSeconds < MIN_PRESENCE_TTL_SECONDS ||
|
|
38
|
+
options.ttlSeconds > MAX_PRESENCE_TTL_SECONDS)) {
|
|
39
|
+
throw new Error("PRESENCE_TTL_INVALID");
|
|
40
|
+
}
|
|
41
|
+
this.ttlSeconds = options.ttlSeconds;
|
|
27
42
|
}
|
|
28
43
|
async request(captured) {
|
|
29
44
|
try {
|
|
45
|
+
// `intentHash` is the structural binding Presence seals into the envelope
|
|
46
|
+
// when the SDK supports it; the display field below stays the human copy.
|
|
47
|
+
const action = {
|
|
48
|
+
intent: captured.intent.action,
|
|
49
|
+
target: captured.intent.target,
|
|
50
|
+
surface: "agent_safe_pipeline",
|
|
51
|
+
intentHash: captured.intentHash,
|
|
52
|
+
};
|
|
30
53
|
const result = await this.presence.gate({
|
|
31
|
-
action
|
|
32
|
-
intent: captured.intent.action,
|
|
33
|
-
target: captured.intent.target,
|
|
34
|
-
surface: "agent_safe_pipeline",
|
|
35
|
-
},
|
|
54
|
+
action,
|
|
36
55
|
agent: {
|
|
37
56
|
id: captured.intent.actor.id,
|
|
38
57
|
display: captured.intent.actor.id,
|
|
@@ -49,11 +68,14 @@ export class PresenceApprovalCoordinator {
|
|
|
49
68
|
{ key: "intent_hash", label: "Intent hash", value: captured.intentHash },
|
|
50
69
|
],
|
|
51
70
|
},
|
|
52
|
-
|
|
71
|
+
...(this.requirements === undefined ? {} : { requirements: this.requirements }),
|
|
72
|
+
...(this.ttlSeconds === undefined ? {} : { ttlSeconds: this.ttlSeconds }),
|
|
73
|
+
}, PresenceApprovalCoordinator.presenceIdempotencyKey(captured, this.approverId));
|
|
53
74
|
const verdict = this.verdictOf(result);
|
|
54
75
|
if (verdict === null || (verdict === "HUMAN_REQUIRED" && this.requestIdOf(result) === null)) {
|
|
55
76
|
throw new Error("Presence returned an invalid gate response");
|
|
56
77
|
}
|
|
78
|
+
await this.recordPresence(captured, verdict === "HUMAN_REQUIRED" ? "PRESENCE_ESCALATED" : "PRESENCE_RESOLVED", verdict);
|
|
57
79
|
return result;
|
|
58
80
|
}
|
|
59
81
|
catch {
|
|
@@ -74,12 +96,14 @@ export class PresenceApprovalCoordinator {
|
|
|
74
96
|
return FailClosedDecision.create(captured.intentHash, "PRESENCE_RESPONSE_INVALID");
|
|
75
97
|
}
|
|
76
98
|
if (verdict !== "PROCEED") {
|
|
99
|
+
await this.recordPresence(captured, "PRESENCE_RESOLVED", verdict);
|
|
77
100
|
return FailClosedDecision.create(captured.intentHash, `PRESENCE_${verdict}`);
|
|
78
101
|
}
|
|
79
102
|
const evidence = this.evidenceOf(resolution.result);
|
|
80
103
|
if (evidence === null) {
|
|
81
104
|
return FailClosedDecision.create(captured.intentHash, "PRESENCE_PROOF_MISSING");
|
|
82
105
|
}
|
|
106
|
+
await this.recordPresence(captured, "PRESENCE_RESOLVED", verdict, evidence.receiptDossierId);
|
|
83
107
|
const preAuthorizationFailure = this.boundaryFailure(captured, options.signal);
|
|
84
108
|
if (preAuthorizationFailure !== null) {
|
|
85
109
|
return FailClosedDecision.create(captured.intentHash, preAuthorizationFailure);
|
|
@@ -268,9 +292,33 @@ export class PresenceApprovalCoordinator {
|
|
|
268
292
|
boundedIdentifier(value) {
|
|
269
293
|
return typeof value === "string" && value.length > 0 && value.length <= 200;
|
|
270
294
|
}
|
|
295
|
+
async recordPresence(captured, eventType, verdict, dossierId) {
|
|
296
|
+
await this.audit?.record({
|
|
297
|
+
eventType,
|
|
298
|
+
captured,
|
|
299
|
+
authority: "NON_AUTHORITATIVE",
|
|
300
|
+
...(dossierId === undefined ? {} : { dossierId }),
|
|
301
|
+
reasonCodes: [`PRESENCE_${verdict}`],
|
|
302
|
+
});
|
|
303
|
+
}
|
|
271
304
|
isRecord(value) {
|
|
272
305
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
273
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Presence replays a sealed answer for a repeated idempotency key, so the key
|
|
309
|
+
* must identify exactly one captured intent for one approver: a re-captured
|
|
310
|
+
* intent has a new hash and must get a new request. The digest keeps the key
|
|
311
|
+
* inside Presence's `[A-Za-z0-9._:-]{8,128}` alphabet whatever the runtime's
|
|
312
|
+
* business key or the approver identity contain.
|
|
313
|
+
*/
|
|
314
|
+
static presenceIdempotencyKey(captured, approverId) {
|
|
315
|
+
const digest = createHash("sha256")
|
|
316
|
+
.update(captured.intentHash)
|
|
317
|
+
.update("\u0000")
|
|
318
|
+
.update(approverId)
|
|
319
|
+
.digest("hex");
|
|
320
|
+
return `presence-${digest}`;
|
|
321
|
+
}
|
|
274
322
|
static pollingConfiguration(options) {
|
|
275
323
|
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
276
324
|
const initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
|