@decidio/sdk 0.2.1 → 0.3.0
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 +128 -13
- package/dist/adapters/langgraph.d.ts +2 -2
- package/dist/adapters/langgraph.js +2 -2
- package/dist/adapters/openai.d.ts +14 -4
- package/dist/adapters/openai.js +3 -2
- package/dist/adapters/temporal.d.ts +2 -2
- package/dist/adapters/temporal.js +7 -2
- package/dist/cli.js +36 -19
- package/dist/errors.js +12 -4
- package/dist/evidence.js +9 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/inngest.d.ts +10 -2
- package/dist/inngest.js +2 -2
- package/dist/resume.d.ts +118 -7
- package/dist/resume.js +192 -68
- package/dist/signing.d.ts +8 -1
- package/dist/signing.js +7 -23
- package/openapi.yaml +38 -0
- package/package.json +2 -7
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
One line to put a human-or-policy approval gate in front of any AI-agent action — and get a signed, verifiable Authority Receipt for every decision, with execution evidence accruing on the record.
|
|
4
4
|
|
|
5
|
+
`@decidio/sdk` is the client for Decidio — the system of record for enterprise authority, from OmniTwin Technologies.
|
|
6
|
+
|
|
5
7
|
## The two-minute tour
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -21,15 +23,17 @@ prints the approve command and exits rather than approve its own request (CI can
|
|
|
21
23
|
|
|
22
24
|
## Quickstart — seven steps to your first sealed receipt
|
|
23
25
|
|
|
24
|
-
Under 30 minutes, no help needed. You'll need Node 20.6
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
allowance
|
|
26
|
+
Under 30 minutes, no help needed. You'll need Node 20.6+ and a terminal.
|
|
27
|
+
|
|
28
|
+
**You'll need a Decidio workspace.** → Request one at [decidioai.com](https://decidioai.com/#early-access)
|
|
29
|
+
|
|
30
|
+
The hosted sandbox is synthetic data, safe to experiment in. Note it is ONE SHARED workspace:
|
|
31
|
+
workspace-wide views like `tokens list` or `approvals` show every lab member's agents and
|
|
32
|
+
decisions — the shared-lab boundary working as designed. Production workspaces are per-tenant.
|
|
33
|
+
|
|
34
|
+
Lab workspaces have a **governed-action allowance** (currently 30 actions/hour); hitting it raises
|
|
35
|
+
a typed `DecidioRateLimitError` naming the limit and reset time — nothing is routed or executed —
|
|
36
|
+
and `tokens list` shows your remaining allowance any time.
|
|
33
37
|
|
|
34
38
|
**PowerShell users** — the equivalents for every Bash idiom used below:
|
|
35
39
|
|
|
@@ -144,6 +148,17 @@ evidence is yours, not ours. To also prove *who* issued it, pin your workspace's
|
|
|
144
148
|
record verify panel — or simply read it off the `receipt` command's output, which prints your
|
|
145
149
|
workspace's issuer DID from the authenticated response so you never have to hunt for it).
|
|
146
150
|
|
|
151
|
+
**What VALID means — and what it does not.** A pinned `VALID` proves the receipt's bytes were
|
|
152
|
+
sealed by your workspace's issuer and are unaltered since. It does **not** prove the approved
|
|
153
|
+
action ran. Execution is confirmed *after* the seal, and a sealed record is immutable, so the
|
|
154
|
+
receipt's own `authority.result` and `confirmation.status` are a snapshot taken at seal time — a
|
|
155
|
+
receipt for an approved-but-never-executed action verifies `VALID`, correctly. The verifier now
|
|
156
|
+
prints those fields under `as sealed`, plus a note saying exactly this, so the distinction is on
|
|
157
|
+
screen rather than left to inference. The check is **cryptographic, not semantic**: it does not
|
|
158
|
+
evaluate business rules, timestamp plausibility, revocation, or whether a later record superseded
|
|
159
|
+
this one. For current execution status, ask the issuer (`npx @decidio/sdk receipt <decisionId>`
|
|
160
|
+
reports the live evidence tier).
|
|
161
|
+
|
|
147
162
|
One receipt proves itself. Pass several files and their **ordering** is verified too — in
|
|
148
163
|
**seal order** (each receipt's `prevHash` links to its predecessor's `contentHash`), which is
|
|
149
164
|
the order decisions were *sealed*, not the order of their displayed decision numbers (two
|
|
@@ -255,8 +270,21 @@ catch (e) { console.log(e.name); } // DecidioSuspendedError — expected, not
|
|
|
255
270
|
```
|
|
256
271
|
|
|
257
272
|
Run `node --env-file=.env agent.mjs`, then `node --env-file=.env submit.mjs` once, approve from
|
|
258
|
-
another terminal, and the running worker executes it.
|
|
259
|
-
|
|
273
|
+
another terminal, and the running worker executes it.
|
|
274
|
+
|
|
275
|
+
To see recovery, the approval has to be waiting while nothing is running — so submit a **second**
|
|
276
|
+
request with the worker stopped:
|
|
277
|
+
|
|
278
|
+
1. Stop the worker (Ctrl-C).
|
|
279
|
+
2. `node --env-file=.env submit.mjs` again — it parks; nothing is running to pick it up.
|
|
280
|
+
3. Approve it from the other terminal. It stays parked, approved and unexecuted.
|
|
281
|
+
4. Start the worker again: it executes that request exactly once, and restarting created no
|
|
282
|
+
duplicate.
|
|
283
|
+
|
|
284
|
+
Step 2 is the one that is easy to skip: by the time you restart, the first request has already
|
|
285
|
+
executed, so without a second submission there is nothing left to approve.
|
|
286
|
+
|
|
287
|
+
**If you can't declare the name at wrap time** (a dynamic
|
|
260
288
|
`describe`), register the raw function explicitly at startup instead —
|
|
261
289
|
`guard.register("payInvoice", payInvoiceRaw)` — before calling `guard.worker()`. Without one of
|
|
262
290
|
those two, a cold worker has an empty registry and reports `no_handler` (the parked action is
|
|
@@ -358,10 +386,30 @@ for (const item of await guard.resume.recover()) {
|
|
|
358
386
|
console.log(item.state, item.decisionId, item.guidance);
|
|
359
387
|
}
|
|
360
388
|
// "executed_unreported" → guard.resume.reconfirm(id) re-sends the saved report (never re-runs)
|
|
361
|
-
// "indeterminate" →
|
|
389
|
+
// "indeterminate" → no outcome was recorded; check downstream, then reconcile. Never blind-retry
|
|
362
390
|
// "parked" → still waiting on a human; nothing has executed
|
|
363
391
|
```
|
|
364
392
|
|
|
393
|
+
Two things `reconcile()` will refuse, because both would undo an authority decision rather than
|
|
394
|
+
record one. **It refuses by returning, not by throwing** — check `reportRecorded`, and read
|
|
395
|
+
`diagnostic` to see why:
|
|
396
|
+
|
|
397
|
+
```js
|
|
398
|
+
// A denial is final. There is no outcome to record — nothing ran — so neither outcome is accepted.
|
|
399
|
+
const r = await guard.resume.reconcile(deniedId, { outcome: "not_committed" });
|
|
400
|
+
r.reportRecorded; // false — r.diagnostic says the decision was DENIED
|
|
401
|
+
|
|
402
|
+
// A decision that is still `invoking` may be running the action at this moment. Releasing it would
|
|
403
|
+
// let a second worker run the same approved action, so it takes an explicit assertion — from you,
|
|
404
|
+
// after you have checked. Nothing here can see another machine's process table, so this is your
|
|
405
|
+
// judgement, and it is stamped into the ledger as such.
|
|
406
|
+
await guard.resume.reconcile(id, { outcome: "not_committed", reason: "…" }); // refused while invoking
|
|
407
|
+
await guard.resume.reconcile(id, { outcome: "not_committed", reason: "…", workerStopped: true }); // released
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
`indeterminate` needs no such flag: the handler has already returned or thrown, so nothing is in
|
|
411
|
+
flight — only the downstream result is unknown.
|
|
412
|
+
|
|
365
413
|
The report a worker sends is written to the pending store's execution ledger *before* the first
|
|
366
414
|
send attempt, so a lost confirmation is recoverable; and because that record holds no handler
|
|
367
415
|
binding, replaying it can only re-send evidence — never re-run your function. The ledger lives in
|
|
@@ -396,6 +444,53 @@ imports none of them — install the engine you use alongside the SDK (`npm i in
|
|
|
396
444
|
for engines you don't. Each adapter's shipped `.d.ts` header documents its wiring; `npx
|
|
397
445
|
@decidio/sdk quickstart` is the fastest runnable end-to-end.
|
|
398
446
|
|
|
447
|
+
|
|
448
|
+
## Upgrading from 0.2.x to 0.3.0
|
|
449
|
+
|
|
450
|
+
0.3.0 is a breaking release. Every item below changes behaviour a 0.2.x caller may depend on, so
|
|
451
|
+
none of it is left to be discovered at runtime.
|
|
452
|
+
|
|
453
|
+
**If you wrote a custom `PendingStore`, it will fail to construct.** `markDiscarded(decisionId,
|
|
454
|
+
verdict, reason?)` is now REQUIRED — it writes the tombstone that makes a denied decision
|
|
455
|
+
permanently unclaimable, and a store that cannot record a denial cannot uphold the guarantee, so
|
|
456
|
+
this is refused at construction rather than deep inside a resolve. `delete` also takes an optional
|
|
457
|
+
owning attempt (`delete(id, attemptId?)`) so a superseded attempt cannot clean up the live one's
|
|
458
|
+
payload, and `settle` takes `workerStopped`. The shipped stores show the shape, and
|
|
459
|
+
`packages/conformance/vectors/ownership.json` is the corpus your store should pass.
|
|
460
|
+
|
|
461
|
+
**`recover()` now throws instead of returning `[]`** when the ledger cannot be read
|
|
462
|
+
(`DecidioRecoveryUnavailableError`). `[]` and "I cannot tell you what you owe" are the same value
|
|
463
|
+
to a caller and mean opposite things, so a script reading `if (!(await recover()).length)` treated
|
|
464
|
+
a permissions change as proof that nothing was outstanding. Wrap the call if you poll it.
|
|
465
|
+
|
|
466
|
+
**`reconcile()` refuses two things it used to allow.** A DENIED decision cannot be reconciled on
|
|
467
|
+
either outcome, and releasing a decision that is still `invoking` takes an explicit
|
|
468
|
+
`workerStopped: true` — that is the one state where the handler may be running right now, and
|
|
469
|
+
releasing it hands one approved action to two live workers. It refuses by RETURNING, not throwing:
|
|
470
|
+
check `reportRecorded` and read `diagnostic`.
|
|
471
|
+
|
|
472
|
+
**The adapters return a different shape.** `decidioGate` (LangGraph, Temporal) returns
|
|
473
|
+
`ProtectedExecution` — `{ value, decisionId, confirmation }` — instead of the bare value.
|
|
474
|
+
`decidioGateStep` (Inngest) renames `result` to `value` and carries `confirmation`.
|
|
475
|
+
`gateInterruptions` is now `authorizeInterruptions` and returns `confirmationSupported: false`
|
|
476
|
+
(the old name remains as a deprecated alias). Read `.value` where you previously read the result.
|
|
477
|
+
|
|
478
|
+
**The transport rule refuses URLs it used to accept.** A gate URL must now be absolute and either
|
|
479
|
+
https or an exact loopback host, whatever credentials the config holds. A relative or scheme-less
|
|
480
|
+
`apiUrl` throws at startup — it used to pass on the reasoning that the shipped transport rejects
|
|
481
|
+
it, which says nothing about a custom `fetchImpl`.
|
|
482
|
+
|
|
483
|
+
**Decision ids are shape-checked.** Letters, digits, dot, dash and underscore, starting with a
|
|
484
|
+
letter or digit, at most 128 characters, never trimmed. The server enforces the same grammar (it
|
|
485
|
+
had none before 0.3.0, which is why the SDK was the stricter of the two). Ids Decidio issues are
|
|
486
|
+
well inside it.
|
|
487
|
+
|
|
488
|
+
**`/agent/confirm` can answer 409.** `TERMINALLY_DENIED` means a human refused the decision and
|
|
489
|
+
your action appears to have run anyway; `NOT_AUTHORIZED` means there is no approved, sealed
|
|
490
|
+
decision for evidence to attach to. Neither is a report failure and neither is retryable — the SDK
|
|
491
|
+
raises `DecidioHttpError` with advice that says so, rather than the "only its report failed" note
|
|
492
|
+
that fits every other confirm error.
|
|
493
|
+
|
|
399
494
|
## Engine adapters (BETA) — and Python too
|
|
400
495
|
|
|
401
496
|
- **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
|
|
@@ -406,6 +501,17 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
|
|
|
406
501
|
runtimes. Use the core path for production until that changes. Each adapter is bound through its
|
|
407
502
|
own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
|
|
408
503
|
The worker/webhook resume is the universal path and is not beta.
|
|
504
|
+
- **One result shape on every door (0.3.0, breaking).** `decidioGate` (LangGraph, Temporal) and
|
|
505
|
+
`decidioGateStep` (Inngest) now return `{ value, decisionId, confirmation }` — the same
|
|
506
|
+
`ProtectedExecution` the core path returns. They used to hand back the bare value and discard what
|
|
507
|
+
Decidio recorded, so "it ran and was recorded" and "it ran and Decidio does not know" were the
|
|
508
|
+
same return value to a caller. Inngest keeps its `status`, because its durable path can end in
|
|
509
|
+
`rejected` or `blocked` without ever calling your action; its value field is `value`, not
|
|
510
|
+
`result`.
|
|
511
|
+
The OpenAI door is the exception and now says so: it was renamed `gateInterruptions` →
|
|
512
|
+
`authorizeInterruptions` and returns `confirmationSupported: false`. The OpenAI runtime executes
|
|
513
|
+
the tool itself, so this SDK never sees the return value and has nothing to attest — and an
|
|
514
|
+
ABSENT confirmation must not look like a failed one. The old name remains as a deprecated alias.
|
|
409
515
|
- **Python twin:** `pip install decidio` exposes the identical `guard.protect` (plus the `@guard.approve` decorator, adapters, and the offline verifier); a conformance suite asserts both languages emit an identical request + receipt.
|
|
410
516
|
|
|
411
517
|
## CLI
|
|
@@ -421,7 +527,7 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
|
|
|
421
527
|
- `doctor` — config, connectivity, and what your current token can do
|
|
422
528
|
- `approvals [--mine] [--json]` / `approvals approve|reject <id> [reason]` — the human side, from your terminal (the list is workspace-wide; `--mine` narrows it to this directory's agent)
|
|
423
529
|
- `receipt <id>` — download a sealed decision's Authority Receipt to a `.json` file
|
|
424
|
-
- `verify [--issuer <did>] <file>...` — verify receipts offline (the canonical verifier; the standalone `@decidio/verify` package is the same implementation, for auditors who want zero Decidio dependency — the sdk
|
|
530
|
+
- `verify [--issuer <did>] <file>...` — verify receipts offline (the canonical verifier; the standalone `@decidio/verify` package is the same implementation, for auditors who want zero Decidio dependency — the sdk depends on the verifier released alongside it, at the same version). Unknown flags are refused, never ignored. Fully offline AND fully decoupled: it touches no network and reads no `.env` — a receipt verifies identically from an empty directory.
|
|
425
531
|
- `tokens list [--mine]` / `tokens revoke <jti>` — see and kill agent tokens (values are never re-shown; the jti is the revocation handle). Each row carries its status — `active`, `expired`, or `REVOKED <date>` — so the list confirms the revocations it performs; re-revoking is an honest no-op (exit 1). The list is workspace-wide (a shared workspace shows every member's agents); `--mine` narrows to this directory's agent
|
|
426
532
|
- `quickstart cleanup` — revoke the tour's token and delete its local credentials. What stays, honestly: sealed records (permanent by design) and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap)
|
|
427
533
|
- `dev [--target URL]` — local relay for the resume webhook (operator setups)
|
|
@@ -471,3 +577,12 @@ your queue.
|
|
|
471
577
|
- **Confirmation integrity:** the wrapper runs in your process, so it captures the genuine API response — a materially stronger signal than an agent self-report, but it trusts the host. For high-assurance, Decidio can *independently* read the source back (`independently_reconciled`) where it holds a read connection.
|
|
472
578
|
- **Zero-code alternative:** an agent that speaks MCP (e.g. Claude on the web) can reach the same gate via Decidio's `request_approval` MCP tool with no code at all.
|
|
473
579
|
- **API surface:** the agent-gate REST contract this SDK speaks is described in [`openapi.yaml`](./openapi.yaml), shipped in the package.
|
|
580
|
+
|
|
581
|
+
## Using this for real?
|
|
582
|
+
|
|
583
|
+
The hosted sandbox is synthetic and shared. Production workspaces are per-tenant, with your own
|
|
584
|
+
issuer key and receipts that verify offline against it.
|
|
585
|
+
|
|
586
|
+
[decidioai.com](https://decidioai.com) · [Developer docs](https://decidioai.com/developers/) · [Request access](https://decidioai.com/#early-access)
|
|
587
|
+
|
|
588
|
+
Built by OmniTwin Technologies.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from "@langchain/langgraph";
|
|
2
|
-
import { type GuardConfig, type ApprovalContext } from "../index.js";
|
|
2
|
+
import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
|
|
3
3
|
/**
|
|
4
4
|
* @decidio/sdk/langgraph — durable suspend via LangGraph's native `interrupt()`.
|
|
5
5
|
* `interrupt` finds the running graph itself (no handle needed), so the gate drops into
|
|
@@ -13,7 +13,7 @@ import { type GuardConfig, type ApprovalContext } from "../index.js";
|
|
|
13
13
|
/** Gate `run()` behind Decidio inside a LangGraph node. */
|
|
14
14
|
export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, opts?: {
|
|
15
15
|
evidence?: (r: R) => unknown;
|
|
16
|
-
}): Promise<R
|
|
16
|
+
}): Promise<ProtectedExecution<R>>;
|
|
17
17
|
/** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
|
|
18
18
|
* maps decisionId → thread_id and passes this to graph.invoke/stream. */
|
|
19
19
|
export declare function decidioResumeCommand(body: {
|
|
@@ -10,8 +10,8 @@ export async function decidioGate(config, ctx, run, opts) {
|
|
|
10
10
|
assertVerdict(verdictFrom(resumed), auth.decisionId, resumed?.reason);
|
|
11
11
|
}
|
|
12
12
|
const result = await run();
|
|
13
|
-
await gateConfirm(config, auth.decisionId, result, opts?.evidence);
|
|
14
|
-
return result;
|
|
13
|
+
const confirmation = await gateConfirm(config, auth.decisionId, result, opts?.evidence);
|
|
14
|
+
return { value: result, decisionId: auth.decisionId, confirmation };
|
|
15
15
|
}
|
|
16
16
|
export function decidioResumeCommand(body) {
|
|
17
17
|
return new Command({ resume: { verdict: body.verdict, reason: body.reason } });
|
|
@@ -34,13 +34,23 @@ export interface Pending {
|
|
|
34
34
|
item: ToolApprovalItem;
|
|
35
35
|
decisionId: string;
|
|
36
36
|
}
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
37
|
+
/** THIS DOOR AUTHORIZES; IT DOES NOT CONFIRM. Named `gateInterruptions` it looked like the other
|
|
38
|
+
* `decidioGate*` functions, which all run the action and report what happened. This one cannot:
|
|
39
|
+
* the OpenAI Agents runtime executes the tool itself, so the SDK never sees the return value and
|
|
40
|
+
* has nothing to attest. Every other adapter now returns a `confirmation`; this one deliberately
|
|
41
|
+
* returns `confirmationSupported: false` instead of an empty one, because an absent confirmation
|
|
42
|
+
* and a failed confirmation must not look alike.
|
|
43
|
+
*
|
|
44
|
+
* proceed → state.approve(item); block → state.reject(item); route → pending (a human decides via
|
|
45
|
+
* Decidio; the host parks state.toString() + resumes via applyResume). */
|
|
46
|
+
export declare function authorizeInterruptions(config: GuardConfig, state: RunStateLike, describe: (item: ToolApprovalItem) => ApprovalContext): Promise<{
|
|
41
47
|
resolved: Resolved[];
|
|
42
48
|
pending: Pending[];
|
|
49
|
+
confirmationSupported: false;
|
|
43
50
|
}>;
|
|
51
|
+
/** @deprecated Renamed to `authorizeInterruptions` in 0.3.0 — the old name read like the other
|
|
52
|
+
* `decidioGate*` doors, which execute and confirm. This one only ever authorized. */
|
|
53
|
+
export declare const gateInterruptions: typeof authorizeInterruptions;
|
|
44
54
|
/** Apply a Decidio verdict to a resumed RunState (after RunState.fromString).
|
|
45
55
|
*
|
|
46
56
|
* VALIDATES BEFORE MUTATING. `else state.reject(item)` treated every unrecognised string as a
|
package/dist/adapters/openai.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { gateAuthorize, checkEngineVersion } from "./_gate.js";
|
|
2
2
|
import { DecidioBlockedError } from "../index.js";
|
|
3
|
-
export async function
|
|
3
|
+
export async function authorizeInterruptions(config, state, describe) {
|
|
4
4
|
const resolved = [];
|
|
5
5
|
const pending = [];
|
|
6
6
|
for (const item of state.getInterruptions()) {
|
|
@@ -18,8 +18,9 @@ export async function gateInterruptions(config, state, describe) {
|
|
|
18
18
|
else
|
|
19
19
|
throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)}`, auth.decisionId);
|
|
20
20
|
}
|
|
21
|
-
return { resolved, pending };
|
|
21
|
+
return { resolved, pending, confirmationSupported: false };
|
|
22
22
|
}
|
|
23
|
+
export const gateInterruptions = authorizeInterruptions;
|
|
23
24
|
export function applyResume(state, item, verdict) {
|
|
24
25
|
if (verdict === "approved" || verdict === "auto_approved") {
|
|
25
26
|
state.approve(item);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type GuardConfig, type ApprovalContext } from "../index.js";
|
|
1
|
+
import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
|
|
2
2
|
import { type GateAuth } from "./_gate.js";
|
|
3
3
|
/**
|
|
4
4
|
* @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
|
|
@@ -32,4 +32,4 @@ export interface DecidioGateOpts<R = unknown> {
|
|
|
32
32
|
export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, verdicts: Map<string, {
|
|
33
33
|
verdict: string;
|
|
34
34
|
reason?: string;
|
|
35
|
-
}>, opts?: DecidioGateOpts<R>): Promise<R
|
|
35
|
+
}>, opts?: DecidioGateOpts<R>): Promise<ProtectedExecution<R>>;
|
|
@@ -21,18 +21,23 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
|
|
|
21
21
|
throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)} — refusing to execute`, auth.decisionId);
|
|
22
22
|
}
|
|
23
23
|
const result = await run();
|
|
24
|
+
let confirmation;
|
|
24
25
|
if (opts.confirm) {
|
|
25
26
|
if (opts.evidence)
|
|
26
27
|
console.warn("[decidio] temporal: `evidence` is ignored when you supply `confirm` — your callback owns the report, so map the result inside it. Remove one of the two to make the intent clear.");
|
|
27
28
|
await opts.confirm(auth.decisionId, result);
|
|
29
|
+
confirmation = {
|
|
30
|
+
reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false,
|
|
31
|
+
diagnostic: "your own `confirm` callback reported this execution, so this SDK cannot read back what Decidio recorded. Check the record, or return the gate's response body from your activity and validate it with readConfirmResponse.",
|
|
32
|
+
};
|
|
28
33
|
if (!confirmWarned) {
|
|
29
34
|
confirmWarned = true;
|
|
30
35
|
console.warn(`[decidio] temporal: a custom \`confirm\` reports through your own activity, so this SDK cannot read back the evidence tier Decidio recorded. Check the record (or return the gate's response body from your activity and validate it with readConfirmResponse) if you need to know whether the execution was CONFIRMED, not merely reported.`);
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
else {
|
|
34
|
-
await gateConfirm(config, auth.decisionId, result, opts?.evidence);
|
|
39
|
+
confirmation = await gateConfirm(config, auth.decisionId, result, opts?.evidence);
|
|
35
40
|
}
|
|
36
|
-
return result;
|
|
41
|
+
return { value: result, decisionId: auth.decisionId, confirmation };
|
|
37
42
|
}
|
|
38
43
|
checkEngineVersion("temporal");
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
|
-
import { userInfo } from "node:os";
|
|
5
4
|
import { createServer } from "node:http";
|
|
6
5
|
import { execFileSync } from "node:child_process";
|
|
7
6
|
import { createInterface } from "node:readline";
|
|
8
7
|
import { createRequire } from "node:module";
|
|
9
8
|
import { generateKeypair, assertSafeTransport } from "./signing.js";
|
|
9
|
+
import { hardenFile } from "./resume.js";
|
|
10
10
|
import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
|
|
11
11
|
import { verifyReceipt, runVerify } from "@decidio/verify";
|
|
12
12
|
const VERSION = (() => {
|
|
@@ -39,6 +39,14 @@ function readEnvFile() {
|
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
41
|
const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", "--help", "-h", "help", undefined].includes(process.argv[2]);
|
|
42
|
+
if (!OFFLINE_CMD) {
|
|
43
|
+
readEnvFile();
|
|
44
|
+
if (envUnreadable) {
|
|
45
|
+
console.error(`✘ .env exists here but could not be read, so this command cannot know what is already configured in this directory.`);
|
|
46
|
+
console.error(` Treating it as empty would risk overwriting an agent key that cannot be recovered. Fix the file's permissions (or move it aside) and run the command again. Nothing was changed.`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
42
50
|
if (!OFFLINE_CMD)
|
|
43
51
|
for (const [k, v] of Object.entries(readEnvFile())) {
|
|
44
52
|
if (k === "DECIDIO_SESSION_TOKEN") {
|
|
@@ -106,21 +114,9 @@ function writeSecretFile(path, body) {
|
|
|
106
114
|
chmodSync(path, 0o600);
|
|
107
115
|
}
|
|
108
116
|
catch { }
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
function effectiveUser() {
|
|
119
|
-
try {
|
|
120
|
-
return userInfo().username;
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
return process.env.USERNAME ?? process.env.USER ?? "";
|
|
117
|
+
const why = hardenFile(path);
|
|
118
|
+
if (why) {
|
|
119
|
+
console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume (${why}) — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
|
|
124
120
|
}
|
|
125
121
|
}
|
|
126
122
|
function upsertEnv(updates) {
|
|
@@ -720,8 +716,11 @@ async function cmdQuickstart(opts = {}) {
|
|
|
720
716
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
|
|
721
717
|
if (rv.status >= 200 && rv.status < 300)
|
|
722
718
|
log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
|
|
723
|
-
else
|
|
724
|
-
|
|
719
|
+
else {
|
|
720
|
+
const carried = [...(prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").filter(Boolean), prevJti];
|
|
721
|
+
envOut.DECIDIO_ORPHANED_TOKEN_JTI = [...new Set(carried)].join(",");
|
|
722
|
+
log(` ⚠ could not revoke the previous tour's token (jti ${prevJti}, ${rv.status}) — it is still LIVE. Its id is kept in ${envFile} as DECIDIO_ORPHANED_TOKEN_JTI, and \`quickstart cleanup\` will retry it; or revoke it by hand: npx @decidio/sdk tokens revoke ${prevJti}`);
|
|
723
|
+
}
|
|
725
724
|
}
|
|
726
725
|
else {
|
|
727
726
|
log(` ⚠ the previous tour file carried no revocation id, so its token (if any) must be revoked from your Decidio app.`);
|
|
@@ -859,6 +858,7 @@ async function cmdQuickstart(opts = {}) {
|
|
|
859
858
|
log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
|
|
860
859
|
log("");
|
|
861
860
|
log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
|
|
861
|
+
log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
|
|
862
862
|
let doCleanup = false;
|
|
863
863
|
if (interactive) {
|
|
864
864
|
const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
|
|
@@ -884,6 +884,23 @@ async function quickstartCleanup(session, envFile) {
|
|
|
884
884
|
}
|
|
885
885
|
return out;
|
|
886
886
|
})();
|
|
887
|
+
const orphans = (env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
888
|
+
const stillOrphaned = [];
|
|
889
|
+
for (const jti of orphans) {
|
|
890
|
+
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, session);
|
|
891
|
+
if (rv.status >= 200 && rv.status < 300)
|
|
892
|
+
log(` ✔ orphaned token revoked (jti ${jti}) — it had survived an earlier replace`);
|
|
893
|
+
else {
|
|
894
|
+
stillOrphaned.push(jti);
|
|
895
|
+
err(` ⚠ orphaned token ${jti} could NOT be revoked (${rv.status}) — it is still live.`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (stillOrphaned.length) {
|
|
899
|
+
err(`✘ ${stillOrphaned.length} orphaned token(s) are STILL LIVE and could not be revoked. Nothing was deleted; ${envFile} keeps their ids so you can retry:`);
|
|
900
|
+
for (const jti of stillOrphaned)
|
|
901
|
+
err(` npx @decidio/sdk tokens revoke ${jti}`);
|
|
902
|
+
return 1;
|
|
903
|
+
}
|
|
887
904
|
if (env.DECIDIO_TOKEN_JTI) {
|
|
888
905
|
const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
|
|
889
906
|
if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
|
|
@@ -906,7 +923,7 @@ async function quickstartCleanup(session, envFile) {
|
|
|
906
923
|
return 1;
|
|
907
924
|
}
|
|
908
925
|
if (existsSync(envFile)) {
|
|
909
|
-
const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI"]);
|
|
926
|
+
const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI", "DECIDIO_ORPHANED_TOKEN_JTI"]);
|
|
910
927
|
const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
|
|
911
928
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
|
|
912
929
|
return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
|
package/dist/errors.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
const RETRYABLE_STATUS = (status) => status === 408 || status === 429 || (status >= 500 && status !== 501);
|
|
2
|
-
function sideEffectNote(endpoint) {
|
|
2
|
+
function sideEffectNote(endpoint, code) {
|
|
3
3
|
if (endpoint.startsWith("/agent/confirm")) {
|
|
4
|
+
if (code === "TERMINALLY_DENIED") {
|
|
5
|
+
return " A human DENIED this decision, and your action appears to have run anyway. Decidio recorded the refusal and will NOT attach execution evidence to a denied decision. Do NOT re-run it — investigate why the action executed without an approval.";
|
|
6
|
+
}
|
|
7
|
+
if (code === "NOT_AUTHORIZED") {
|
|
8
|
+
return " This decision is not an approved, sealed decision, so no execution evidence can attach to it yet. If your action already ran, it ran WITHOUT authority — do NOT re-run it; investigate. If you are confirming early, wait for the approval and confirm then.";
|
|
9
|
+
}
|
|
4
10
|
return " Your action ALREADY RAN — only its report failed. Do NOT re-run it.";
|
|
5
11
|
}
|
|
6
12
|
if (endpoint.startsWith("/agent/status")) {
|
|
@@ -22,7 +28,7 @@ export class DecidioHttpError extends Error {
|
|
|
22
28
|
: retryable
|
|
23
29
|
? ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`
|
|
24
30
|
: ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
|
|
25
|
-
sideEffectNote(endpoint));
|
|
31
|
+
sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
|
|
26
32
|
this.status = status;
|
|
27
33
|
this.endpoint = endpoint;
|
|
28
34
|
this.body = body;
|
|
@@ -44,7 +50,9 @@ export function decisionIdProblem(raw) {
|
|
|
44
50
|
if (cp < 0x20 || cp === 0x7f)
|
|
45
51
|
return "it contains control characters";
|
|
46
52
|
}
|
|
47
|
-
if (raw.length >
|
|
48
|
-
return `it is ${raw.length} characters long (limit
|
|
53
|
+
if (raw.length > 128)
|
|
54
|
+
return `it is ${raw.length} characters long (limit 128)`;
|
|
55
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(raw))
|
|
56
|
+
return "it is not letters, digits, dot, dash or underscore starting with a letter or digit (an opaque id needs nothing else, and every other character is a way to make two different strings look like the same decision)";
|
|
49
57
|
return null;
|
|
50
58
|
}
|
package/dist/evidence.js
CHANGED
|
@@ -32,20 +32,22 @@ export function readConfirmResponse(body, opts) {
|
|
|
32
32
|
if (!EVIDENCE_TIERS.includes(tier)) {
|
|
33
33
|
return { reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: journalled, diagnostic: `the gate reported an evidence tier this SDK does not recognise (${JSON.stringify(tier)}) — refusing to treat it as evidence. Upgrade @decidio/sdk; your action DID run, and ${retry}` };
|
|
34
34
|
}
|
|
35
|
-
const
|
|
35
|
+
const rpt = body?.executionReport;
|
|
36
|
+
const hasVerdict = !!rpt && typeof rpt === "object" && typeof rpt.verified === "boolean";
|
|
37
|
+
const proofExplicitlyFailed = hasVerdict && rpt.verified === false;
|
|
38
|
+
const executionConfirmed = CONFIRMING.includes(tier) && !proofExplicitlyFailed;
|
|
36
39
|
const outcome = { reportRecorded: true, evidenceTier: tier, executionConfirmed, reportJournalled: journalled };
|
|
37
|
-
if (!executionConfirmed && opts.signed)
|
|
40
|
+
if (!executionConfirmed && !proofExplicitlyFailed && opts.signed)
|
|
38
41
|
outcome.diagnostic = describeTierGap(opts.capturedResponse);
|
|
39
|
-
else if (!executionConfirmed)
|
|
42
|
+
else if (!executionConfirmed && !proofExplicitlyFailed)
|
|
40
43
|
outcome.diagnostic = "no signing key is configured, so the gate capped this report at an unproven agent assertion. Run `npx @decidio/sdk init` to generate the agent's key and earn a confirming tier.";
|
|
41
|
-
|
|
42
|
-
if (rpt && typeof rpt === "object" && typeof rpt.verified === "boolean") {
|
|
44
|
+
if (hasVerdict) {
|
|
43
45
|
outcome.executionReportVerified = rpt.verified;
|
|
44
46
|
if (typeof rpt.reason === "string" && rpt.reason)
|
|
45
47
|
outcome.executionReportReason = rpt.reason;
|
|
46
|
-
if (
|
|
48
|
+
if (proofExplicitlyFailed) {
|
|
47
49
|
const why = typeof rpt.reason === "string" && rpt.reason ? ` (${rpt.reason})` : "";
|
|
48
|
-
outcome.diagnostic =
|
|
50
|
+
outcome.diagnostic = `the signed execution report did NOT verify${why}, so this is NOT a confirmed execution — the gate reported tier "${tier}", and this SDK refuses to read an unproven attestation as confirmation. Your action DID run; check the agent's key/DID/registration (npx @decidio/sdk doctor), then ${retry}`;
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
return outcome;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResumeController } from "./resume.js";
|
|
2
2
|
import { type EvidenceOutcome } from "./evidence.js";
|
|
3
|
-
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
|
|
3
|
+
export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
|
|
4
4
|
export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
|
|
5
5
|
export { guard, Guard } from "./guard.js";
|
|
6
6
|
export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { DecidioSuspendedError } from "./resume.js";
|
|
|
2
2
|
import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential } from "./signing.js";
|
|
3
3
|
import { canonicalize, canonicalizeArgs } from "./canonical.js";
|
|
4
4
|
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
5
|
-
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
|
|
5
|
+
export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
|
|
6
6
|
export { guard, Guard } from "./guard.js";
|
|
7
7
|
export { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
|
8
8
|
import { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
package/dist/inngest.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { RequestListener } from "node:http";
|
|
2
2
|
import { verifyResumeSignature } from "./resume.js";
|
|
3
|
-
import type { GuardConfig, ApprovalContext } from "./index.js";
|
|
3
|
+
import type { GuardConfig, ApprovalContext, EvidenceOutcome } from "./index.js";
|
|
4
4
|
/** The Inngest event Decidio's verdict is translated into. The waiting function
|
|
5
5
|
* correlates on `data.decisionId`. */
|
|
6
6
|
export declare const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
|
|
@@ -24,10 +24,18 @@ export interface InngestClientLike {
|
|
|
24
24
|
data: Record<string, any>;
|
|
25
25
|
}): Promise<unknown>;
|
|
26
26
|
}
|
|
27
|
+
/** ONE RESULT SHAPE ACROSS EVERY DOOR (F-008), with the one field this door genuinely needs on
|
|
28
|
+
* top of it. `status` stays because Inngest's durable path can legitimately end in `rejected` or
|
|
29
|
+
* `blocked` without ever calling your action — the other adapters signal that by throwing. What
|
|
30
|
+
* changed: the value is `value` (not `result`) so it reads the same as every other door, and
|
|
31
|
+
* `confirmation` is CARRIED rather than discarded. It used to be thrown away inside the confirm
|
|
32
|
+
* step, which made "it ran and Decidio recorded it" and "it ran and Decidio does not know"
|
|
33
|
+
* indistinguishable to the caller. */
|
|
27
34
|
export type GateStepOutcome<R> = {
|
|
28
35
|
status: "executed" | "rejected" | "blocked";
|
|
29
36
|
decisionId: string;
|
|
30
|
-
|
|
37
|
+
value?: R;
|
|
38
|
+
confirmation?: EvidenceOutcome;
|
|
31
39
|
};
|
|
32
40
|
/**
|
|
33
41
|
* Gate an action INSIDE an Inngest function, with durable suspend on routing.
|
package/dist/inngest.js
CHANGED
|
@@ -22,8 +22,8 @@ export async function decidioGateStep(args) {
|
|
|
22
22
|
return { status: "blocked", decisionId: auth.decisionId };
|
|
23
23
|
}
|
|
24
24
|
const result = await step.run("agent-action", args.run);
|
|
25
|
-
await step.run("decidio-confirm", async () =>
|
|
26
|
-
return { status: "executed", decisionId: auth.decisionId, result };
|
|
25
|
+
const confirmation = await step.run("decidio-confirm", async () => gateConfirm(config, auth.decisionId, result, args.evidence));
|
|
26
|
+
return { status: "executed", decisionId: auth.decisionId, value: result, confirmation };
|
|
27
27
|
}
|
|
28
28
|
export function createInngestResumeBridge(opts) {
|
|
29
29
|
const secret = opts.webhookSecret ?? "";
|