@decidio/sdk 0.6.4 → 0.7.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/CHANGELOG.md +167 -0
- package/README.md +103 -23
- package/dist/adapters/_gate.d.ts +27 -2
- package/dist/adapters/_gate.js +33 -2
- package/dist/adapters/langgraph.d.ts +5 -11
- package/dist/adapters/langgraph.js +57 -6
- package/dist/adapters/openai.d.ts +40 -10
- package/dist/adapters/openai.js +19 -11
- package/dist/adapters/temporal.d.ts +21 -2
- package/dist/adapters/temporal.js +18 -5
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +252 -37
- package/dist/demo-gate.js +6 -4
- package/dist/index.d.ts +36 -4
- package/dist/index.js +48 -5
- package/dist/inngest.d.ts +5 -2
- package/dist/inngest.js +23 -15
- package/dist/resume.d.ts +98 -5
- package/dist/resume.js +203 -109
- package/dist/signing.d.ts +35 -0
- package/dist/signing.js +40 -1
- package/openapi.yaml +65 -6
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,173 @@ the gate, signing, receipts and the verifier - is conformance-locked between the
|
|
|
5
5
|
first-run surfaces (the fork, `--demo`, the code sign-in) are TypeScript-first and named as such in the parity
|
|
6
6
|
allowlist. Each entry names what changed, what a reader must do on upgrade, and what proved it.
|
|
7
7
|
|
|
8
|
+
## 0.7.0 — approvals bind to the exact request they approved; receipts pin to a real trust anchor
|
|
9
|
+
|
|
10
|
+
- **The receipt trust anchor is built in — nobody pastes an issuer DID, and nothing pins to a receipt's own
|
|
11
|
+
claimed issuer** (founder-approved 3A, 2026-09-24). `receipt` and the tour used to pin their offline check to the
|
|
12
|
+
`issuer` field of the server's verify response — which echoes the credential's OWN claim — and never read `valid`:
|
|
13
|
+
a receipt signed by any key that names itself verified "pinned" against itself, and a record the server's own check
|
|
14
|
+
had failed was shown as VALID. Now:
|
|
15
|
+
- `receipt` and the tour pin to `expectedIssuer` — the server's configured key, which the API now returns beside
|
|
16
|
+
`issuer` — else to the DID remembered for that host; they fail (exit 1) when the server says `valid: false`.
|
|
17
|
+
- Sign-in (`login`, the tour's or `init`'s sign-in) and `init` fetch the host's public `/api/did` and remember it in
|
|
18
|
+
the profile beside the stored sign-in (`issuers.json`, owner-only; kept past logout). A later `/api/did` answering
|
|
19
|
+
a DIFFERENT DID is warned about loudly and NOT adopted.
|
|
20
|
+
- `verify` with no `--issuer` pins to that remembered DID for the configured host, else to the built-in
|
|
21
|
+
`KNOWN_ISSUERS` (exported by `@decidio/verify`: the hosted service's issuer only — never the verification kit's
|
|
22
|
+
published sample key), else refuses (exit 2) with the remedy. An explicit `--issuer` always wins, and every run
|
|
23
|
+
prints its trust anchor first. An unreadable `issuers.json` refuses rather than falling back.
|
|
24
|
+
- `@decidio/verify` (and `npx @decidio/verify`) with no `--issuer` pins to the built-in list; a receipt from any other
|
|
25
|
+
issuer is refused (exit 2) with the remedy. `runVerify` takes an optional anchor description and prints
|
|
26
|
+
`trust anchor: …` as its first line.
|
|
27
|
+
On upgrade: nothing to do; scripts that pass `--issuer` behave as before (plus the anchor line on stdout). Deploy the
|
|
28
|
+
API first: against a server without `expectedIssuer`, `receipt` and the tour fall back to the remembered DID and
|
|
29
|
+
otherwise fail closed. Proved by: `cli-smoke.ts` (a forged receipt naming itself fails the tour and is never offered
|
|
30
|
+
as the anchor; `valid:false` fails `receipt` and the tour; init/sign-in remember `/api/did`; flagless verify pins to
|
|
31
|
+
it; `--issuer` wins; a changed DID is not adopted; nothing remembered → exit 2 naming the built-in anchor; an
|
|
32
|
+
unreadable store refuses), `decidio-verify/smoke.ts` (the built-in list's content and refusals), the three-door
|
|
33
|
+
conformance run (every door names the same built-in anchor), and a claims tombstone for the retired
|
|
34
|
+
"refuses to run without --issuer" sentence.
|
|
35
|
+
|
|
36
|
+
- **3A follow-up (independent invariant-guard review, 2026-09-24).**
|
|
37
|
+
- **The remembered DID wins over a server that names another.** `receipt` and the tour compared nothing: a server whose
|
|
38
|
+
`expectedIssuer` differed from the DID remembered for its host was followed, so "not adopted" held at sign-in and
|
|
39
|
+
failed one command later. Now the same loud HAS CHANGED warning prints and the pin stays the remembered DID (the
|
|
40
|
+
receipt then fails its offline check: `receipt` exits 1, the tour fails). An unreadable `issuers.json` now yields no
|
|
41
|
+
anchor at all rather than the server's.
|
|
42
|
+
- **`receipt` checks the file offline against the anchor it prints.** It honoured the server's `valid` but never
|
|
43
|
+
checked the credential against the anchor it recommended - a server naming the built-in DID over another key's
|
|
44
|
+
credential got "No --issuer needed" and exit 0 while that flagless verify then refused. Any failure now behaves like
|
|
45
|
+
`valid:false`: exit 1, the file kept for inspection only.
|
|
46
|
+
- **One host rule.** `verify` resolves its remembered DID for `DECIDIO_API_URL` from the shell (it reads no `.env`),
|
|
47
|
+
and `receipt` prints the flagless command only when that verify lands on the same anchor - a host set only in `.env`
|
|
48
|
+
gets the explicit `--issuer` form and says why.
|
|
49
|
+
- **`issuers.json` is keyed by URL origin** (scheme + lowercased host + non-default port, no path, no trailing slash):
|
|
50
|
+
`HTTP://Host:80/` and `http://host` are one entry, so a variant spelling cannot pass as a first sighting and skip
|
|
51
|
+
the change warning.
|
|
52
|
+
- **Output contract.** `receipt --json`'s `issuer` is the trust anchor the file was checked against (null against an
|
|
53
|
+
older server with nothing remembered), and `valid` is false when the server's check or the offline check failed.
|
|
54
|
+
`verify`'s stdout starts with a `trust anchor: <did> (<source>)` line before the verdicts.
|
|
55
|
+
Proved by: `cli-smoke.ts` +9 (a server naming a different key than the remembered DID: warned, not followed, receipt
|
|
56
|
+
exit 1, tour fails; a server naming the built-in DID over another key's credential: exit 1, never "No --issuer
|
|
57
|
+
needed"; the flipped forged vector now exits 1; a variant spelling of the host is the same entry; eight origin-key
|
|
58
|
+
cases shared with Python; `.env` never chooses verify's anchor; the flagless command follows verify's host; an issuer
|
|
59
|
+
store with `"v": true` is refused; the update gate's switches come from the shell) - 8 of them red against the
|
|
60
|
+
previous commit's code; the other three are the twins of Python-only bugs the TS CLI never had. The release workflow
|
|
61
|
+
now fails if `KNOWN_ISSUERS` (TS or Python) differs from the live `https://decidio-api.onrender.com/api/did`.
|
|
62
|
+
- **3A follow-up, round 2 (guard re-review, 2026-09-24) — same hostile-`.env` threat model.**
|
|
63
|
+
- **The session and the trust store live where the SHELL says.** `sessionDir()` (and the terminal-id read) took
|
|
64
|
+
`XDG_CONFIG_HOME`/`LOCALAPPDATA`/`HOME` from the environment *after* `.env` was promoted, so a project `.env` with
|
|
65
|
+
`XDG_CONFIG_HOME=.cfg` put the session token and `issuers.json` inside the repo — and let the repo plant a DID that
|
|
66
|
+
then showed as "remembered on this computer". Both now read the shell snapshot.
|
|
67
|
+
- **A project `.env` promotes only `DECIDIO_*`.** It configures Decidio; it does not reconfigure the OS, toolchain or
|
|
68
|
+
network stack. A cloned repo's `.env` can no longer inject `HTTP(S)_PROXY`/`NO_PROXY` (which would route this CLI's
|
|
69
|
+
authenticated calls, bearer token and all, through the repo's server), TLS-trust or CA-bundle variables,
|
|
70
|
+
`NODE_OPTIONS`/`GIT_*` code loaders, or the session-dir variables into the environment a network command runs with.
|
|
71
|
+
A switch genuinely wanted is set in the shell, which still wins.
|
|
72
|
+
- **The cached update-check value is validated like the network answer** (a full `x.y.z`), and `semverNewer` requires
|
|
73
|
+
exactly three all-digit components: a planted `update-check.json` with trailing bytes can no longer be printed as an
|
|
74
|
+
available-version notice, escape codes and all, or used to force an upgrade.
|
|
75
|
+
- **`issuerStoreKey` fails closed** on host spellings it cannot canonicalise identically to the Python twin
|
|
76
|
+
(percent-encoding, IDN, numeric-shorthand IPs, backslashes): it no longer uses the browser URL's canonicalisation,
|
|
77
|
+
which mapped `https://%64ecidio-api.onrender.com` onto the real host — a planted store entry under an exotic spelling
|
|
78
|
+
is never trusted as the real Decidio.
|
|
79
|
+
- **An `issuers.json` whose version literal is `1.0`** (a float, not the integer `1`) is refused, matching Python: JS
|
|
80
|
+
cannot tell `1.0` from `1` after `JSON.parse`, so the raw token is checked.
|
|
81
|
+
- **`receipt --json` reports `valid: null` when no offline check ran** (no anchor — e.g. an unreadable store): the
|
|
82
|
+
server's own verdict alone is not enough to call a receipt verified, because the point is the offline pin.
|
|
83
|
+
Proved by: `cli-smoke.ts` +5 (session/trust-store location; `.env` promotes only `DECIDIO_*`; the poisoned cache; the
|
|
84
|
+
`1.0` version literal; `receipt --json` valid:null) plus 7 new origin-key cases, all red against the previous commit's
|
|
85
|
+
code. The release workflow's live-DID gate now uses `curl --retry-all-errors` and emits an explicit `::error::` when
|
|
86
|
+
the host is unreachable or the body is malformed.
|
|
87
|
+
|
|
88
|
+
### An approval is bound to the exact request it approved (#4B, BREAKING)
|
|
89
|
+
|
|
90
|
+
**Breaking.** Read "On upgrade" before bumping. The version is not bumped here; this ships as the next minor.
|
|
91
|
+
|
|
92
|
+
- **Every door confirms with Decidio that THIS request was approved before it runs it** — inline (the blocking
|
|
93
|
+
wait), the durable webhook and worker, LangGraph, Temporal, Inngest and OpenAI Agents, through one check
|
|
94
|
+
(`checkApproval`, exported; the adapters share `adapters/_gate.ts`). `GET /agent/status` must answer
|
|
95
|
+
`approved`/`auto_approved` AND return the request commitment this SDK sent with `/agent/authorize`, recomputed
|
|
96
|
+
over the request about to run. A resume webhook, a Temporal signal, an Inngest event or a LangGraph resume value
|
|
97
|
+
is now only a WAKE-UP; before, each of them was the authority, so anything able to send one approved the action.
|
|
98
|
+
A mismatch throws `DecidioRequestChangedError` ("the request changed after approval", naming the decision); a
|
|
99
|
+
gate that returns no commitment for a request this SDK bound throws `DecidioApprovalUnverifiedError`
|
|
100
|
+
(`code: "commitment_missing"`; `"unverifiable"` when there is no key material to recompute it). Both extend
|
|
101
|
+
`DecidioBlockedError`, so existing `catch` blocks still see a refusal. The durable resolve reports
|
|
102
|
+
`not_approved` (Decidio still says pending - the entry stays parked for the next wake-up) or
|
|
103
|
+
`request_unverified` (refused, kept parked, named once). Because it now asks Decidio first, **`resolve()` THROWS
|
|
104
|
+
when Decidio is unreachable** (nothing ran; the entry stays parked) - callers must catch it.
|
|
105
|
+
- **The commitment is keyed, never a digest** (invariant I5): lowercase-hex HMAC-SHA256 over the canonical
|
|
106
|
+
request (`committedRequestJson`) under a subkey of a secret only the agent holds - the Ed25519 signing key when
|
|
107
|
+
`agentKey`+`agentDid` are configured, else the API token (`bindingMaterial`, `requestCommitment`). An unkeyed
|
|
108
|
+
sha256 would sit in Decidio's database as a dictionary-attackable fingerprint of the request. Byte-identical to
|
|
109
|
+
the Python twin: shared vectors in `packages/conformance/vectors/canonical.json` (`commitments`), consumed by
|
|
110
|
+
both runners. A keyless agent sends none and is checked on Decidio's state alone. **Prefer the Ed25519 key for
|
|
111
|
+
binding**: under the API-token fallback the key is a bearer Decidio receives on every call, so the commitment is
|
|
112
|
+
opaque to everyone except the Decidio server itself. **Rotation strands pending approvals**: re-minting the
|
|
113
|
+
token, changing `DECIDIO_AGENT_KEY`, or a guard and resume controller with different key material turns every
|
|
114
|
+
routed approval still pending into `request_changed` - drain before rotating, or ask again.
|
|
115
|
+
- **LangGraph: the $100 -> $1,000,000 resume bug is closed.** LangGraph re-runs an interrupted node on resume, so
|
|
116
|
+
the node authorized AGAIN - minting a phantom second decision nobody saw - and then ran whatever it computed the
|
|
117
|
+
second time under the first approval: approve $100, edit the graph state, resume, and $1,000,000 executed (and
|
|
118
|
+
was confirmed under the phantom). Now the re-run authorizes with an idempotency key derived from its own thread +
|
|
119
|
+
checkpoint namespace + call index + commitment (`idempotencyKey`), Decidio answers with the SAME decision, the
|
|
120
|
+
resume value must name that decision, and the check runs. An edited state is a new task and a new request: it
|
|
121
|
+
becomes a NEW pending decision and the graph pauses on it; the old approval is never applied across requests.
|
|
122
|
+
`decidioResumeCommand(body)` now carries - and requires - the webhook's `decisionId`. The gate needs key material
|
|
123
|
+
and a checkpointed run with a `thread_id` and the node execution's scratchpad; without them it refuses (before
|
|
124
|
+
sending anything, or before pausing on a decision it could never find again, naming it - it never falls back to
|
|
125
|
+
a constant call index).
|
|
126
|
+
- **Temporal**: `DecidioGateOpts.verify` runs the check (as your activity) after the signal and before `run()`;
|
|
127
|
+
it is REQUIRED whenever `authorize` is supplied (refused before anything is routed). The activity body is
|
|
128
|
+
exported: `verifyApprovalActivity(config, decisionId, ctx)`. A signal only wakes the workflow; still pending
|
|
129
|
+
-> it waits for the next signal.
|
|
130
|
+
- **Inngest**: after each wait the check runs as a memoized step (`decidio-verify-approval`); a premature or
|
|
131
|
+
forged event waits again (later waits are `decidio-await-approval-1`, `-2`, ...: deterministic across replays).
|
|
132
|
+
- **OpenAI Agents: `applyResume(state, item, verdict)` is REMOVED**, not deprecated. Its only input was a verdict
|
|
133
|
+
string, so no deprecation shim could check anything - keeping it would keep the hole. Use
|
|
134
|
+
`await applyVerifiedResume(config, state, item, decisionId, describe)`: it approves the interruption only when
|
|
135
|
+
Decidio records that decision as approved for THIS tool call, rejects on a rejection, and otherwise throws with
|
|
136
|
+
the RunState unmodified. The approval is bound to the call itself - its name, call id and exact argument string
|
|
137
|
+
from `rawItem`, beside `describe(item)` (`toolCallRequest`) - so a parked item edited after approval (a target
|
|
138
|
+
`describe()` never covered) and a second identical call whose own decision is still pending are both refused
|
|
139
|
+
(`request_changed`); binding `describe(item)` alone let both through (#4B review W2). `pending[]` now carries
|
|
140
|
+
each `callId`; an interruption with no call id is refused before anything is sent.
|
|
141
|
+
- **The resume webhook no longer falls back to the API token** (`webhookHandler`, `fetchHandler`): with no
|
|
142
|
+
`webhookSecret` (and no `allowUnsigned`) it answers 401 naming the fix - a dedicated secret, **never the Decidio
|
|
143
|
+
server's `API_AUTH_TOKEN`** (its admin bearer). The token is a credential the agent SENDS; accepting webhooks
|
|
144
|
+
signed with it let anyone holding it drive resumes. The LOCAL integrity signature on parked payloads keeps its
|
|
145
|
+
token fallback, so entries parked before the upgrade still verify.
|
|
146
|
+
- **Known limit (predates this release): one gated action per LangGraph node.** The node re-runs on resume, so an
|
|
147
|
+
earlier gate in it runs again; one whose request auto-approved (action already run) replays as `proceed` to a
|
|
148
|
+
verified agent and its action runs a second time when a later gate in the node resumes. Put each gated action
|
|
149
|
+
in its own node, or make the actions idempotent.
|
|
150
|
+
- **Known limit (predates this release): LangGraph and OpenAI Agents have no single-execution claim.** Two
|
|
151
|
+
concurrent resumes of the same thread or RunState can both pass the check and both run the action; resume each
|
|
152
|
+
from one place. The core durable path claims each execution in its store.
|
|
153
|
+
- The demo gate (`demo-gate.ts`, the `--demo` stand-in) stores and returns commitments like the real gate.
|
|
154
|
+
|
|
155
|
+
**On upgrade:** (1) deploy the Decidio gate from this release FIRST - an older gate returns no commitment and a
|
|
156
|
+
keyed SDK refuses its routed approvals (`commitment_missing`: fail closed, never a silent pass). (2) Webhook
|
|
157
|
+
deployments: set `DECIDIO_WEBHOOK_SECRET` on both sides, or switch to `worker()`. (3) LangGraph: run with a
|
|
158
|
+
checkpointer + `thread_id`, and build the resume with `decidioResumeCommand(webhookBody)`. (4) Temporal: add the
|
|
159
|
+
`verify` activity. (5) OpenAI Agents: replace `applyResume` with `applyVerifiedResume`, park each pending
|
|
160
|
+
`{ callId, decisionId }` and find items again by `rawItem.callId`. (6) A test double of the gate must return
|
|
161
|
+
`requestCommitment` from `/agent/status`. (7) Catch errors from `resolve()`. (8) Drain pending approvals before
|
|
162
|
+
rotating the agent token or key. Entries parked by 0.6.x carry no commitment and are checked on Decidio's state
|
|
163
|
+
alone.
|
|
164
|
+
|
|
165
|
+
Proved by: `smoke.ts` (the request-binding block: approved-for-another-request, gate drops the commitment,
|
|
166
|
+
keyless, rejected/pending wake-ups, the durable webhook and worker), `adapters-smoke.ts` (the REAL
|
|
167
|
+
`@langchain/langgraph`: unedited resume = one decision; edit after approval = a new pending decision and
|
|
168
|
+
nothing executed; rejected; keyless refused; no scratchpad refused; Inngest forged event; Temporal verify
|
|
169
|
+
required; OpenAI verified resume incl. an edited target and a second identical call), `inngest-smoke.ts` (a forged
|
|
170
|
+
event executes nothing), both conformance runners (commitment vectors incl. the OpenAI tool-call binding built by
|
|
171
|
+
each SDK from its own native item, every door with a gate that returns the commitment), and `conformance/run.mjs` -
|
|
172
|
+
which caught the two twins committing different bytes for the same call (Python sent an empty `kwargs` member)
|
|
173
|
+
before this shipped.
|
|
174
|
+
|
|
8
175
|
## 0.6.4 — the request closes when the agent gives up
|
|
9
176
|
|
|
10
177
|
- **The README links the agent skill file** published on the site (https://decidioai.com/skills/decidio-guard.md, one page for both languages) and the site's llms.txt. The file is served by the site, not shipped in the package; an in-package copy is a one-line publish-boundary decision for this release if wanted.
|
package/README.md
CHANGED
|
@@ -154,21 +154,30 @@ becomes the record's execution evidence.
|
|
|
154
154
|
|
|
155
155
|
```bash
|
|
156
156
|
npx @decidio/sdk receipt <decisionId>
|
|
157
|
-
npx @decidio/sdk verify
|
|
157
|
+
npx @decidio/sdk verify decidio-receipt-<decisionId>.json
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
No DID to paste: `verify` pins to a **trust anchor** and prints it first. In order: an explicit
|
|
161
|
+
`--issuer <did:key>` if you give one; else the issuer DID this computer remembered for your Decidio
|
|
162
|
+
when you signed in (`login`, or the sign-in `init`/the tour asks for) — fetched from the host's
|
|
163
|
+
public `<api>/api/did` and kept in your profile beside the stored sign-in; else the built-in
|
|
164
|
+
issuer of Decidio's hosted service (`did:key:z6MkmLGeR5NjJ87a1yU5ygqju49CtnUrt38esqM54Y9tXnKq`,
|
|
165
|
+
published at https://decidio-api.onrender.com/api/did). A receipt's own `issuer` field is never the
|
|
166
|
+
anchor — it is only what the file claims, and any key can sign a file that names itself. If a later
|
|
167
|
+
sign-in sees the host publish a DIFFERENT DID - or a server's verify response names one - the CLI
|
|
168
|
+
says so loudly and keeps the remembered one: a new key is confirmed with the host's operator, not
|
|
169
|
+
adopted silently. The `receipt` command pins to the DID remembered for the host, else the DID the
|
|
170
|
+
server names as its own (`expectedIssuer`); checks the file offline against it; fails (exit 1) when
|
|
171
|
+
that check or the server's own check says the record does not verify; and prints the flagless verify
|
|
172
|
+
command only when `verify` - which takes its host from your shell's `DECIDIO_API_URL`, never `.env` -
|
|
173
|
+
would land on the same DID. An UNPINNED check proves internal consistency only, so it stays a named choice
|
|
174
|
+
(`--allow-unpinned`), and its verdict says so. The download is an authenticated fetch from your
|
|
165
175
|
workspace; the *verification* is fully offline — the verifier ships inside this package (no second download, no registry probe, same
|
|
166
176
|
implementation as the standalone `@decidio/verify`). The receipt is a W3C Verifiable
|
|
167
177
|
Credential: signature and content binding verify with no Decidio account and no network — the
|
|
168
|
-
evidence is yours, not ours.
|
|
169
|
-
`npx @decidio/
|
|
170
|
-
|
|
171
|
-
workspace's issuer DID from the authenticated response so you never have to hunt for it).
|
|
178
|
+
evidence is yours, not ours. On another machine, pin the same DID explicitly:
|
|
179
|
+
`npx @decidio/verify --issuer <did:key:...> receipt.json` (receipts from the hosted service need no
|
|
180
|
+
flag there either — the anchor is built in).
|
|
172
181
|
|
|
173
182
|
**What VALID means — and what it does not.** A pinned `VALID` proves the receipt's **claims** were
|
|
174
183
|
sealed by your workspace's issuer and that none of them has changed since. Not its *bytes*: the
|
|
@@ -320,8 +329,10 @@ executed, so without a second submission there is nothing left to approve.
|
|
|
320
329
|
those two, a cold worker has an empty registry and reports `no_handler` (the parked action is
|
|
321
330
|
kept, not lost, and the message names the exact fix).
|
|
322
331
|
|
|
323
|
-
**The signed webhook (operator deployments).** Decidio POSTs a signed
|
|
324
|
-
`resumeUrl`; the handler verifies the HMAC signature fail-closed,
|
|
332
|
+
**The signed webhook (operator deployments).** Decidio POSTs a signed wake-up to your agent's
|
|
333
|
+
`resumeUrl`; the handler verifies the HMAC signature fail-closed, asks Decidio for the decision, and
|
|
334
|
+
re-runs your function only if Decidio says THIS request was approved (the verdict inside the webhook
|
|
335
|
+
is never the authority — see "Every door confirms the approval" below):
|
|
325
336
|
|
|
326
337
|
```js
|
|
327
338
|
// Express: app.post("/decidio/resume", guard.resumeHandler())
|
|
@@ -332,7 +343,12 @@ Honest requirement: webhook signing uses a **shared secret configured on both si
|
|
|
332
343
|
`DECIDIO_WEBHOOK_SECRET` must equal the Decidio server's, and self-hosted production deployments
|
|
333
344
|
also allow-list resume hosts (default-deny). That's operator territory: if you run your own
|
|
334
345
|
Decidio (or we run a pilot with you), the webhook is set up then. Against the hosted sandbox,
|
|
335
|
-
use the worker — a webhook secret your server never learned would (correctly) fail closed.
|
|
346
|
+
use the worker — a webhook secret your server never learned would (correctly) fail closed. Since
|
|
347
|
+
0.7.0 there is **no API-token fallback**: without `webhookSecret` (or `allowUnsigned`, for a trusted
|
|
348
|
+
network only) the handler answers 401 and names the fix. The worker needs no inbound secret. The
|
|
349
|
+
secret is a dedicated one — **never the Decidio server's `API_AUTH_TOKEN`** (its admin bearer, which
|
|
350
|
+
must not be copied onto an agent host); since this release the server sends no webhook at all without
|
|
351
|
+
`DECIDIO_WEBHOOK_SECRET`.
|
|
336
352
|
|
|
337
353
|
**Earning the strongest evidence tier.** Decidio records `application_confirmed` — its
|
|
338
354
|
strongest wrapper tier — only when your function's captured response shows a committed record:
|
|
@@ -475,6 +491,64 @@ imports none of them — install the engine you use alongside the SDK (`npm i in
|
|
|
475
491
|
for engines you don't. Each adapter's shipped `.d.ts` header documents its wiring; `npx
|
|
476
492
|
@decidio/sdk quickstart` is the fastest runnable end-to-end.
|
|
477
493
|
|
|
494
|
+
### Every door confirms the approval is for THIS request (0.7.0)
|
|
495
|
+
|
|
496
|
+
Before 0.7.0 a resume ran on the word of whatever woke it — a webhook, a Temporal signal, an Inngest
|
|
497
|
+
event, a LangGraph resume value saying "approved". Now every door (inline, the durable webhook and
|
|
498
|
+
worker, LangGraph, Temporal, Inngest, OpenAI Agents) asks Decidio before your function runs:
|
|
499
|
+
`GET /agent/status` must say `approved` (or `auto_approved`) **and** return the request commitment
|
|
500
|
+
this SDK sent with `/agent/authorize`, recomputed over the request about to run. A wake-up is only a
|
|
501
|
+
wake-up.
|
|
502
|
+
|
|
503
|
+
- **The commitment** is an HMAC-SHA256 under your agent's key material over the canonical request:
|
|
504
|
+
`{action, amount, scope, args}` on the core wrapper, the described context on an engine adapter
|
|
505
|
+
(plus the tool call itself on the OpenAI door). With the Ed25519 key `init` writes
|
|
506
|
+
(`DECIDIO_AGENT_KEY` + `DECIDIO_AGENT_DID`) only your agent holds the key, and Decidio stores and
|
|
507
|
+
returns an opaque value it can neither read nor forge for another request. **Prefer that key.** Without
|
|
508
|
+
it the commitment is keyed by `DECIDIO_API_TOKEN` — a bearer Decidio receives on every call — so it is
|
|
509
|
+
opaque to everyone except the Decidio server itself.
|
|
510
|
+
- **Rotating key material strands pending approvals.** Re-minting the agent token, changing
|
|
511
|
+
`DECIDIO_AGENT_KEY`, or running the guard and the resume controller with different key material makes
|
|
512
|
+
every routed approval still pending fail its check as `request_changed` (the recompute no longer
|
|
513
|
+
matches). Drain pending approvals before rotating, or ask again for each afterwards.
|
|
514
|
+
- **Refusals** (both extend `DecidioBlockedError`; nothing ran): `DecidioRequestChangedError` —
|
|
515
|
+
"the request changed after approval", naming the decision — and `DecidioApprovalUnverifiedError`
|
|
516
|
+
(`code: "commitment_missing"` when Decidio returns no commitment for a request this SDK bound, or
|
|
517
|
+
`"unverifiable"`). The durable path reports `not_approved` (Decidio still says pending: the action
|
|
518
|
+
stays parked) or `request_unverified`. **`resolve()` now asks Decidio before it runs, so it THROWS when
|
|
519
|
+
Decidio is unreachable** (nothing ran; the entry stays parked) — catch it and retry, or leave the entry
|
|
520
|
+
for the worker.
|
|
521
|
+
- **LangGraph** needs key material (`init`, or `DECIDIO_API_TOKEN`) and a checkpointer run with a
|
|
522
|
+
`thread_id` (a routed gate without the run's thread, task and per-execution scratchpad refuses, naming
|
|
523
|
+
its decision). LangGraph re-runs an interrupted node on resume; the re-run authorizes with an
|
|
524
|
+
idempotency key derived from its own thread + task + commitment, so Decidio answers with the SAME
|
|
525
|
+
decision instead of minting a second one. Edit the graph state and the edited request becomes a
|
|
526
|
+
NEW pending decision — an approval is never applied across requests. `decidioResumeCommand` now
|
|
527
|
+
requires the webhook's `decisionId`.
|
|
528
|
+
- **Temporal**: whenever you pass `authorize`, also pass `verify: (id) => executeActivity(verifyActivity, id)`,
|
|
529
|
+
where the activity returns `verifyApprovalActivity(config, id, ctx)` (from `@decidio/sdk/temporal`)
|
|
530
|
+
for the same `ctx`. A signal only wakes the workflow; still pending → it waits for the next one.
|
|
531
|
+
- **Inngest**: the check runs as a memoized step after the event; a premature or forged event waits again.
|
|
532
|
+
- **OpenAI Agents**: `applyResume(state, item, verdict)` is removed. Use
|
|
533
|
+
`await applyVerifiedResume(config, state, item, decisionId, describe)`, which approves the
|
|
534
|
+
interruption only when Decidio records that decision as approved for THIS tool call: the approval is
|
|
535
|
+
bound to the call's name, call id and exact argument string as well as `describe(item)`, so an item
|
|
536
|
+
edited after approval, or a second identical call, never rides it. Park each pending
|
|
537
|
+
`{ callId, decisionId }` (both on `authorizeInterruptions(...).pending`) with the serialized state and,
|
|
538
|
+
after `RunState.fromString`, find each item again by `item.rawItem.callId`. An interruption with no
|
|
539
|
+
call id is refused before anything is sent.
|
|
540
|
+
- **Known limit — one gated action per LangGraph node** (true before 0.7.0 too). LangGraph re-runs the
|
|
541
|
+
whole node on resume, so an earlier gate in the same node runs again: if its request auto-approved and
|
|
542
|
+
its action already ran, the replay answers `proceed` to your verified agent and the action runs twice.
|
|
543
|
+
Put each gated action in its own node, or make the actions idempotent.
|
|
544
|
+
- **Known limit — LangGraph and OpenAI Agents have no single-execution claim** (true before 0.7.0 too).
|
|
545
|
+
The checkpoint or the RunState is your storage, so two concurrent resumes of the same thread or run
|
|
546
|
+
can both pass the check and both run the action. Resume each one from one place (one worker per
|
|
547
|
+
thread/run id, or a lock). The core durable path claims each execution in its store; Temporal and
|
|
548
|
+
Inngest rely on their engine's own activity/step semantics.
|
|
549
|
+
- **Upgrade the gate first.** A keyed SDK refuses a routed approval from a Decidio gate that does not
|
|
550
|
+
return commitments (`commitment_missing`) — it fails closed, never silently passes.
|
|
551
|
+
|
|
478
552
|
|
|
479
553
|
|
|
480
554
|
|
|
@@ -561,11 +635,13 @@ sealed after a specific predecessor, but a single-file verification cannot check
|
|
|
561
635
|
exists or is what it claims. Verify a chain (`verify --issuer <did> a.json b.json c.json`) when the sequence
|
|
562
636
|
matters.
|
|
563
637
|
|
|
564
|
-
**Pinning is trust-on-first-use.** The first
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
638
|
+
**Pinning is trust-on-first-use.** The first sign-in (or `init`) against a Decidio remembers the DID
|
|
639
|
+
it publishes at `<api>/api/did` — so that first contact trusts the connection, not the key. What
|
|
640
|
+
remembering buys is everything afterwards: every later verification is checked against a key this
|
|
641
|
+
computer already holds rather than one it was handed with the receipt, and a host that starts
|
|
642
|
+
publishing a different key is flagged, never silently adopted. Receipts from Decidio's hosted service
|
|
643
|
+
also verify against the issuer built into the verifier. The verifier refuses an unpinned run unless you
|
|
644
|
+
ask for one (`--allow-unpinned`), and marks that verdict.
|
|
569
645
|
|
|
570
646
|
## Upgrading from 0.2.x to 0.3.0
|
|
571
647
|
|
|
@@ -653,7 +729,7 @@ that fits every other confirm error.
|
|
|
653
729
|
- `doctor` — config, connectivity, and what your current token can do
|
|
654
730
|
- `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)
|
|
655
731
|
- `receipt <id> [--json]` — download a sealed decision's Authority Receipt to a `.json` file (`--json`: stdout is the JSON result only; the identities your access covers print only on a terminal, or in the JSON)
|
|
656
|
-
- `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
|
|
732
|
+
- `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. It pins to the trust anchor it prints first: `--issuer`, else the DID remembered for your Decidio at sign-in, else the built-in hosted-service issuer — never the receipt's own `issuer` field. Fully offline AND fully decoupled: it touches no network and reads no `.env` (only the issuer DIDs remembered in your profile) — a receipt verifies identically from any directory.
|
|
657
733
|
- `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
|
|
658
734
|
- `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) Also ends the stored sign-in.
|
|
659
735
|
- **Update check.** Network commands ask the public npm registry once a day whether a newer version exists; if so, an interactive run asks `Upgrade now? [y/n]` first (`y` upgrades the way you installed - `npm i @decidio/sdk@latest` in the project for a local install, `-g` for a global one, and under `npx` it prints the `@latest` form - then re-runs your command once; `n` exits), a headless run prints the notice and continues (`DECIDIO_REQUIRE_LATEST=1` makes it exit). `DECIDIO_NO_UPDATE_CHECK=1` skips it. Nothing is sent to Decidio; `verify` never checks.
|
|
@@ -679,6 +755,8 @@ manager for production.
|
|
|
679
755
|
|
|
680
756
|
- `DecidioBlockedError` — policy blocked the action: a named block rule, or the universal backstop that fires when NO active policy governs agents at all (fail closed, no human involved). Distinct from routing-by-default — with an active policy and no matching auto-approve rule, a request is not blocked, it is ROUTED to a person.
|
|
681
757
|
- `DecidioRejectedError` — a human rejected the routed action.
|
|
758
|
+
- `DecidioRequestChangedError` (0.7.0, a `DecidioBlockedError`) — Decidio approved the decision for a *different* request than the one about to run ("the request changed after approval"); nothing executed. Ask again for the request you mean to run.
|
|
759
|
+
- `DecidioApprovalUnverifiedError` (0.7.0, a `DecidioBlockedError`) — the approval could not be tied to this request: `code: "commitment_missing"` (Decidio returned no request commitment although this SDK sent one — typically a gate older than 0.7.0) or `"unverifiable"`. Nothing executed.
|
|
682
760
|
- `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
|
|
683
761
|
- `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); nothing executed locally. Since 0.6.4 the SDK's last act before throwing is to tell Decidio it gave up, so the request is closed as **withdrawn** (it leaves the queue; a later approval does nothing) — `error.withdrawn` is `true` when that landed and `false` when the call failed and the request may still be open. Withdrawing needs the bound agent token and the signing key `init` writes (an unbound or unsigned caller cannot withdraw anything — it just gets `withdrawn:false`). If a human decided in the last poll window, the SDK honours that decision instead: the action runs as approved, or `DecidioRejectedError` is thrown. The durable path never withdraws: its parked action is what a later approval resumes.
|
|
684
762
|
- `DecidioRateLimitError` — the workspace's governed-action limit was hit; carries the server's `limit` / `remaining` / `resetAt`.
|
|
@@ -712,10 +790,12 @@ the next reader grades a promise, not a moving target:
|
|
|
712
790
|
- **Engine adapters are Beta and outside the stability promise.** They pass the same conformance
|
|
713
791
|
battery as the core against structural mocks; no live LangGraph/Temporal/Inngest/OpenAI restart suite
|
|
714
792
|
exists yet. Use the core path for production until it does.
|
|
715
|
-
- **The issuer DID is trust on first use.**
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
793
|
+
- **The issuer DID is trust on first use.** Sign-in and `init` remember the DID your Decidio publishes at
|
|
794
|
+
`<api>/api/did`; the tour and `receipt` pin to the DID the server names as its own, never to the one
|
|
795
|
+
the receipt claims. Pinned verification is only as strong as that first contact: cross-check the DID
|
|
796
|
+
through a channel you trust independently (published in the open by the Decidio host you use at
|
|
797
|
+
`<api>/api/did` — for the hosted sandbox https://decidio-api.onrender.com/api/did, which is also the
|
|
798
|
+
issuer built into the verifier).
|
|
719
799
|
- **Verification is cryptographic, not schema validation.** `VALID` means the issuer's key signed these
|
|
720
800
|
canonical claims; the sealed-payload schema the receipt names by hash is not shipped, so a
|
|
721
801
|
structurally odd but correctly signed document is not refused on shape. Shipping the schema is on
|
package/dist/adapters/_gate.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type GuardConfig, type ApprovalContext, type EvidenceOutcome } from "../index.js";
|
|
1
|
+
import { type GuardConfig, type ApprovalContext, type EvidenceOutcome, type ApprovalCheck, type CommittedRequest } from "../index.js";
|
|
2
2
|
/** The ONLY authorization verdicts this SDK understands. A gate answer outside this set means
|
|
3
3
|
* the SDK does not understand the decision — and an answer you do not understand means NO. */
|
|
4
4
|
export declare const AUTHORIZE_VERDICTS: readonly ["proceed", "route", "block"];
|
|
@@ -15,7 +15,32 @@ export interface GateAuth {
|
|
|
15
15
|
* how an unknown verdict reached a protected action. Version skew (the day the gate learns a
|
|
16
16
|
* fourth verdict) is the realistic trigger, and it must never mean "run it". */
|
|
17
17
|
export declare function assertAuthorizeVerdict(auth: unknown, fallbackDecisionId?: string): GateAuth;
|
|
18
|
-
|
|
18
|
+
/** What an ENGINE door binds a routed approval to (0.7.0): the whole described context, canonicalized
|
|
19
|
+
* exactly as gateAuthorize canonicalizes it. An adapter cannot see inside its `run()` closure, so the
|
|
20
|
+
* described request - action, amount, scope and the display details a human approved - is the request.
|
|
21
|
+
* Recompute it from the SAME describe() the door authorized with. */
|
|
22
|
+
export declare function adapterRequest(ctx: ApprovalContext): CommittedRequest;
|
|
23
|
+
/** What the OpenAI Agents door binds a routed approval to (#4B review W2): the described context AND the
|
|
24
|
+
* tool call itself - its name, its call id and its exact argument string, from the interruption's raw
|
|
25
|
+
* item. describe(item) is the host's summary and need not cover every argument (edit a parked item's
|
|
26
|
+
* target and it can stay the same), and it never says WHICH call: without the call id one approval could
|
|
27
|
+
* be applied to a second, identical call whose own decision is still pending. An item with no call id
|
|
28
|
+
* cannot be bound to one call, so it is refused - nothing routed, nothing approved. (Python twin:
|
|
29
|
+
* tool_call_request.) */
|
|
30
|
+
export declare function toolCallRequest(item: unknown, ctx: ApprovalContext): CommittedRequest;
|
|
31
|
+
/** THE ONE APPROVAL CHECK on an engine door: GET /agent/status + the request commitment recomputed over
|
|
32
|
+
* this `ctx` (see checkApproval) - or over `request` when the door binds more than the described context
|
|
33
|
+
* (the OpenAI door's toolCallRequest). Returns the plain-JSON outcome - an engine that must run HTTP
|
|
34
|
+
* inside an activity or a step (Temporal, Inngest) memoizes exactly this value. */
|
|
35
|
+
export declare function gateCheckApproval(config: GuardConfig, decisionId: string, ctx: ApprovalContext, request?: CommittedRequest): Promise<ApprovalCheck>;
|
|
36
|
+
/** Validate an approval check that crossed a serialization boundary (a Temporal activity result, an
|
|
37
|
+
* Inngest step memo). Anything that is not a well-formed check is refused as unverifiable - the same
|
|
38
|
+
* "an answer you do not understand is never permission" rule the verdict allow-list applies. */
|
|
39
|
+
export declare function assertApprovalCheckShape(raw: unknown, decisionId: string): ApprovalCheck;
|
|
40
|
+
export declare function gateAuthorize(config: GuardConfig, ctx: ApprovalContext, opts?: {
|
|
41
|
+
idempotencyKey?: string;
|
|
42
|
+
request?: CommittedRequest;
|
|
43
|
+
}): Promise<GateAuth>;
|
|
19
44
|
/** Report an adapter-path execution and RETURN what Decidio recorded.
|
|
20
45
|
*
|
|
21
46
|
* Two 0.2.0 changes, both closing the same gap. It applies the caller's `evidence` mapper — the
|
package/dist/adapters/_gate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
|
|
2
|
+
import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem, bindingMaterial, requestCommitment, checkApproval } from "../index.js";
|
|
3
3
|
export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
|
|
4
4
|
export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
5
5
|
const a = auth;
|
|
@@ -13,7 +13,34 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
|
13
13
|
}
|
|
14
14
|
return a;
|
|
15
15
|
}
|
|
16
|
-
export
|
|
16
|
+
export function adapterRequest(ctx) {
|
|
17
|
+
const c = canonicalize(ctx, "describe(...)");
|
|
18
|
+
return { action: c.action, amount: c.amount, ...(c.scope !== undefined && c.scope !== null ? { scope: c.scope } : {}), ...(c.context !== undefined && c.context !== null ? { context: c.context } : {}) };
|
|
19
|
+
}
|
|
20
|
+
export function toolCallRequest(item, ctx) {
|
|
21
|
+
const it = (item && typeof item === "object" ? item : {});
|
|
22
|
+
const raw = (it.rawItem && typeof it.rawItem === "object" ? it.rawItem : it);
|
|
23
|
+
const callId = raw.callId ?? raw.call_id ?? raw.id;
|
|
24
|
+
if (typeof callId !== "string" || callId === "") {
|
|
25
|
+
throw new Error("[decidio] openai: this tool-approval item carries no call id (rawItem.callId), so an approval could not be bound to this ONE call - nothing was routed or approved. Pass the items state.getInterruptions() returns.");
|
|
26
|
+
}
|
|
27
|
+
const name = raw.name ?? it.name ?? it.toolName;
|
|
28
|
+
const args = raw.arguments ?? it.arguments;
|
|
29
|
+
return { ...adapterRequest(ctx), args: [{ arguments: args === undefined ? null : args, callId, name: typeof name === "string" ? name : null }] };
|
|
30
|
+
}
|
|
31
|
+
export async function gateCheckApproval(config, decisionId, ctx, request) {
|
|
32
|
+
return checkApproval(config, decisionId, request ?? adapterRequest(ctx));
|
|
33
|
+
}
|
|
34
|
+
export function assertApprovalCheckShape(raw, decisionId) {
|
|
35
|
+
const c = raw;
|
|
36
|
+
if (!c || typeof c !== "object" || typeof c.ok !== "boolean" || c.decisionId !== decisionId || typeof c.state !== "string") {
|
|
37
|
+
return { ok: false, decisionId, state: "", code: "unverifiable", reason: `the approval check for decision ${decisionId} came back malformed (${JSON.stringify(raw ?? null).slice(0, 120)}) - return checkApproval(...) / gateCheckApproval(...) from your activity unchanged` };
|
|
38
|
+
}
|
|
39
|
+
if (c.ok === true && c.code !== undefined)
|
|
40
|
+
return { ok: false, decisionId, state: c.state, code: "unverifiable", reason: "the approval check came back contradictory (ok with a refusal code)" };
|
|
41
|
+
return c;
|
|
42
|
+
}
|
|
43
|
+
export async function gateAuthorize(config, ctx, opts = {}) {
|
|
17
44
|
ctx = canonicalize(ctx, "describe(...)");
|
|
18
45
|
assertDescribed(ctx);
|
|
19
46
|
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
@@ -22,6 +49,8 @@ export async function gateAuthorize(config, ctx) {
|
|
|
22
49
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
23
50
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
24
51
|
: undefined;
|
|
52
|
+
const material = bindingMaterial(config);
|
|
53
|
+
const commitment = material ? requestCommitment(material, opts.request ?? adapterRequest(ctx)) : undefined;
|
|
25
54
|
const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/authorize", {
|
|
26
55
|
method: "POST",
|
|
27
56
|
headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
|
|
@@ -29,6 +58,8 @@ export async function gateAuthorize(config, ctx) {
|
|
|
29
58
|
requester: config.agentId, action: ctx.action, amount: ctx.amount, scope: ctx.scope,
|
|
30
59
|
sourceSystem: config.sourceSystem, ...(ctx.context ? { context: ctx.context } : {}),
|
|
31
60
|
...(requesterIdentity ? { requesterIdentity } : {}), ...(resumeUrl ? { resumeUrl } : {}),
|
|
61
|
+
...(commitment ? { requestCommitment: commitment } : {}),
|
|
62
|
+
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
32
63
|
}),
|
|
33
64
|
});
|
|
34
65
|
if (!res.ok) {
|
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
import { Command } from "@langchain/langgraph";
|
|
2
2
|
import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
|
|
3
|
-
/**
|
|
4
|
-
* @decidio/sdk/langgraph — durable suspend via LangGraph's native `interrupt()`.
|
|
5
|
-
* `interrupt` finds the running graph itself (no handle needed), so the gate drops into
|
|
6
|
-
* a node: authorize → on route, interrupt() durably pauses (the checkpointer persists;
|
|
7
|
-
* the process can die) → on resume (Command(resume={verdict})) run the agent's own
|
|
8
|
-
* action + confirm. Decidio is the approval system; the host bridges its webhook to the
|
|
9
|
-
* graph resume (decidioResumeCommand). Requires @langchain/langgraph (optional peer dep).
|
|
10
|
-
*
|
|
11
|
-
* (This wiring doc ships in the .d.ts — the README points here.)
|
|
12
|
-
*/
|
|
13
3
|
/** Gate `run()` behind Decidio inside a LangGraph node. */
|
|
14
4
|
export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>, opts?: {
|
|
15
5
|
evidence?: (r: R) => unknown;
|
|
16
6
|
}): Promise<ProtectedExecution<R>>;
|
|
17
7
|
/** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
|
|
18
|
-
* maps decisionId → thread_id and passes this to graph.invoke/stream.
|
|
8
|
+
* maps decisionId → thread_id and passes this to graph.invoke/stream. Since 0.7.0 the Command
|
|
9
|
+
* carries the decisionId - the gate refuses a resume value that does not say which decision it
|
|
10
|
+
* approves - and the verdict in it is informational only: the node confirms the approval with
|
|
11
|
+
* Decidio. Verify the webhook's signature BEFORE building this (verifyResumeSignature). */
|
|
19
12
|
export declare function decidioResumeCommand(body: {
|
|
13
|
+
decisionId?: string;
|
|
20
14
|
verdict?: string;
|
|
21
15
|
reason?: string;
|
|
22
16
|
}): Command;
|
|
@@ -1,19 +1,70 @@
|
|
|
1
1
|
import { interrupt, Command } from "@langchain/langgraph";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import * as langgraph from "@langchain/langgraph";
|
|
3
|
+
import { DecidioBlockedError, approvalRefusal, bindingMaterial, requestCommitment, idempotencyKey, decisionIdProblem } from "../index.js";
|
|
4
|
+
import { gateAuthorize, gateConfirm, gateCheckApproval, adapterRequest, checkEngineVersion } from "./_gate.js";
|
|
5
|
+
const gatesOpened = new WeakMap();
|
|
6
|
+
function runIdentity() {
|
|
7
|
+
let conf;
|
|
8
|
+
try {
|
|
9
|
+
const getConfig = langgraph.getConfig;
|
|
10
|
+
conf = typeof getConfig === "function" ? getConfig()?.configurable : undefined;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const thread = conf?.["thread_id"], ns = conf?.["checkpoint_ns"];
|
|
16
|
+
if ((typeof thread !== "string" && typeof thread !== "number") || String(thread) === "")
|
|
17
|
+
return undefined;
|
|
18
|
+
if (typeof ns !== "string" || ns === "")
|
|
19
|
+
return undefined;
|
|
20
|
+
const scratch = conf?.["__pregel_scratchpad"];
|
|
21
|
+
if (!scratch || typeof scratch !== "object")
|
|
22
|
+
return undefined;
|
|
23
|
+
const call = gatesOpened.get(scratch) ?? 0;
|
|
24
|
+
gatesOpened.set(scratch, call + 1);
|
|
25
|
+
return { thread: String(thread), ns, call };
|
|
26
|
+
}
|
|
4
27
|
export async function decidioGate(config, ctx, run, opts) {
|
|
5
|
-
const
|
|
28
|
+
const material = bindingMaterial(config);
|
|
29
|
+
if (!material) {
|
|
30
|
+
throw new Error("[decidio] the LangGraph adapter binds a routed approval to the exact request with a key only this agent holds, and none is configured - set DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID (npx @decidio/sdk init) or DECIDIO_API_TOKEN. Nothing was routed or executed.");
|
|
31
|
+
}
|
|
32
|
+
const request = adapterRequest(ctx);
|
|
33
|
+
const self = runIdentity();
|
|
34
|
+
const key = self ? idempotencyKey(material, { thread: self.thread, ns: self.ns, call: self.call, commitment: requestCommitment(material, request) }) : undefined;
|
|
35
|
+
const auth = await gateAuthorize(config, ctx, key ? { idempotencyKey: key } : {});
|
|
6
36
|
if (auth.decision === "block")
|
|
7
37
|
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
8
38
|
if (auth.decision === "route") {
|
|
9
|
-
|
|
10
|
-
|
|
39
|
+
if (!self) {
|
|
40
|
+
throw new DecidioBlockedError(`this LangGraph run exposes no thread/task identity (a checkpointer with a thread_id, @langchain/langgraph's getConfig() and the node execution's scratchpad are required), so a resumed node could not recognise decision ${auth.decisionId} as its own. Nothing was executed; ${auth.decisionId} is pending in Decidio - reject it (npx @decidio/sdk approvals reject ${auth.decisionId}).`, auth.decisionId);
|
|
41
|
+
}
|
|
42
|
+
const payload = { decidio: { decisionId: auth.decisionId, action: ctx.action, amount: ctx.amount, scope: ctx.scope } };
|
|
43
|
+
let resumed = interrupt(payload);
|
|
44
|
+
for (;;) {
|
|
45
|
+
const named = resumed && typeof resumed === "object" ? resumed.decisionId : undefined;
|
|
46
|
+
if (typeof named !== "string" || named === "") {
|
|
47
|
+
throw new DecidioBlockedError(`the LangGraph resume value names no decision (${JSON.stringify(resumed ?? null).slice(0, 120)}) - refusing to execute. Build it with decidioResumeCommand(webhookBody), which carries the decisionId: an approval that does not say which decision it approves cannot be applied to this one.`, auth.decisionId);
|
|
48
|
+
}
|
|
49
|
+
if (named === auth.decisionId) {
|
|
50
|
+
const check = await gateCheckApproval(config, auth.decisionId, ctx);
|
|
51
|
+
if (check.ok)
|
|
52
|
+
break;
|
|
53
|
+
if (check.code !== "pending")
|
|
54
|
+
throw approvalRefusal(check);
|
|
55
|
+
}
|
|
56
|
+
resumed = interrupt(payload);
|
|
57
|
+
}
|
|
11
58
|
}
|
|
12
59
|
const result = await run();
|
|
13
60
|
const confirmation = await gateConfirm(config, auth.decisionId, result, opts?.evidence);
|
|
14
61
|
return { value: result, decisionId: auth.decisionId, confirmation };
|
|
15
62
|
}
|
|
16
63
|
export function decidioResumeCommand(body) {
|
|
17
|
-
|
|
64
|
+
const decisionId = body?.decisionId;
|
|
65
|
+
const problem = decisionIdProblem(decisionId);
|
|
66
|
+
if (problem)
|
|
67
|
+
throw new Error(`[decidio] decidioResumeCommand needs the webhook's decisionId (${problem}) - the node refuses a resume that does not say which decision it approves. Pass the webhook body verbatim.`);
|
|
68
|
+
return new Command({ resume: { decisionId, verdict: body.verdict, reason: body.reason } });
|
|
18
69
|
}
|
|
19
70
|
checkEngineVersion("langgraph");
|