@decidio/sdk 0.3.5 → 0.4.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 +92 -0
- package/README.md +41 -1
- package/dist/adapters/temporal.d.ts +5 -2
- package/dist/adapters/temporal.js +4 -3
- package/dist/cli.js +109 -25
- package/dist/inngest.d.ts +2 -0
- package/dist/resume.d.ts +5 -3
- package/dist/resume.js +6 -1
- package/dist/signing.d.ts +4 -0
- package/dist/signing.js +5 -2
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Changelog — @decidio/sdk
|
|
2
|
+
|
|
3
|
+
Release notes for the client SDK. The Python package `decidio` ships the same changes under the same
|
|
4
|
+
version (the two are conformance-locked twins). Each entry names what changed, what a reader must do
|
|
5
|
+
on upgrade, and what proved it.
|
|
6
|
+
|
|
7
|
+
## 0.4.0 — the scope statement
|
|
8
|
+
|
|
9
|
+
- **README: "What 0.4 does not yet claim" and "Compatibility and support."** The stability promise is
|
|
10
|
+
stated: what is stable, what is Beta (the engine adapters), what is deprecated and kept until 1.0
|
|
11
|
+
(`protectBestEffort`, `gateInterruptions`), and the five things reviewers keep finding that only
|
|
12
|
+
product decisions close (trust-on-first-use issuer DID, the unshipped schema, the unshipped
|
|
13
|
+
conformance suites, provenance and the repository link). 1.0.0 follows user-acceptance testing.
|
|
14
|
+
- **The tour fails closed on an unpinned check.** If the API omits the issuer DID, the tour used to warn
|
|
15
|
+
and still report success; it now fails and cleans up.
|
|
16
|
+
- **The tour's preflight names everything it writes**, including `.decidio-token-journal` and
|
|
17
|
+
`.decidio-pending/`.
|
|
18
|
+
- **`--` ends option parsing on all three verifier doors**, so a receipt saved under a dash-leading
|
|
19
|
+
name can be verified.
|
|
20
|
+
- **The token recovery journal treats corruption as uncertainty, never absence** (prodsec retest of
|
|
21
|
+
0.3.5). Every row is parsed and schema-checked before any mint; a row that cannot be trusted refuses
|
|
22
|
+
`init` and `quickstart`, turns `doctor` red and makes `quickstart cleanup` exit non-zero - the bytes are
|
|
23
|
+
left as found. Appends are framed (a delimiter first if the previous row lost its newline, a schema
|
|
24
|
+
version, a write loop before the fsync), so a torn row stays discoverable.
|
|
25
|
+
- **A resume signal that finds the decision `invoking` answers `indeterminate`**, not `already_resolved`
|
|
26
|
+
with "already ran": the action may still be running, may have crashed before any effect, or may have
|
|
27
|
+
finished without reporting. `already_resolved` is reserved for the final states.
|
|
28
|
+
- **Temporal (Beta): a custom `confirm` may return the gate's confirm outcome** (validated with
|
|
29
|
+
`readConfirmResponse`) and it becomes the step's confirmation instead of being discarded.
|
|
30
|
+
- **Transport: every refusal redacts URL userinfo**, including the opaque `scheme:user:secret@` spelling,
|
|
31
|
+
and Unicode space and format characters (NBSP, ideographic space, zero-width space, …) are refused in
|
|
32
|
+
both runtimes from one explicit list. Four cases joined the shared transport corpus.
|
|
33
|
+
- **This file ships** in the tarball (and in the Python sdist), as a deliberate publish-boundary line.
|
|
34
|
+
|
|
35
|
+
## 0.3.5 — the retest residuals
|
|
36
|
+
|
|
37
|
+
- **Transport:** any control character or whitespace anywhere in the URL is refused before anything is
|
|
38
|
+
sent; refusals echo a redacted spelling (URL userinfo is a credential).
|
|
39
|
+
- **Credential lifecycle:** the quickstart inspects a previous `.env.quickstart` before minting; secret
|
|
40
|
+
files are written temp + fsync + rename; **the token recovery journal** (`.decidio-token-journal`,
|
|
41
|
+
owner-only, append-only) records every minted token id before the credential file exists and retires
|
|
42
|
+
it on a durable write or any proven revoke. Credential creation fails closed if the journal cannot be
|
|
43
|
+
written. `quickstart cleanup` sweeps it; `doctor` lists outstanding entries.
|
|
44
|
+
- **Verifiers (all three doors):** the `verificationMethod` must be the issuer's exact `did:key` method
|
|
45
|
+
identifier, and `created` must be a real calendar date-time. Correctly signed receipts naming a
|
|
46
|
+
nonexistent method fragment or an impossible time are now INVALID.
|
|
47
|
+
- **Adapter gate:** the same describe validation as the core (a blank action or negative amount is
|
|
48
|
+
refused before signing or posting).
|
|
49
|
+
- **Outcomes:** a resume signal that lost the race to a final answer is `already_resolved`; a parked row
|
|
50
|
+
whose payload is gone is `orphaned_state`.
|
|
51
|
+
- Upgrade: no action. Custom stores are unaffected.
|
|
52
|
+
|
|
53
|
+
## 0.3.4 — first-run consistency
|
|
54
|
+
|
|
55
|
+
- Tour and README pointers, routing-by-default vs the universal-backstop block named as two states, the
|
|
56
|
+
stale "unsafe values park and fail on resume" paragraph corrected, cleanup discloses the durable
|
|
57
|
+
store, help never loads `.env` (and only when usage will actually print), adapter headers current.
|
|
58
|
+
- Upgrade: no action.
|
|
59
|
+
|
|
60
|
+
## 0.3.2 / 0.3.3 — the transition itself is atomic (COORDINATED UPGRADE)
|
|
61
|
+
|
|
62
|
+
- **The shipped file stores are an append-only revision chain** (`<key>.rev.<N>`), published with a
|
|
63
|
+
hard link that refuses an existing name: exactly one writer wins each transition, and a loser
|
|
64
|
+
re-validates against what actually happened. This closed the pre-0.3.2 race in which a denial could
|
|
65
|
+
be written and returned and the denied action still run.
|
|
66
|
+
- **Upgrade — read this if a directory is shared by more than one worker:** the first 0.3.2+ store
|
|
67
|
+
migrates the older marker layout one way. **Stop every older worker that shares the directory before
|
|
68
|
+
the first 0.3.2+ process starts**, then upgrade them together. An older worker writing into an
|
|
69
|
+
upgraded directory leaves a marker beside the chain, and that decision reads as unreadable by design.
|
|
70
|
+
Nothing deletes a revision; each decision keeps a handful of small files (see the README's sizing
|
|
71
|
+
envelope).
|
|
72
|
+
- 0.3.3: one snapshot of `describe()`'s answer for signing and posting; the shared id grammar at every
|
|
73
|
+
exported resume door; a compensating revoke when a minted token's credential file fails to write;
|
|
74
|
+
`reconfirm()` on a reported decision tells the truth about confirmation.
|
|
75
|
+
|
|
76
|
+
## 0.3.1
|
|
77
|
+
|
|
78
|
+
- The verifier says what it proves (canonical signed claims, not exact bytes); proof options
|
|
79
|
+
(`proofPurpose`, `verificationMethod`, `created`) are part of the claim on every door; duplicate
|
|
80
|
+
`--issuer` flags are refused everywhere; orphaned token ids survive every replacement.
|
|
81
|
+
|
|
82
|
+
## 0.3.0
|
|
83
|
+
|
|
84
|
+
- A claim that gates nothing: `reportRecorded` and `executionConfirmed` are distinct, honest booleans.
|
|
85
|
+
A denial is final on every path. The decision-id grammar is defined server-side and mirrored here.
|
|
86
|
+
- Upgrade for custom stores: `markDiscarded` is required (a hard construction failure without it).
|
|
87
|
+
|
|
88
|
+
## 0.2.x
|
|
89
|
+
|
|
90
|
+
- The execution state machine (`parked → claimed_preinvoke → invoking → executed_unreported |
|
|
91
|
+
indeterminate → reported`), `reconcile()`, `ExecutionContext` with a stable idempotency key, and the
|
|
92
|
+
composite return (`{ value, decisionId, confirmation }`) with `protectBestEffort` keeping the old shape.
|
package/README.md
CHANGED
|
@@ -66,7 +66,8 @@ stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another De
|
|
|
66
66
|
`init` signs you into your workspace (email + password, or paste the sign-in link from your
|
|
67
67
|
workspace email), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
|
|
68
68
|
is sent), registers the agent, mints its **floor-limited API token** (limited to the agent runtime — request authorization, poll status, report signed confirmation; it cannot administer the workspace or approve anything — and your workspace session never touches the agent's disk), and writes `.env`.
|
|
69
|
-
Every later command reads `.env` from this directory automatically
|
|
69
|
+
Every later command that talks to Decidio reads `.env` from this directory automatically (`verify` is
|
|
70
|
+
offline and never does). Re-running init for an
|
|
70
71
|
agent that already has a token REPLACES it — the server keeps one active token per agent, so
|
|
71
72
|
the previous one is revoked and init says so.
|
|
72
73
|
|
|
@@ -677,6 +678,45 @@ Every error raised after a decision exists carries the `decisionId`, so a suspen
|
|
|
677
678
|
timed-out action is always findable in
|
|
678
679
|
your queue.
|
|
679
680
|
|
|
681
|
+
## What 0.4 does not yet claim
|
|
682
|
+
|
|
683
|
+
The retest that graded 0.3.3 *A- for the core and the shipped stores* and the first-run reviews of
|
|
684
|
+
0.3.3 and 0.3.5 agree on what is left, and none of it is a defect a patch release closes. Stated here so
|
|
685
|
+
the next reader grades a promise, not a moving target:
|
|
686
|
+
|
|
687
|
+
- **Engine adapters are Beta and outside the stability promise.** They pass the same conformance
|
|
688
|
+
battery as the core against structural mocks; no live LangGraph/Temporal/Inngest/OpenAI restart suite
|
|
689
|
+
exists yet. Use the core path for production until it does.
|
|
690
|
+
- **The issuer DID is trust on first use.** The tour and `receipt` print your workspace's issuer DID
|
|
691
|
+
from the same authenticated response that carries the receipt. Pinned verification is only as strong
|
|
692
|
+
as where you keep that DID: record it through a channel you trust independently (your app's
|
|
693
|
+
record-verify panel today; a published well-known location is on the roadmap).
|
|
694
|
+
- **Verification is cryptographic, not schema validation.** `VALID` means the issuer's key signed these
|
|
695
|
+
canonical claims; the sealed-payload schema the receipt names by hash is not shipped, so a
|
|
696
|
+
structurally odd but correctly signed document is not refused on shape. Shipping the schema is on
|
|
697
|
+
the roadmap.
|
|
698
|
+
- **The conformance suites are not in the package.** The deterministic two-process schedules that
|
|
699
|
+
prove the durable store, and the corpus every door is held to, live in the Decidio source repository.
|
|
700
|
+
A customer cannot rerun them from this tarball; a public conformance kit is on the roadmap.
|
|
701
|
+
- **No provenance attestation, no repository link.** The source repository is private; npm refuses
|
|
702
|
+
provenance for private repositories, and a link that 404s is worse than none. Registry signatures and
|
|
703
|
+
lockfile integrity are what you can check today.
|
|
704
|
+
|
|
705
|
+
## Compatibility and support
|
|
706
|
+
|
|
707
|
+
- **Stable in 0.4:** `guard.protect` / `withApproval` and their options; the durable resume
|
|
708
|
+
controller (`createDecidioResume`: `park`, `resolve`, `reconcile`, `reconfirm`, `recover`, the worker
|
|
709
|
+
and both webhook handlers); the `PendingStore` contract and both shipped stores, including the on-disk
|
|
710
|
+
revision-chain layout; `EvidenceOutcome` (a root export) and `ResumeStatus` / `RecoveryState` (on the
|
|
711
|
+
`@decidio/sdk/resume` subpath), additive growth only; the error classes; the CLI's commands, flags and exit codes; the receipt verifier's verdict and
|
|
712
|
+
reasons. Changes to any of these announce themselves in the changelog first.
|
|
713
|
+
- **Beta:** the engine adapters (above).
|
|
714
|
+
- **Deprecated, kept until 1.0:** `protectBestEffort` (the value-only shape) and the old adapter
|
|
715
|
+
name `gateInterruptions`. They will be removed in 1.0.0, not before.
|
|
716
|
+
- **Versioning:** 0.x minor releases may still carry deliberate breaking cleanups, announced in the
|
|
717
|
+
changelog with the upgrade step. 1.0.0 follows user-acceptance testing of this release and freezes the
|
|
718
|
+
surface above.
|
|
719
|
+
|
|
680
720
|
## Notes
|
|
681
721
|
|
|
682
722
|
- **Framework-agnostic:** it wraps at the tool-call boundary, so it works with any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any action (Salesforce, M365, SAP, a DB write).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type GuardConfig, type ApprovalContext, type ProtectedExecution } from "../index.js";
|
|
1
|
+
import { type GuardConfig, type ApprovalContext, type ProtectedExecution, type EvidenceOutcome } 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
|
|
@@ -23,7 +23,10 @@ export declare function installDecidioSignal(): Map<string, {
|
|
|
23
23
|
}>;
|
|
24
24
|
export interface DecidioGateOpts<R = unknown> {
|
|
25
25
|
authorize?: () => Promise<GateAuth>;
|
|
26
|
-
|
|
26
|
+
/** Reports through YOUR activity. Return the gate's confirm response body (validated with
|
|
27
|
+
* `readConfirmResponse`) and it becomes the outcome; return nothing and the outcome says the SDK could
|
|
28
|
+
* not read back what Decidio recorded (prodsec V035-03: a returned outcome used to be discarded). */
|
|
29
|
+
confirm?: (decisionId: string, result: unknown) => Promise<EvidenceOutcome | void>;
|
|
27
30
|
/** Map your action's return value to the evidence Decidio records — the same option the core
|
|
28
31
|
* path takes. Every adapter's stderr note named it and no adapter accepted one, so on this
|
|
29
32
|
* path the advice could not be followed. */
|
|
@@ -25,12 +25,13 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
|
|
|
25
25
|
if (opts.confirm) {
|
|
26
26
|
if (opts.evidence)
|
|
27
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.");
|
|
28
|
-
await opts.confirm(auth.decisionId, result);
|
|
29
|
-
|
|
28
|
+
const returned = await opts.confirm(auth.decisionId, result);
|
|
29
|
+
const usable = !!returned && typeof returned === "object" && typeof returned.reportRecorded === "boolean";
|
|
30
|
+
confirmation = usable ? returned : {
|
|
30
31
|
reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: false,
|
|
31
32
|
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
33
|
};
|
|
33
|
-
if (!confirmWarned) {
|
|
34
|
+
if (!usable && !confirmWarned) {
|
|
34
35
|
confirmWarned = true;
|
|
35
36
|
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.`);
|
|
36
37
|
}
|
package/dist/cli.js
CHANGED
|
@@ -168,11 +168,25 @@ async function revokeFreshMint(jti, bearer, writeError, target) {
|
|
|
168
168
|
return false;
|
|
169
169
|
}
|
|
170
170
|
const JOURNAL_PATH = ".decidio-token-journal";
|
|
171
|
+
const JOURNAL_SCHEMA = 1;
|
|
172
|
+
const JOURNAL_EVENTS = new Set(["probe", "minted", "retired"]);
|
|
171
173
|
function journalAppend(entry) {
|
|
172
174
|
const fresh = !existsSync(JOURNAL_PATH);
|
|
175
|
+
const row = Buffer.from(JSON.stringify({ v: JOURNAL_SCHEMA, ...entry, at: new Date().toISOString() }) + "\n", "utf8");
|
|
173
176
|
const fd = openSync(JOURNAL_PATH, "a", 0o600);
|
|
174
177
|
try {
|
|
175
|
-
|
|
178
|
+
if (!fresh) {
|
|
179
|
+
const st = readFileSync(JOURNAL_PATH);
|
|
180
|
+
if (st.length && st[st.length - 1] !== 0x0a) {
|
|
181
|
+
let w = 0;
|
|
182
|
+
const nl = Buffer.from("\n");
|
|
183
|
+
while (w < nl.length)
|
|
184
|
+
w += writeSync(fd, nl, w);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
let written = 0;
|
|
188
|
+
while (written < row.length)
|
|
189
|
+
written += writeSync(fd, row, written);
|
|
176
190
|
fsyncSync(fd);
|
|
177
191
|
}
|
|
178
192
|
finally {
|
|
@@ -186,7 +200,60 @@ function journalAppend(entry) {
|
|
|
186
200
|
hardenFile(JOURNAL_PATH);
|
|
187
201
|
}
|
|
188
202
|
}
|
|
203
|
+
function journalRowProblem(e) {
|
|
204
|
+
if (!e || typeof e !== "object" || Array.isArray(e))
|
|
205
|
+
return "not a JSON object";
|
|
206
|
+
if (e.v !== undefined && e.v !== JOURNAL_SCHEMA)
|
|
207
|
+
return `schema version ${JSON.stringify(e.v)} is not ${JOURNAL_SCHEMA}`;
|
|
208
|
+
if (typeof e.event !== "string" || !JOURNAL_EVENTS.has(e.event))
|
|
209
|
+
return `unknown event ${JSON.stringify(e.event ?? null)}`;
|
|
210
|
+
if (e.event !== "probe" && (typeof e.jti !== "string" || !e.jti.trim()))
|
|
211
|
+
return `${e.event} row without a string jti`;
|
|
212
|
+
if (e.event === "retired" && e.how !== "persisted" && e.how !== "revoked")
|
|
213
|
+
return `retired row with how=${JSON.stringify(e.how ?? null)}`;
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
class JournalCorruptError extends Error {
|
|
217
|
+
line;
|
|
218
|
+
why;
|
|
219
|
+
constructor(line, why) {
|
|
220
|
+
super(`${JOURNAL_PATH} line ${line}: ${why}`);
|
|
221
|
+
this.line = line;
|
|
222
|
+
this.why = why;
|
|
223
|
+
this.name = "JournalCorruptError";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function journalRows() {
|
|
227
|
+
if (!existsSync(JOURNAL_PATH))
|
|
228
|
+
return [];
|
|
229
|
+
const rows = [];
|
|
230
|
+
const lines = readFileSync(JOURNAL_PATH, "utf8").split(/\r?\n/);
|
|
231
|
+
for (let i = 0; i < lines.length; i++) {
|
|
232
|
+
const line = lines[i];
|
|
233
|
+
if (!line.trim())
|
|
234
|
+
continue;
|
|
235
|
+
let e;
|
|
236
|
+
try {
|
|
237
|
+
e = JSON.parse(line);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
throw new JournalCorruptError(i + 1, "not valid JSON (a torn or hand-edited row)");
|
|
241
|
+
}
|
|
242
|
+
const why = journalRowProblem(e);
|
|
243
|
+
if (why)
|
|
244
|
+
throw new JournalCorruptError(i + 1, why);
|
|
245
|
+
rows.push(e);
|
|
246
|
+
}
|
|
247
|
+
return rows;
|
|
248
|
+
}
|
|
249
|
+
const REFUSE_MINT_CORRUPT_JOURNAL = (e) => `✘ the token recovery journal cannot be trusted (${e.message}) - it may hold the id of a token that is still live. Refusing to mint on top of uncertain history. Inspect the file, revoke anything it names (npx @decidio/sdk tokens list --mine), and repair or move it aside; every byte is left as found. Nothing was minted.`;
|
|
189
250
|
function journalProbe() {
|
|
251
|
+
try {
|
|
252
|
+
journalRows();
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
return `CORRUPT: ${e?.message ?? e}`;
|
|
256
|
+
}
|
|
190
257
|
try {
|
|
191
258
|
journalAppend({ event: "probe" });
|
|
192
259
|
return null;
|
|
@@ -201,27 +268,18 @@ function journalRetire(jti, how) { try {
|
|
|
201
268
|
}
|
|
202
269
|
catch { } }
|
|
203
270
|
function journalOutstanding() {
|
|
204
|
-
if (!existsSync(JOURNAL_PATH))
|
|
205
|
-
return [];
|
|
206
271
|
const minted = new Map();
|
|
207
|
-
for (const
|
|
208
|
-
if (
|
|
209
|
-
continue;
|
|
210
|
-
let e;
|
|
211
|
-
try {
|
|
212
|
-
e = JSON.parse(line);
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
if (e?.event === "minted" && typeof e.jti === "string")
|
|
272
|
+
for (const e of journalRows()) {
|
|
273
|
+
if (e.event === "minted")
|
|
218
274
|
minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
|
|
219
|
-
if (e
|
|
275
|
+
if (e.event === "retired")
|
|
220
276
|
minted.delete(e.jti);
|
|
221
277
|
}
|
|
222
278
|
return [...minted.values()];
|
|
223
279
|
}
|
|
224
|
-
const REFUSE_MINT_NO_JOURNAL = (why) =>
|
|
280
|
+
const REFUSE_MINT_NO_JOURNAL = (why) => why.startsWith("CORRUPT: ")
|
|
281
|
+
? REFUSE_MINT_CORRUPT_JOURNAL(new Error(why.slice("CORRUPT: ".length)))
|
|
282
|
+
: `✘ could not write the token recovery journal (${JOURNAL_PATH}: ${why}) - refusing to mint a credential this directory cannot record a revocation handle for. Fix the directory's permissions or free space and re-run. Nothing was minted.`;
|
|
225
283
|
function upsertEnv(updates) {
|
|
226
284
|
const remaining = { ...updates };
|
|
227
285
|
const updatedKeys = new Set();
|
|
@@ -514,14 +572,14 @@ async function cmdDoctor() {
|
|
|
514
572
|
let warned = 0;
|
|
515
573
|
const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
|
|
516
574
|
const warn = (label) => { warned++; log(`⚠ ${label}`); };
|
|
517
|
-
let
|
|
575
|
+
let journalItems = [];
|
|
518
576
|
try {
|
|
519
|
-
|
|
577
|
+
journalItems = journalOutstanding();
|
|
520
578
|
}
|
|
521
579
|
catch (e) {
|
|
522
|
-
|
|
580
|
+
line(false, `token recovery journal: ${e?.message ?? e} — it may hold the id of a live token, and this directory cannot mint until it is repaired or moved aside (every byte is left as found).`);
|
|
523
581
|
}
|
|
524
|
-
for (const j of
|
|
582
|
+
for (const j of journalItems) {
|
|
525
583
|
const where = j.apiUrl && j.apiUrl !== API ? `DECIDIO_API_URL=${j.apiUrl} ` : "";
|
|
526
584
|
warn(`token recovery journal: jti ${j.jti} (${j.label ?? "token"}, agent ${j.agentId ?? "?"}, minted ${j.at ?? "?"} against ${j.apiUrl ?? API}) has no recorded revocation in this directory's journal — it may still be live. Revoke it, or confirm it is gone: ${where}npx @decidio/sdk quickstart cleanup (or: ${where}npx @decidio/sdk tokens revoke ${j.jti})`);
|
|
527
585
|
}
|
|
@@ -762,6 +820,10 @@ function cmdVerify(argsIn) {
|
|
|
762
820
|
const files = [];
|
|
763
821
|
for (let i = 0; i < argsIn.length; i++) {
|
|
764
822
|
const a = argsIn[i];
|
|
823
|
+
if (a === "--") {
|
|
824
|
+
files.push(...argsIn.slice(i + 1));
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
765
827
|
if (a === "--issuer") {
|
|
766
828
|
const v = argsIn[i + 1];
|
|
767
829
|
if (v === undefined || v === "" || v.startsWith("-")) {
|
|
@@ -817,8 +879,10 @@ async function cmdQuickstart(opts = {}) {
|
|
|
817
879
|
log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
|
|
818
880
|
log("");
|
|
819
881
|
log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
|
|
820
|
-
log(" write .env (or .env.quickstart)
|
|
821
|
-
log("
|
|
882
|
+
log(" write .env (or .env.quickstart), .decidio-token-journal (revocation ids, no secrets) and .gitignore");
|
|
883
|
+
log(" entries (incl. .decidio-pending/, the durable store your real agent will use) · save the receipt");
|
|
884
|
+
log(" as decidio-receipt-<id>.json here (public; it stays) · create one synthetic decision and its");
|
|
885
|
+
log(" sealed record (records are permanent by design). Cleanup offer at the end.");
|
|
822
886
|
log("");
|
|
823
887
|
const session = await acquireSession("The quickstart");
|
|
824
888
|
if (!session)
|
|
@@ -1043,11 +1107,17 @@ async function cmdQuickstart(opts = {}) {
|
|
|
1043
1107
|
const issuer = rec.body?.issuer;
|
|
1044
1108
|
const v = verifyReceipt(rec.body.credential, issuer);
|
|
1045
1109
|
log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
|
|
1110
|
+
if (!v.valid) {
|
|
1111
|
+
err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
|
|
1112
|
+
return failTour(1);
|
|
1113
|
+
}
|
|
1046
1114
|
if (!issuer) {
|
|
1047
1115
|
err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
|
|
1048
1116
|
err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
|
|
1049
1117
|
err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
|
|
1050
1118
|
err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
|
|
1119
|
+
err(` ✘ the tour does not count an UNPINNED check as success.`);
|
|
1120
|
+
return failTour(1);
|
|
1051
1121
|
}
|
|
1052
1122
|
if (issuer)
|
|
1053
1123
|
log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
|
|
@@ -1101,7 +1171,15 @@ async function quickstartCleanup(session, envFile) {
|
|
|
1101
1171
|
})();
|
|
1102
1172
|
let journalRevoked = 0;
|
|
1103
1173
|
const journalStuck = [];
|
|
1104
|
-
|
|
1174
|
+
let outstanding = [];
|
|
1175
|
+
try {
|
|
1176
|
+
outstanding = journalOutstanding();
|
|
1177
|
+
}
|
|
1178
|
+
catch (e) {
|
|
1179
|
+
err(`✘ the token recovery journal cannot be trusted (${e?.message ?? e}). It may hold the id of a live token, so this cleanup cannot claim nothing is owed. Every byte is left as found: inspect ${JOURNAL_PATH}, revoke anything it names (npx @decidio/sdk tokens list --mine), then repair or move it aside and re-run.`);
|
|
1180
|
+
return 1;
|
|
1181
|
+
}
|
|
1182
|
+
for (const j of outstanding) {
|
|
1105
1183
|
if (j.apiUrl && j.apiUrl !== API) {
|
|
1106
1184
|
journalStuck.push(j.jti);
|
|
1107
1185
|
err(` ⚠ journalled token ${j.jti} was minted against ${j.apiUrl}, not ${API} — revoke it there: DECIDIO_API_URL=${j.apiUrl} npx @decidio/sdk tokens revoke ${j.jti}`);
|
|
@@ -1437,7 +1515,12 @@ async function main(argv) {
|
|
|
1437
1515
|
if (argsIn.includes("cleanup")) {
|
|
1438
1516
|
const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
|
|
1439
1517
|
: (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
|
|
1440
|
-
:
|
|
1518
|
+
: (() => { try {
|
|
1519
|
+
return journalOutstanding().length ? ".env.quickstart" : undefined;
|
|
1520
|
+
}
|
|
1521
|
+
catch {
|
|
1522
|
+
return ".env.quickstart";
|
|
1523
|
+
} })();
|
|
1441
1524
|
if (!envFile) {
|
|
1442
1525
|
log("Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal owes nothing.");
|
|
1443
1526
|
return 0;
|
|
@@ -1458,7 +1541,8 @@ async function main(argv) {
|
|
|
1458
1541
|
err("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
|
|
1459
1542
|
return 2;
|
|
1460
1543
|
}
|
|
1461
|
-
const
|
|
1544
|
+
const ddIdx = argsIn.indexOf("--");
|
|
1545
|
+
const nameArg = ddIdx >= 0 ? argsIn[ddIdx + 1] : argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
|
|
1462
1546
|
let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
|
|
1463
1547
|
if (unique)
|
|
1464
1548
|
name = `${name}-${randomBytes(2).toString("hex")}`;
|
package/dist/inngest.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export type GateStepOutcome<R> = {
|
|
|
35
35
|
status: "executed" | "rejected" | "blocked";
|
|
36
36
|
decisionId: string;
|
|
37
37
|
value?: R;
|
|
38
|
+
/** Always present when `status` is "executed" (the step confirms before it returns); optional in the type
|
|
39
|
+
* because the other statuses carry none. */
|
|
38
40
|
confirmation?: EvidenceOutcome;
|
|
39
41
|
};
|
|
40
42
|
/**
|
package/dist/resume.d.ts
CHANGED
|
@@ -450,9 +450,11 @@ export declare class DecidioSuspendedError extends Error {
|
|
|
450
450
|
/** `indeterminate` (0.2.0): the handler was INVOKED and then threw. Whether the downstream
|
|
451
451
|
* effect committed is not knowable from here, so the action is never retried automatically —
|
|
452
452
|
* it waits for reconcile(). Callers that switch exhaustively on this union must handle it. */
|
|
453
|
-
/** `already_resolved` (0.3.5): this resume signal LOST the transition race to an earlier,
|
|
454
|
-
* (a denial,
|
|
455
|
-
*
|
|
453
|
+
/** `already_resolved` (0.3.5): this resume signal LOST the transition race to an earlier, FINAL answer
|
|
454
|
+
* (a denial, an execution that already ran, or a report already recorded) — `reason` names which. It used
|
|
455
|
+
* to read `unknown`, the word for "not ours" (prodsec V033-06). An `invoking` row is NOT final and answers
|
|
456
|
+
* `indeterminate` (prodsec V035-02). Both members are additive: an exhaustive switch over this union
|
|
457
|
+
* gains a case per release, which the changelog announces. */
|
|
456
458
|
export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved";
|
|
457
459
|
/** The states a durable action can actually be in — every one of them nameable, including the
|
|
458
460
|
* one nobody likes. */
|
package/dist/resume.js
CHANGED
|
@@ -1037,7 +1037,12 @@ export function createDecidioResume(opts) {
|
|
|
1037
1037
|
refuseOnce(decisionId, claim.state === "discarded"
|
|
1038
1038
|
? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
|
|
1039
1039
|
: `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`, claim.state === "discarded" ? " (This prints once. The decision is closed — no action is needed.)" : undefined);
|
|
1040
|
-
|
|
1040
|
+
if (claim.state === "invoking" || claim.state === "indeterminate") {
|
|
1041
|
+
return finish({ status: "indeterminate", decisionId, action: pending.action, reason: claim.state === "invoking"
|
|
1042
|
+
? `this decision is in durable state invoking: its action may still be running, may have crashed before any effect, or may have finished without reporting - it may or may not have run. Nothing will retry it; check the worker and the downstream system, then settle it with reconcile("${decisionId}", …).`
|
|
1043
|
+
: claim.reason });
|
|
1044
|
+
}
|
|
1045
|
+
return finish({ status: "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
|
|
1041
1046
|
}
|
|
1042
1047
|
return { status: "unknown", decisionId };
|
|
1043
1048
|
}
|
package/dist/signing.d.ts
CHANGED
|
@@ -78,6 +78,10 @@ export declare function assertJsonSafe(value: unknown, path?: string): void;
|
|
|
78
78
|
* (it is refused as one), so no refusal may print it - every branch of assertSafeTransport echoes through
|
|
79
79
|
* this, not the raw string (invariant-guard, 0.3.5). */
|
|
80
80
|
export declare function redactUrl(u: string): string;
|
|
81
|
+
/** Unicode SPACE and FORMAT characters a URL must never contain: the Zs/Zl/Zp separators (NBSP, ideographic
|
|
82
|
+
* space, …) and the zero-width formats. Spelled as an explicit list in BOTH runtimes so the answers cannot
|
|
83
|
+
* drift with a library's notion of whitespace (Python's urlsplit strips a leading NBSP; WHATWG does not). */
|
|
84
|
+
export declare const URL_UNICODE_SPACE: RegExp;
|
|
81
85
|
export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
|
|
82
86
|
/** The ONE answer to "does this request carry a credential worth protecting?" (A6, 0.1.12).
|
|
83
87
|
* A signed identity proof is a credential too: sending it over remote plaintext disclosed
|
package/dist/signing.js
CHANGED
|
@@ -106,14 +106,17 @@ export function assertJsonSafe(value, path = "args") {
|
|
|
106
106
|
};
|
|
107
107
|
walk(value, path);
|
|
108
108
|
}
|
|
109
|
-
export function redactUrl(u) {
|
|
109
|
+
export function redactUrl(u) {
|
|
110
|
+
return u.replace(/\/\/[^/?#]*@/, "//***@").replace(/^([^/?#@:]*:)(?!\/\/)[\/\\]*[^/?#]*@/, "$1***@");
|
|
111
|
+
}
|
|
112
|
+
export const URL_UNICODE_SPACE = /[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\u200b-\u200d\u2060]/;
|
|
110
113
|
export function assertSafeTransport(url, hasCredential) {
|
|
111
114
|
if (typeof url !== "string") {
|
|
112
115
|
throw new Error(`[decidio] the gate URL is ${url === null ? "null" : typeof url}, not a string, so there is no host to send to. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
113
116
|
}
|
|
114
117
|
for (const ch of url) {
|
|
115
118
|
const cp = ch.codePointAt(0);
|
|
116
|
-
if (cp <= 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp === 0xfeff) {
|
|
119
|
+
if (cp <= 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp === 0xfeff || URL_UNICODE_SPACE.test(ch)) {
|
|
117
120
|
throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} contains whitespace or a control character (U+${cp.toString(16).toUpperCase().padStart(4, "0")}) - a validator and a transport can read such a spelling differently, so it is refused as written. Remove it. Nothing was sent.`);
|
|
118
121
|
}
|
|
119
122
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"dist",
|
|
41
41
|
"openapi.yaml",
|
|
42
42
|
"README.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
43
44
|
"LICENSE"
|
|
44
45
|
],
|
|
45
46
|
"engines": {
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
"prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
|
|
83
84
|
},
|
|
84
85
|
"dependencies": {
|
|
85
|
-
"@decidio/verify": "0.
|
|
86
|
+
"@decidio/verify": "0.4.0"
|
|
86
87
|
},
|
|
87
88
|
"author": "OmniTwin Technologies Inc.",
|
|
88
89
|
"bugs": {
|