@sprigr/apps-app-sdk 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,3 +56,24 @@ export default { list_things: actorTool(async (env, actor, args) => { /* ... */
56
56
  - `fetchFileBytes` / `fetchFileAsBase64` / `bytesToBase64` / `base64ToBytes` — file byte helpers
57
57
 
58
58
  Full platform semantics: [`docs/platform-reference.md`](../../docs/platform-reference.md).
59
+
60
+ ## Write protection
61
+
62
+ Three platform tiers, and they are not substitutes for each other. Full guide: [`docs/write-protection.md`](../../docs/write-protection.md).
63
+
64
+ | Tier | Where | Who attests | Helper |
65
+ |---|---|---|---|
66
+ | T1 confirmation policy | manifest `tools[].confirmation` | the model (`confirm: true`) | `buildConfirmationPolicy` + `checkConfirmationPolicy`, `sprigr-check-write-protection` |
67
+ | T2 approval | handler returns `_approval` | a human tap | `requireApproval`, `approvalHash` / `set` / `seq` |
68
+ | T3 undo | handler returns `_undo`; manifest `undo.reverse_tool` | platform token | `requireApproval` (capture), `undoEnvelope`, `runUndoApply`, `@sprigr/apps-undo-journal` |
69
+
70
+ - `requireApproval(handlers, specs, opts)` wraps the named tools so each returns an `_approval` card instead of writing, then on the granted pass captures a before-image through the **pinned** connection and offers `_undo`. It resolves the connection inside itself, before the label lookup, the hash and the capture: a gate that sits outside the app's own pin names the wrong store, hashes the wrong store and captures the wrong store's copy. Register the result after the originals: `Object.assign(registry, requireApproval(registry, SPECS, opts))`.
71
+ - `approvalHash(rawId, connection, ...parts)`, `set(values)`, `seq(values)`: a stable operation identity so a tap survives the model reordering a list or dropping an optional argument on the retry.
72
+ - `undoEnvelope({...})` throws on a blank field. The platform drops the whole envelope on a blank and mints nothing, silently.
73
+ - `refuseWithoutForce(name, handler, opts)` / `archiveOfferRefusal(...)`: a delete refuses once and offers the vendor's native archive unless `force: true`.
74
+ - `buildConfirmationPolicy({ irreversible, always, rules })` declares a dispatcher's policy beside its action registry; `checkConfirmationPolicy({ policy, registry, ungated, requiredInput })` returns every silent failure (dead rule, `(unset)` placeholder, money threshold, unclassified write) for a one-line test.
75
+ - `runUndoApply(args, { env, journal, specs, pin })` is the body of your `internal: true` reverse tool: load, re-pin to the journalled connection, restore, drop. `pin` may hand `restore` a different type from the env (a per-actor token state), may throw (`connection_unavailable`) or return null to refuse (`connection_mismatch`); a restore that throws is `restore_failed` and the copy is kept.
76
+ - `safeCapture(scope, name, id, read)` and `offerUndo({ journal, entity, id, before, connection, fidelity, resource, describe, warning })`: the capture-then-offer halves for a write that is not behind an approval card (a dispatcher `update_*` action). `requireApproval` uses the same two on its granted pass.
77
+ - `sprigr-check-write-protection` (bin): every destructive-by-name tool or enumerated dispatcher action must carry a policy, or a reason in `apps/<app>/write-protection.json`; ratchets on `tools/write-protection-baseline.json`.
78
+
79
+ **Never offer `_undo` for money or messages.** Refunds, captures, cancellations, sent mail: the approval gate is the only control there.
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sprigr-check-write-protection: every destructive tool declares a policy.
4
+ *
5
+ * The platform cannot tell which of an app's tools are destructive (sprigr-team
6
+ * decisions 0009 and 0011 both say so), so nothing platform-side can enforce
7
+ * that a permanent delete is gated. The app repo can, by name: this scans every
8
+ * `sprigr-app.json` and fails on a tool or enumerated dispatcher action whose
9
+ * name is destructive and that carries no `confirmation` entry.
10
+ *
11
+ * Escape hatches, both explicit and reviewable:
12
+ *
13
+ * apps/<app>/write-protection.json
14
+ * { "allow": { "<tool>": "<reason>", "<tool>:<action>": "<reason>" } }
15
+ * A destructive-by-name entry that is genuinely harmless (a read named
16
+ * `refund_rate`, a fixture reset). Every entry needs a reason. A stale
17
+ * entry fails, so the file cannot silently mask a tool that came back.
18
+ *
19
+ * --baseline <file> (default tools/write-protection-baseline.json if present)
20
+ * The ratchet. Violations listed in the baseline are reported but do not
21
+ * fail; anything new fails; a baseline entry that no longer violates fails
22
+ * too, so headroom is banked with --write-baseline rather than re-spent.
23
+ *
24
+ * A dispatcher tool that does not enumerate its actions (`dispatch.actions`
25
+ * or an `enum` on the action field) cannot be inspected and is itself a
26
+ * violation until enumerated or allowlisted.
27
+ *
28
+ * Dependency-free: Node stdlib only, like the other repo guards.
29
+ *
30
+ * sprigr-check-write-protection [--apps apps] [--warn] [--baseline f] [--write-baseline]
31
+ */
32
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+
35
+ /**
36
+ * A tool or action name that destroys, reverses money, or cannot be walked
37
+ * back. Tested against the bare name and with any leading `<app>_` prefix
38
+ * stripped, so `shopify_delete_product` and `delete_product` both match.
39
+ */
40
+ export const DESTRUCTIVE_ACTION_PATTERN =
41
+ /(^|_)(delete|remove|destroy|cancel|void|refund|unship|rotate|offboard|purge|wipe|drop|terminate|clear|reset|revoke|unregister|force|deactivate|suspend)(_|$)|(^|_)(capture_payment|complete_draft)/;
42
+
43
+ export function isDestructiveName(name) {
44
+ return DESTRUCTIVE_ACTION_PATTERN.test(name);
45
+ }
46
+
47
+ function enumeratedActions(tool) {
48
+ const d = tool.dispatch;
49
+ if (d && d.actions) {
50
+ if (Array.isArray(d.actions)) return d.actions.map((a) => (typeof a === 'string' ? a : a && a.name)).filter(Boolean);
51
+ if (typeof d.actions === 'object') return Object.keys(d.actions);
52
+ }
53
+ const field = (d && d.actionField) || 'action';
54
+ const prop = tool.input_schema && tool.input_schema.properties && tool.input_schema.properties[field];
55
+ if (!prop) return null; // not a dispatcher
56
+ if (Array.isArray(prop.enum)) return prop.enum.map(String);
57
+ for (const k of ['oneOf', 'anyOf']) {
58
+ if (Array.isArray(prop[k])) return prop[k].map((o) => o && o.const).filter(Boolean).map(String);
59
+ }
60
+ return []; // a dispatcher, but nothing enumerated
61
+ }
62
+
63
+ function isGatedRule(rule) {
64
+ return !!rule && typeof rule === 'object' && (rule.always === true || !!rule.when);
65
+ }
66
+
67
+ /**
68
+ * Violations for one manifest, as `tool` or `tool:action` strings.
69
+ * Pure, so it is unit-testable without touching the filesystem.
70
+ */
71
+ export function scanManifest(manifest) {
72
+ const out = [];
73
+ for (const tool of manifest.tools || []) {
74
+ if (!tool || tool.internal) continue;
75
+ const pol = tool.confirmation && typeof tool.confirmation === 'object' ? tool.confirmation : null;
76
+ const toolGated = tool.confirmation_required === true || isGatedRule(pol);
77
+ const actions = enumeratedActions(tool);
78
+ if (actions === null) {
79
+ if (isDestructiveName(tool.name) && !toolGated) out.push(tool.name);
80
+ continue;
81
+ }
82
+ if (toolGated) continue;
83
+ if (actions.length === 0) {
84
+ out.push(`${tool.name}:<unenumerated>`);
85
+ continue;
86
+ }
87
+ const perAction = (pol && pol.actions) || {};
88
+ for (const a of actions) {
89
+ if (isDestructiveName(a) && !isGatedRule(perAction[a])) out.push(`${tool.name}:${a}`);
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** Apply the app's allowlist. Returns { violations, stale, unreasoned }. */
96
+ export function applyAllowlist(violations, allowlist) {
97
+ const allow = (allowlist && allowlist.allow) || {};
98
+ const unreasoned = Object.entries(allow).filter(([, r]) => typeof r !== 'string' || !r.trim()).map(([k]) => k);
99
+ const remaining = violations.filter((v) => !(v in allow));
100
+ const stale = Object.keys(allow).filter((k) => !violations.includes(k));
101
+ return { violations: remaining, stale, unreasoned };
102
+ }
103
+
104
+ function readJson(path) {
105
+ return JSON.parse(readFileSync(path, 'utf8'));
106
+ }
107
+
108
+ export function run(argv, cwd = process.cwd()) {
109
+ const args = [...argv];
110
+ const opt = (flag, dflt) => {
111
+ const i = args.indexOf(flag);
112
+ if (i === -1) return dflt;
113
+ const v = args[i + 1];
114
+ args.splice(i, 2);
115
+ return v;
116
+ };
117
+ const has = (flag) => {
118
+ const i = args.indexOf(flag);
119
+ if (i === -1) return false;
120
+ args.splice(i, 1);
121
+ return true;
122
+ };
123
+ const warnOnly = has('--warn');
124
+ const writeBaseline = has('--write-baseline');
125
+ const appsDir = opt('--apps', 'apps');
126
+ const defaultBaseline = join(cwd, 'tools', 'write-protection-baseline.json');
127
+ const baselinePath = opt('--baseline', existsSync(defaultBaseline) ? defaultBaseline : null);
128
+ const baseline = baselinePath && existsSync(baselinePath) ? readJson(baselinePath) : {};
129
+
130
+ const root = join(cwd, appsDir);
131
+ const appDirs = existsSync(join(cwd, 'sprigr-app.json'))
132
+ ? [{ slug: '.', dir: cwd }]
133
+ : readdirSync(root, { withFileTypes: true })
134
+ .filter((e) => e.isDirectory() && existsSync(join(root, e.name, 'sprigr-app.json')))
135
+ .map((e) => ({ slug: e.name, dir: join(root, e.name) }));
136
+
137
+ const failures = [];
138
+ const report = [];
139
+ const nextBaseline = {};
140
+
141
+ for (const { slug, dir } of appDirs) {
142
+ let manifest;
143
+ try {
144
+ manifest = readJson(join(dir, 'sprigr-app.json'));
145
+ } catch (err) {
146
+ failures.push(`${slug}: sprigr-app.json does not parse (${err.message})`);
147
+ continue;
148
+ }
149
+ const allowPath = join(dir, 'write-protection.json');
150
+ const allowlist = existsSync(allowPath) ? readJson(allowPath) : null;
151
+ const raw = scanManifest(manifest);
152
+ const { violations, stale, unreasoned } = applyAllowlist(raw, allowlist);
153
+ for (const k of unreasoned) failures.push(`${slug}: write-protection.json allow entry "${k}" has no reason`);
154
+ for (const k of stale) failures.push(`${slug}: write-protection.json allow entry "${k}" is stale (no longer destructive-and-ungated); remove it`);
155
+
156
+ const known = new Set(baseline[slug] || []);
157
+ const fresh = violations.filter((v) => !known.has(v));
158
+ const banked = [...known].filter((v) => !violations.includes(v));
159
+ if (violations.length) nextBaseline[slug] = violations.sort();
160
+
161
+ for (const v of fresh) failures.push(`${slug}: ${v} is destructive by name and declares no confirmation policy`);
162
+ for (const v of banked) failures.push(`${slug}: baseline lists "${v}" but it is now gated or gone; run --write-baseline to bank the headroom`);
163
+ report.push(`${slug.padEnd(28)} destructive-ungated=${String(violations.length).padStart(3)} (new ${fresh.length}, baselined ${violations.length - fresh.length})`);
164
+ }
165
+
166
+ if (writeBaseline && baselinePath) {
167
+ writeFileSync(baselinePath, JSON.stringify(nextBaseline, null, 2) + '\n');
168
+ return { code: 0, lines: [...report, `wrote ${baselinePath}`] };
169
+ }
170
+
171
+ const lines = [...report];
172
+ if (failures.length) {
173
+ lines.push('', `${failures.length} write-protection finding(s):`, ...failures.map((f) => ` ${f}`));
174
+ lines.push(
175
+ '',
176
+ 'Fix: add a `confirmation` policy to the tool (or `confirmation.actions[<action>]` on a dispatcher),',
177
+ 'or record a reason in apps/<app>/write-protection.json under "allow". See @sprigr/apps-app-sdk README, "Write protection".',
178
+ );
179
+ return { code: warnOnly ? 0 : 1, lines };
180
+ }
181
+ lines.push(`OK: ${appDirs.length} app manifest(s), no unguarded destructive tools outside the baseline.`);
182
+ return { code: 0, lines };
183
+ }
184
+
185
+ const invokedDirectly = process.argv[1] && /check-write-protection\.mjs$/.test(process.argv[1]);
186
+ if (invokedDirectly) {
187
+ const { code, lines } = run(process.argv.slice(2));
188
+ for (const l of lines) (code ? console.error : console.log)(l);
189
+ process.exit(code);
190
+ }
package/dist/index.d.ts CHANGED
@@ -825,6 +825,540 @@ declare function deleteAppFile(env: AppFilesEnv, key: string): Promise<{
825
825
  key: string;
826
826
  }>;
827
827
 
828
+ /**
829
+ * Write-protection types, mirrored from the Sprigr platform.
830
+ *
831
+ * Three tiers exist on the platform and they are not substitutes for each
832
+ * other (sprigr-team decisions 0009, 0011, 0012):
833
+ *
834
+ * T1 `confirmation` policy in the manifest. The platform enforces it BEFORE
835
+ * your handler is dispatched, but the MODEL sets `confirm: true`, so it
836
+ * is a reviewable declaration and an audit trail, not a control.
837
+ * T2 `_approval` envelope returned by the handler. The platform pauses the
838
+ * turn on a decision card; only a real human tap mints the grant. This is
839
+ * the only tier that actually stops a write.
840
+ * T3 `_undo` envelope returned beside a successful write. The platform mints
841
+ * the token and surfaces `undo_change`; the app keeps the before-image.
842
+ *
843
+ * Canonical declarations live in sprigr-team:
844
+ * - `AppToolConfirmRule`, `AppToolConfirmationPolicy`, `AppUndoEnvelope`:
845
+ * packages/shared/src/types/marketplace.ts
846
+ * - `AppApprovalEnvelope`: packages/agent-core/src/marketplace-tool-builder.ts
847
+ * Keep the field sets in sync when the platform changes them. Mirrored (not
848
+ * imported) for the same reason `MarketplaceEventSourceIntegration` is: the
849
+ * platform packages are private and this SDK is public.
850
+ */
851
+ /** One confirmation rule. `always`, or `when` a counted input crosses a threshold. */
852
+ interface ConfirmRule {
853
+ /** Gate every call. Use for permanent deletes, refunds, anything public-facing. */
854
+ always?: boolean;
855
+ /**
856
+ * Gate only when a batch crosses a threshold. `count` names an input key
857
+ * (dotted paths work; on a `{ action, input }` dispatcher it MUST be dotted
858
+ * through `input.`); an array contributes its length, a number its own
859
+ * value, anything else counts as nothing and never trips. Never threshold a
860
+ * money field: a numeric STRING amount is silently no gate at all.
861
+ */
862
+ when?: ConfirmCondition | ConfirmCondition[];
863
+ /**
864
+ * What the person is asked to approve. `{key}` interpolates from the
865
+ * top-level input. Do NOT end it in a period: the platform renders it as
866
+ * `Action: <text>.`.
867
+ */
868
+ describe?: string;
869
+ /** Appends "which cannot be undone" so the model cannot undersell it. */
870
+ irreversible?: boolean;
871
+ }
872
+ interface ConfirmCondition {
873
+ count: string;
874
+ atLeast: number;
875
+ }
876
+ /** Tool-level policy, plus per-action rules for a dispatcher tool. */
877
+ interface ConfirmationPolicy extends ConfirmRule {
878
+ /**
879
+ * Per-action rules, keyed by action name (read from `dispatch.actionField`,
880
+ * default `action`). A listed action's rule wins; an unlisted action falls
881
+ * back to the tool-level rule, which is normally absent.
882
+ */
883
+ actions?: Record<string, ConfirmRule>;
884
+ }
885
+ /**
886
+ * Returned INSTEAD of doing the work when a write needs a human. The platform
887
+ * replaces your whole result with the decision card on this pass, so nothing
888
+ * beside `_approval` reaches anyone; put everything the person needs to read
889
+ * in `question`, `header` and `summary`.
890
+ */
891
+ interface AppApprovalEnvelope {
892
+ /** The question on the card, e.g. "Delete 12 products from acme.myshopify.com?" */
893
+ question: string;
894
+ /** Short header above the card, e.g. "Shopify". */
895
+ header: string;
896
+ /** Optional detail under the approve option; the card is the whole preview. */
897
+ summary?: string;
898
+ /**
899
+ * Stable identity of the exact operation. SUPPLY THIS for any tool with
900
+ * optional arguments: the platform's default is derived from whatever the
901
+ * model happened to send, and a hash that moves between the ask and the
902
+ * retry silently discards the tap. See `approvalHash`.
903
+ */
904
+ hash?: string;
905
+ }
906
+ /**
907
+ * Platform-stamped marker telling an app that a human approved this call. The
908
+ * platform DELETES any caller-supplied copy before dispatch and sets it only
909
+ * after consuming a real grant, so a handler may trust it.
910
+ */
911
+ declare const APPROVAL_GRANTED_KEY = "_approval_granted";
912
+ type UndoFidelity = 'full' | 'recreated';
913
+ /**
914
+ * Returned BESIDE the real result after a successful write. Every field is
915
+ * required and non-empty: the platform drops the whole envelope on a blank
916
+ * field and mints nothing, silently. Build it with `undoEnvelope()`, which
917
+ * refuses a blank at construction time.
918
+ */
919
+ interface AppUndoEnvelope {
920
+ /** `'full'`: same id restored in place. `'recreated'`: a NEW object under a NEW id. */
921
+ fidelity: UndoFidelity;
922
+ /** Relayed to the model verbatim. On `'recreated'` it must say the id changes. */
923
+ warning: string;
924
+ /** Human-readable subject for the list tool, e.g. `collection "Summer Sale" on acme.myshopify.com`. */
925
+ describes: string;
926
+ /** Coarse type, for grouping: "product", "contact". */
927
+ resource: string;
928
+ /** The app's own handle for the before-image (`journal.captureBefore().ref`). */
929
+ ref: string;
930
+ }
931
+ /** Args-first handler signature the marketplace wrapper dispatches with. */
932
+ type ToolArgs = Record<string, unknown>;
933
+ type ToolHandler<E = unknown> = (args: ToolArgs, env: E, ctx?: unknown) => Promise<unknown>;
934
+
935
+ /**
936
+ * The stable identity of a gated operation, for the approval grant.
937
+ *
938
+ * A grant is bound to a hash so a tap on a small write cannot be spent on a big
939
+ * one. When an app supplies no hash the platform derives one from the arguments
940
+ * the MODEL sent, and the model does not send the same thing twice: an optional
941
+ * param included on the asking call and dropped on the retry, a `store` learned
942
+ * between the two, a tag list in a different order, each mints a different hash.
943
+ * When the hash moves, the tap is silently discarded and the person is asked
944
+ * the identical question again with nothing saying their answer was thrown
945
+ * away.
946
+ *
947
+ * Only the app knows which arguments identify the operation, so only the app
948
+ * can make the identity stable. Put in exactly the things that decide what
949
+ * changes: the resource, the connection, and the payload. NOT `confirm` or
950
+ * `_approval_granted` (attestation, not operation) and not free text the model
951
+ * composes.
952
+ *
953
+ * Extracted unchanged from the Shopify app (sprigr-apps #1197).
954
+ */
955
+ /**
956
+ * Field separator. A control character, so it cannot occur in a tag, an id or
957
+ * a store domain, which is what stops one combination of values being
958
+ * re-partitioned into another ("a" + "bc" must not collide with "ab" + "c").
959
+ */
960
+ declare const UNIT_SEP = "\u001F";
961
+ /** A set, order-insensitive: `set(["a","b"])` === `set(["b","a"])`. */
962
+ declare function set(values: unknown): string;
963
+ /** An ordered list, for payloads where position changes the outcome. */
964
+ declare function seq(values: unknown): string;
965
+ /**
966
+ * Join the parts into the `_approval.hash` string. The platform mixes the tool
967
+ * name in before hashing, so this never needs to repeat it.
968
+ */
969
+ declare function approvalHash(...parts: Array<string | number | undefined | null>): string;
970
+
971
+ /**
972
+ * Thrown by `undoEnvelope` for a field the platform would reject.
973
+ *
974
+ * The platform's `extractAppUndo` requires `fidelity` in {full, recreated} and
975
+ * every string non-empty after trim; on any miss it logs a warning in the
976
+ * PLATFORM worker and mints no token. The app sees nothing wrong: the write
977
+ * succeeds and the journal row is there. The only symptom is that the model
978
+ * never gets an undo affordance, which reads as a discoverability problem and
979
+ * sends you into the tool registry instead of your own envelope. Xero 0.1.1
980
+ * shipped `warning: ''` this way. Failing here, in a unit test, is cheaper.
981
+ */
982
+ declare class InvalidUndoEnvelopeError extends Error {
983
+ constructor(message: string);
984
+ }
985
+ /** Build an `_undo` envelope, refusing anything the platform would drop. */
986
+ declare function undoEnvelope(input: {
987
+ fidelity: UndoFidelity;
988
+ warning: string;
989
+ describes: string;
990
+ resource: string;
991
+ ref: string;
992
+ }): AppUndoEnvelope;
993
+ /**
994
+ * The honest warning for a `'recreated'` reversal. Always names the new-id
995
+ * consequence, then whatever the rebuild cannot bring back.
996
+ */
997
+ declare function recreatedWarning(resource: string, notRestored?: string): string;
998
+ /**
999
+ * The honest warning for a `'full'` reversal. Even a perfect revert overwrites
1000
+ * anything changed since the capture, so this is never empty.
1001
+ */
1002
+ declare function fullWarning(notRestored?: string): string;
1003
+
1004
+ /**
1005
+ * Archive-first: a delete refuses ONCE and offers the reversible option.
1006
+ *
1007
+ * Where the vendor has a real archived state, a native archive beats any
1008
+ * journal: same id, real restore, no fidelity caveat. So a `delete_*` on such
1009
+ * an entity does not delete unless the caller passes `force: true`; it returns
1010
+ * a refusal pointing at the archive tool. Automation that genuinely means to
1011
+ * delete adds `force: true` once; a person who said "get rid of it" is offered
1012
+ * the thing they almost certainly meant.
1013
+ *
1014
+ * The refusal shape is shared by simPRO (five deletes) and Shopify
1015
+ * (`shopify_delete_product`) so an agent that has learned one has learned both:
1016
+ * "I did NOT write; here is the reversible option; pass force: true if you
1017
+ * really mean it".
1018
+ */
1019
+ interface ArchiveOfferOptions {
1020
+ /** Human noun: "product", "customer". */
1021
+ resource: string;
1022
+ /** Tool or action name the caller should reach for instead. */
1023
+ archiveAction: string;
1024
+ /** Arg names that may carry the target id, most specific first. */
1025
+ idKeys?: string[];
1026
+ /** Deep link to the record, when a verified single-record URL shape exists. */
1027
+ link?: (args: ToolArgs, id: string) => string | undefined;
1028
+ }
1029
+ interface ArchiveOfferRefusal {
1030
+ ok: false;
1031
+ deleted: false;
1032
+ archive_available: true;
1033
+ resource: string;
1034
+ record_id: string | null;
1035
+ delete_action: string;
1036
+ archive_action: string;
1037
+ link?: string;
1038
+ note: string;
1039
+ }
1040
+ /** The refusal payload, for dispatcher apps that wrap at the action layer. */
1041
+ declare function archiveOfferRefusal(deleteAction: string, args: ToolArgs, opts: ArchiveOfferOptions): ArchiveOfferRefusal;
1042
+ /**
1043
+ * Wrap a flat delete handler so an un-forced call returns the offer instead.
1044
+ * `force` is consumed here and never forwarded to the provider.
1045
+ */
1046
+ declare function refuseWithoutForce<E>(deleteAction: string, inner: ToolHandler<E>, opts: ArchiveOfferOptions): ToolHandler<E>;
1047
+
1048
+ /**
1049
+ * Human approval for the writes a vendor cannot undo, plus the before-image
1050
+ * capture for the ones it can partly undo, in ONE wrapper.
1051
+ *
1052
+ * ## Why this exists when the manifest already declares `confirmation`
1053
+ *
1054
+ * The manifest tier gates on `confirm: true`, which the MODEL sets. Measured on
1055
+ * staging 2026-08-19: an agent wrote `confirm: true` into its own code_mode
1056
+ * source before any prompt, because the user's request ("add these five tags")
1057
+ * reads as the confirmation for that action. That tier is an audit trail; it is
1058
+ * not a control. `_approval` is the tier a model cannot satisfy alone: the
1059
+ * platform pauses the turn on a decision card, and only a real tap makes the
1060
+ * DecisionRouter write the grant that lets the retry through.
1061
+ *
1062
+ * ## The two rules baked in, both learned live
1063
+ *
1064
+ * 1. THE CONNECTION IS RESOLVED AND PINNED INSIDE THIS WRAPPER, before the card
1065
+ * label lookup, the hash, and the capture. Apps establish their store /
1066
+ * organisation / business pin inside their own inner wrapper; a gate that
1067
+ * sits outside it sees an UNPINNED env, resolves the install's default
1068
+ * connection, and (a) names the wrong store on the card, (b) hashes the
1069
+ * wrong store so the retry misses the grant, and (c) captures the wrong
1070
+ * store's copy so a permanent delete succeeds and mints no token. Shopify
1071
+ * shipped all three from one cause (sprigr-team decision 0011 addendum).
1072
+ *
1073
+ * 2. CAPTURE BEFORE THE WRITE, MINT ONLY AFTER IT SUCCEEDS, AND NEVER RETURN
1074
+ * `_undo` ON A NULL CAPTURE. A failed capture does not fail the write: the
1075
+ * user asked for it and a human approved it. It degrades to no token.
1076
+ *
1077
+ * Generalised from the Shopify app's `require-approval.ts`; the per-tool
1078
+ * questions stay in the app, because only the app knows what cannot be walked
1079
+ * back.
1080
+ */
1081
+ /** Per-tool spec: how the card reads, and (optionally) how to journal a before-image. */
1082
+ interface ApprovalSpec<E = unknown> {
1083
+ /**
1084
+ * Arg names that may carry the target id, most specific first. ONE list, used
1085
+ * for both the card label and the grant hash, so the two cannot drift apart
1086
+ * and describe different resources.
1087
+ */
1088
+ keys: string[];
1089
+ /**
1090
+ * The card. `target` is the already-resolved human label (or the raw id),
1091
+ * `connection` the resolved store / organisation / business, or '' when the
1092
+ * app has one. Do not set `hash` here; the wrapper derives it from the raw
1093
+ * id and the connection so it cannot move between the ask and the retry.
1094
+ * Override with `hash` below only when the arguments understate the blast
1095
+ * radius.
1096
+ */
1097
+ describe: (target: string, args: ToolArgs, connection: string) => Omit<AppApprovalEnvelope, 'hash'>;
1098
+ /**
1099
+ * Extra identity parts beyond `rawId + connection`, e.g. a sorted tag set.
1100
+ * Use `set()` / `seq()` from `approval-hash`. Never include `confirm`.
1101
+ */
1102
+ hash?: (args: ToolArgs) => Array<string | number | undefined | null>;
1103
+ /** Present only for tools whose write can be (partly) reversed. */
1104
+ undo?: UndoCaptureSpec<E>;
1105
+ }
1106
+ interface UndoCaptureSpec<E = unknown> {
1107
+ /** Coarse type surfaced as `_undo.resource`. */
1108
+ resource: string;
1109
+ fidelity: UndoFidelity;
1110
+ /** Read the object BEFORE the write, through the PINNED env. Null or throw on miss. */
1111
+ capture: (pinnedEnv: E, id: string, args: ToolArgs) => Promise<unknown>;
1112
+ /** How the change reads in `list_undoable_changes`; the connection is appended by the wrapper. */
1113
+ describe: (before: Record<string, unknown>, id: string) => string;
1114
+ /** Relayed verbatim; on `'recreated'` it must say the id changes. Never empty. */
1115
+ warning: (id: string, before: Record<string, unknown>) => string;
1116
+ }
1117
+ /** The journal surface this wrapper needs; `createUndoJournal()` satisfies it. */
1118
+ interface CaptureJournal {
1119
+ captureBefore(args: {
1120
+ entity: string;
1121
+ originalId: string;
1122
+ before: unknown;
1123
+ connection: string | null;
1124
+ }): Promise<{
1125
+ ref: string;
1126
+ } | null>;
1127
+ }
1128
+ interface RequireApprovalOptions<E = unknown> {
1129
+ /** Log prefix, e.g. `shopify-undo`. */
1130
+ scope: string;
1131
+ /**
1132
+ * Resolve the connection this call will ACTUALLY hit, from the caller's
1133
+ * arguments and the install, canonicalised (a label like "EU store" becomes
1134
+ * the real domain). Return '' for a single-connection app. Never read it off
1135
+ * the arguments alone: on the retry the model usually omits it.
1136
+ */
1137
+ resolveConnection: (env: E, args: ToolArgs) => Promise<string>;
1138
+ /** Re-pin the env to a resolved connection so lookups and captures hit it. */
1139
+ pinEnv: (env: E, connection: string) => E;
1140
+ /**
1141
+ * Human label for a raw id, looked up THROUGH the pinned env. Fall back to
1142
+ * the id on any miss; never guess, a confidently wrong card is worse than a
1143
+ * terse one. Omit to always show the raw id.
1144
+ */
1145
+ describeTarget?: (pinnedEnv: E, id: string) => Promise<string>;
1146
+ /**
1147
+ * Where before-images go. Required when any spec declares `undo`. The
1148
+ * journal's own capture returns null rather than throwing, and this wrapper
1149
+ * treats null as "no undo offered".
1150
+ */
1151
+ journal?: (env: E) => CaptureJournal;
1152
+ /**
1153
+ * Stamp the resolved connection onto a successful result (e.g. `{ store }`)
1154
+ * so an agent reporting "deleted it" can name where. Optional.
1155
+ */
1156
+ stampConnection?: (result: unknown, connection: string) => unknown;
1157
+ }
1158
+ /**
1159
+ * Wrap the named tools so each asks a human first, then (if it declares
1160
+ * `undo`) journals a before-image on the granted pass and offers `_undo`.
1161
+ *
1162
+ * Returns only the wrapped entries. Register them AFTER the originals
1163
+ * (`Object.assign(registry, requireApproval(registry, specs, opts))`) so
1164
+ * last-write-wins puts the gated version in front.
1165
+ *
1166
+ * A spec naming a tool absent from `handlers` throws rather than skipping: a
1167
+ * rename that quietly ungated a permanent delete is exactly the failure this
1168
+ * guard exists to prevent, and it would look identical to working.
1169
+ */
1170
+ declare function requireApproval<E>(handlers: Record<string, ToolHandler<E>>, specs: Record<string, ApprovalSpec<E>>, opts: RequireApprovalOptions<E>): Record<string, ToolHandler<E>>;
1171
+
1172
+ /**
1173
+ * The reversal entry point every adopting app declares as `undo.reverse_tool`.
1174
+ *
1175
+ * Declared `internal: true` in the manifest, so the platform can dispatch it
1176
+ * but no agent ever sees it: an agent that can see a reversal entry point can
1177
+ * call it with an invented handle, and an agent inventing a plausible undo
1178
+ * identifier is a thing that happened on the simPRO shakedown. The platform
1179
+ * resolves its own token first and only then dispatches here with
1180
+ * `{ ref, undo_token }`.
1181
+ *
1182
+ * By the time this runs the platform has checked that the token exists,
1183
+ * belongs to this actor and company, has not expired, and has been claimed
1184
+ * exactly once. This runner re-checks none of that, because it cannot. It does
1185
+ * one thing: load the copy, re-pin the connection the original write hit,
1186
+ * hand both to the app's restore, and report honestly.
1187
+ *
1188
+ * THE RE-PIN IS THE WHOLE GAME. The platform dispatches with no memory of
1189
+ * which store / organisation / business the original write targeted. Without
1190
+ * `pin`, a replay lands on the install's CURRENT default and rebuilds the
1191
+ * object in the wrong customer account, and nothing errors.
1192
+ *
1193
+ * `E` is the env the tool receives; `P` is what `pin` hands to `restore`. For
1194
+ * a store-pinned env they are the same type (Shopify). For an app that
1195
+ * re-authenticates per actor and organisation, `P` is the fresh token state
1196
+ * (Xero), which is why they are separate parameters.
1197
+ *
1198
+ * Extracted from the Shopify and Xero `undo-apply.ts` handlers, which were
1199
+ * byte-similar apart from their restore call and their pin.
1200
+ */
1201
+
1202
+ interface RestoreSpec<P, T = Record<string, unknown>> {
1203
+ resource: string;
1204
+ fidelity: UndoFidelity;
1205
+ /**
1206
+ * Rebuild or write back, through the PINNED connection. May return
1207
+ * `{ ok: false, error }` or throw; both become `restore_failed` and the
1208
+ * before-image is kept.
1209
+ */
1210
+ restore: (pinned: P, before: T, row: JournalRowLike<T>) => Promise<{
1211
+ ok: boolean;
1212
+ newId?: string;
1213
+ error?: string;
1214
+ } | void>;
1215
+ /** What the restore does NOT bring back, folded into the note verbatim. */
1216
+ notRestored?: string;
1217
+ }
1218
+ interface JournalRowLike<T = unknown> {
1219
+ entity: string;
1220
+ original_id: string;
1221
+ connection: string | null;
1222
+ before: T;
1223
+ }
1224
+ interface UndoApplyOptions<E, P = E> {
1225
+ env: E;
1226
+ journal: {
1227
+ loadBefore<T = unknown>(ref: string): Promise<JournalRowLike<T> | null>;
1228
+ dropBefore(ref: string): Promise<void>;
1229
+ };
1230
+ specs: Record<string, RestoreSpec<P>>;
1231
+ /**
1232
+ * Re-pin to the journalled connection. May be async (re-auth per actor, as
1233
+ * Xero does). Throw to report `connection_unavailable`; return null to
1234
+ * REFUSE with `connection_mismatch`, e.g. a session-stateful app whose
1235
+ * current business differs from the row's.
1236
+ */
1237
+ pin: (env: E, connection: string | null, args: ToolArgs) => Promise<P | null> | P | null;
1238
+ }
1239
+ type UndoApplyResult = {
1240
+ ok: true;
1241
+ resource: string;
1242
+ original_id: string;
1243
+ connection: string | null;
1244
+ new_id: string | null;
1245
+ fidelity: UndoFidelity;
1246
+ not_restored?: string;
1247
+ note: string;
1248
+ } | {
1249
+ ok: false;
1250
+ error: string;
1251
+ note: string;
1252
+ };
1253
+ /** Run the reversal. Every failure names what it was and states that nothing changed. */
1254
+ declare function runUndoApply<E, P = E>(args: ToolArgs, opts: UndoApplyOptions<E, P>): Promise<UndoApplyResult>;
1255
+
1256
+ /**
1257
+ * Declare a dispatcher tool's confirmation policy beside its action registry,
1258
+ * emit it into the manifest with a `gen:` script, and let a test prove that
1259
+ * every write action is either gated or knowingly exempt.
1260
+ *
1261
+ * This is the Xero app's `confirmation-policy.ts` + its test, generalised. The
1262
+ * two things that fail SILENTLY are what the checker exists for: a rule keyed
1263
+ * on an action that does not exist (dead rule, no gate, no error) and a
1264
+ * placeholder path that renders "(unset)" in the prompt a human is asked to
1265
+ * approve.
1266
+ */
1267
+ interface PolicySource {
1268
+ /** Money has left, a customer has been mailed, or the vendor offers no way back. */
1269
+ irreversible?: Record<string, string>;
1270
+ /** Gated on every call, reversible but real. */
1271
+ always?: Record<string, string>;
1272
+ /** Full rules, for thresholds and anything the two maps above cannot express. */
1273
+ rules?: Record<string, ConfirmRule>;
1274
+ }
1275
+ /** Build a sorted `{ actions }` policy. An action named in two groups throws. */
1276
+ declare function buildConfirmationPolicy(src: PolicySource): ConfirmationPolicy;
1277
+ /**
1278
+ * Action-name prefixes that mean "this writes". Used only for coverage: every
1279
+ * registry action with one of these prefixes must be gated or listed as
1280
+ * deliberately ungated. Reads (`get_`, `list_`, `search_`) are not here.
1281
+ */
1282
+ declare const DEFAULT_WRITE_PREFIXES: readonly string[];
1283
+ /** Fields a threshold must never be placed on: a numeric string never trips. */
1284
+ declare const DEFAULT_MONEY_FIELDS: readonly string[];
1285
+ interface PolicyCheckInput {
1286
+ policy: ConfirmationPolicy;
1287
+ /** Every dispatchable action name (the registry KEYS). */
1288
+ registry: Iterable<string>;
1289
+ /** Write actions deliberately left ungated, each a conscious choice. */
1290
+ ungated?: readonly string[];
1291
+ writePrefixes?: readonly string[];
1292
+ /**
1293
+ * The input fields an action REQUIRES. A `describe` may interpolate only
1294
+ * those (or a field named by its own `when.count`): an optional field is
1295
+ * absent on some calls by definition and renders as "(unset)". Omit to skip
1296
+ * this check.
1297
+ */
1298
+ requiredInput?: (action: string) => readonly string[] | undefined;
1299
+ /**
1300
+ * True (default) when the tool nests params under `input`: every placeholder
1301
+ * and count path must then be dotted through `input.`, because the platform
1302
+ * evaluates against the TOP-LEVEL args it reads `action` from.
1303
+ */
1304
+ nestedUnderInput?: boolean;
1305
+ moneyFields?: readonly string[];
1306
+ }
1307
+ /**
1308
+ * Every way a policy can be wrong without erroring, as a list of findings.
1309
+ * An app's test is `expect(checkConfirmationPolicy(...)).toEqual([])`.
1310
+ */
1311
+ declare function checkConfirmationPolicy(input: PolicyCheckInput): string[];
1312
+
1313
+ /**
1314
+ * The two halves of offering an undo, for handlers that are not behind an
1315
+ * approval card (a dispatcher's `update_*` actions, for instance). The order
1316
+ * is the whole contract:
1317
+ *
1318
+ * const before = await safeCapture(scope, action, id, () => read(pinned, id));
1319
+ * const result = await write(pinned, input); // happens regardless
1320
+ * const _undo = await offerUndo({ journal, entity: action, id, before, connection, ... });
1321
+ * return { ok: true, result, ...(_undo ? { _undo } : {}) };
1322
+ *
1323
+ * `requireApproval` uses the same two functions on its granted pass.
1324
+ */
1325
+ /**
1326
+ * Read the before-image, degrading to null on any failure. A capture that
1327
+ * throws (404, scope denied, network) is not a reason to refuse a write the
1328
+ * user asked for; it is a reason to offer no undo, and to say so in the log.
1329
+ */
1330
+ declare function safeCapture(scope: string, name: string, id: string, capture: () => Promise<unknown>): Promise<Record<string, unknown> | null>;
1331
+ interface OfferUndoArgs {
1332
+ journal: CaptureJournal;
1333
+ /** The tool or action that captured; the reverse tool looks the spec up by it. */
1334
+ entity: string;
1335
+ id: string;
1336
+ /** From `safeCapture`. Null means no undo is offered, deliberately. */
1337
+ before: Record<string, unknown> | null;
1338
+ /** The resolved store / organisation / business, or null for a single-connection app. */
1339
+ connection: string | null;
1340
+ fidelity: UndoFidelity;
1341
+ resource: string;
1342
+ /** How the change reads in `list_undoable_changes`; the connection is appended when present. */
1343
+ describe: (before: Record<string, unknown>, id: string) => string;
1344
+ /** Relayed verbatim; never empty. */
1345
+ warning: string;
1346
+ /** Word between the description and the connection: "on" for a store, "in" for an organisation. */
1347
+ connectionPreposition?: 'on' | 'in';
1348
+ /**
1349
+ * What `describes` calls the connection, when the journalled id is not
1350
+ * readable by a person (a Xero tenantId). Defaults to `connection`. The
1351
+ * journal row always stores `connection` itself, because that is the pin.
1352
+ */
1353
+ connectionLabel?: string;
1354
+ }
1355
+ /**
1356
+ * Store the before-image and build the `_undo` envelope, or return undefined
1357
+ * when there is nothing to offer. Never returns an envelope for a null capture
1358
+ * or a journal that could not store the copy.
1359
+ */
1360
+ declare function offerUndo(args: OfferUndoArgs): Promise<AppUndoEnvelope | undefined>;
1361
+
828
1362
  /**
829
1363
  * @sprigr/apps-app-sdk
830
1364
  *
@@ -1225,4 +1759,4 @@ interface ExecutionContextLike {
1225
1759
  }
1226
1760
  type HandlerFn<TArgs, TResult> = (args: TArgs, env: Record<string, unknown>, ctx?: ExecutionContextLike) => Promise<TResult>;
1227
1761
 
1228
- export { type Actor, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type D1Like, type D1PreparedStatementLike, DEFAULT_EMIT_TIMEOUT_MS, DEFAULT_MAX_FILE_BYTES, type EmitMarketplaceEventOptions, type EmitResult, type EmitTransport, type EventArgs, type ExecutionContextLike, type FetchFileResult, type FetchWithRetryOptions, type GetAppFileResult, type HandlerFn, type InboxOwner, type InstallBridge, type MarketplaceEventEmitOpts, type MarketplaceEventSourceIntegration, NotConnectedError, type OAuthBouncerSignals, type OAuthState, type PlatformHostEnv, type PutAppFileArgs, type PutAppFileResult, type PutAppFileStreamArgs, type ScheduleArgs, type SprigrDataAggregationCell, type SprigrDataAggregationResult, type SprigrDataAggregationSpec, type SprigrDataApi, type SprigrDataIndexOpts, type SprigrDataSearchOpts, type SprigrDataSearchResult, type SprigrFilesApi, type SprigrFilesApiWithEdit, type SprigrFilesCreateInput, type SprigrFilesCreateResult, type SprigrFilesEditInput, type SprigrFilesEditResult, type SprigrFilesJobResult, type ToolResult, type ToolWrapperOptions, type WebhookArgs, type WfpBridgeEnv, type WfpEmitReply, actorKey, actorTool, appFileUrl, base64ToBytes, buildMarketplaceWebhookUrl, bytesToBase64, canEmit, constantTimeEqual, createMarketplaceEmitter, createToolWrappers, decodeState, deleteAppFile, describeMissingBridge, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, getAppFile, hmacSha256Hex, installTokenPost, isNotConnectedError, isStagingHost, listAppFiles, overlaySprigr, ownerFromActorKey, parseActor, putAppFile, putAppFileStream, randomHex, resolveInstallBridge, resolveOAuthBouncerBase, resolveOAuthBouncerCallbackUrl, resolvePlatformWebhookBase, tool, withSprigrEmitFallback };
1762
+ export { APPROVAL_GRANTED_KEY, type Actor, type AppApprovalEnvelope, type AppFileListItem, type AppFileUrlArgs, type AppFileUrlResult, type AppFilesEnv, type AppUndoEnvelope, type ApprovalSpec, type ArchiveOfferOptions, type ArchiveOfferRefusal, type CaptureJournal, type ConfirmCondition, type ConfirmRule, type ConfirmationPolicy, type D1Like, type D1PreparedStatementLike, DEFAULT_EMIT_TIMEOUT_MS, DEFAULT_MAX_FILE_BYTES, DEFAULT_MONEY_FIELDS, DEFAULT_WRITE_PREFIXES, type EmitMarketplaceEventOptions, type EmitResult, type EmitTransport, type EventArgs, type ExecutionContextLike, type FetchFileResult, type FetchWithRetryOptions, type GetAppFileResult, type HandlerFn, type InboxOwner, type InstallBridge, InvalidUndoEnvelopeError, type JournalRowLike, type MarketplaceEventEmitOpts, type MarketplaceEventSourceIntegration, NotConnectedError, type OAuthBouncerSignals, type OAuthState, type OfferUndoArgs, type PlatformHostEnv, type PolicyCheckInput, type PolicySource, type PutAppFileArgs, type PutAppFileResult, type PutAppFileStreamArgs, type RequireApprovalOptions, type RestoreSpec, type ScheduleArgs, type SprigrDataAggregationCell, type SprigrDataAggregationResult, type SprigrDataAggregationSpec, type SprigrDataApi, type SprigrDataIndexOpts, type SprigrDataSearchOpts, type SprigrDataSearchResult, type SprigrFilesApi, type SprigrFilesApiWithEdit, type SprigrFilesCreateInput, type SprigrFilesCreateResult, type SprigrFilesEditInput, type SprigrFilesEditResult, type SprigrFilesJobResult, type ToolArgs, type ToolHandler, type ToolResult, type ToolWrapperOptions, UNIT_SEP, type UndoApplyOptions, type UndoApplyResult, type UndoCaptureSpec, type UndoFidelity, type WebhookArgs, type WfpBridgeEnv, type WfpEmitReply, actorKey, actorTool, appFileUrl, approvalHash, archiveOfferRefusal, base64ToBytes, buildConfirmationPolicy, buildMarketplaceWebhookUrl, bytesToBase64, canEmit, checkConfirmationPolicy, constantTimeEqual, createMarketplaceEmitter, createToolWrappers, decodeState, deleteAppFile, describeMissingBridge, emitMarketplaceEvent, encodeState, fetchFileAsBase64, fetchFileBytes, fetchWithRetry, fullWarning, getAppFile, hmacSha256Hex, installTokenPost, isNotConnectedError, isStagingHost, listAppFiles, offerUndo, overlaySprigr, ownerFromActorKey, parseActor, putAppFile, putAppFileStream, randomHex, recreatedWarning, refuseWithoutForce, requireApproval, resolveInstallBridge, resolveOAuthBouncerBase, resolveOAuthBouncerCallbackUrl, resolvePlatformWebhookBase, runUndoApply, safeCapture, seq, set, tool, undoEnvelope, withSprigrEmitFallback };
package/dist/index.js CHANGED
@@ -485,17 +485,450 @@ function listAppFiles(env, prefix) {
485
485
  function deleteAppFile(env, key) {
486
486
  return call(env, "delete", { key });
487
487
  }
488
+
489
+ // src/write-protection/types.ts
490
+ var APPROVAL_GRANTED_KEY = "_approval_granted";
491
+
492
+ // src/write-protection/approval-hash.ts
493
+ var UNIT_SEP = "";
494
+ function set(values) {
495
+ if (!Array.isArray(values)) return "";
496
+ return [...values].map((v) => String(v)).sort().join(UNIT_SEP);
497
+ }
498
+ function seq(values) {
499
+ if (!Array.isArray(values)) return "";
500
+ return values.map((v) => String(v)).join(UNIT_SEP);
501
+ }
502
+ function approvalHash(...parts) {
503
+ return parts.map((p) => p === void 0 || p === null ? "" : String(p)).join(UNIT_SEP);
504
+ }
505
+
506
+ // src/write-protection/undo-envelope.ts
507
+ var InvalidUndoEnvelopeError = class extends Error {
508
+ constructor(message) {
509
+ super(message);
510
+ this.name = "InvalidUndoEnvelopeError";
511
+ }
512
+ };
513
+ function undoEnvelope(input) {
514
+ if (input.fidelity !== "full" && input.fidelity !== "recreated") {
515
+ throw new InvalidUndoEnvelopeError(
516
+ `_undo.fidelity must be 'full' or 'recreated', got ${JSON.stringify(input.fidelity)}`
517
+ );
518
+ }
519
+ for (const key of ["warning", "describes", "resource", "ref"]) {
520
+ const v = input[key];
521
+ if (typeof v !== "string" || v.trim() === "") {
522
+ throw new InvalidUndoEnvelopeError(
523
+ `_undo.${key} must be a non-empty string; the platform drops the whole envelope and mints no token on a blank field`
524
+ );
525
+ }
526
+ }
527
+ return {
528
+ fidelity: input.fidelity,
529
+ warning: input.warning.trim(),
530
+ describes: input.describes.trim(),
531
+ resource: input.resource.trim(),
532
+ ref: input.ref
533
+ };
534
+ }
535
+ function recreatedWarning(resource, notRestored) {
536
+ const base = `Not a true undo: the original ${resource} is permanently gone and its id will never be reissued. Reversing creates a NEW ${resource} from the saved copy, under a new id, so anything that referenced the old id stays broken.`;
537
+ return notRestored && notRestored.trim() ? `${base} Not restored: ${notRestored.trim()}.` : base;
538
+ }
539
+ function fullWarning(notRestored) {
540
+ const base = "Reversing writes the previously saved values back over the record, so any change made to it since then is overwritten.";
541
+ return notRestored && notRestored.trim() ? `${base} It does not restore ${notRestored.trim()}.` : base;
542
+ }
543
+
544
+ // src/write-protection/refuse-without-force.ts
545
+ function idOf(args, keys) {
546
+ for (const k of keys) {
547
+ const v = args[k];
548
+ if (typeof v === "string" && v.trim() || typeof v === "number") return String(v);
549
+ }
550
+ return null;
551
+ }
552
+ function archiveOfferRefusal(deleteAction, args, opts) {
553
+ const id = idOf(args, opts.idKeys ?? ["id"]);
554
+ const link = id && opts.link ? opts.link(args, id) : void 0;
555
+ return {
556
+ ok: false,
557
+ deleted: false,
558
+ archive_available: true,
559
+ resource: opts.resource,
560
+ record_id: id,
561
+ delete_action: deleteAction,
562
+ archive_action: opts.archiveAction,
563
+ ...link ? { link } : {},
564
+ note: `Nothing was deleted. This ${opts.resource}${id ? ` (${id})` : ""} can be ARCHIVED instead with ${opts.archiveAction}, which keeps its id and is reversible; a delete throws the id away for good and cannot be undone. Offer the archive to the person first. If they explicitly want it destroyed even though it cannot be recovered, call ${deleteAction} again with force: true.`
565
+ };
566
+ }
567
+ function refuseWithoutForce(deleteAction, inner, opts) {
568
+ return async (args, env, ctx) => {
569
+ const a = args ?? {};
570
+ if (a.force === true) {
571
+ const { force: _force, ...rest } = a;
572
+ return inner(rest, env, ctx);
573
+ }
574
+ return archiveOfferRefusal(deleteAction, a, opts);
575
+ };
576
+ }
577
+
578
+ // src/write-protection/undo-capture.ts
579
+ async function safeCapture(scope, name, id, capture) {
580
+ try {
581
+ const got = await capture();
582
+ return got && typeof got === "object" ? got : null;
583
+ } catch (err) {
584
+ console.warn(`[${scope}] ${name} ${id}: capture failed, writing without undo: ${String(err)}`);
585
+ return null;
586
+ }
587
+ }
588
+ async function offerUndo(args) {
589
+ if (!args.before) return void 0;
590
+ const captured = await args.journal.captureBefore({
591
+ entity: args.entity,
592
+ originalId: args.id,
593
+ before: args.before,
594
+ connection: args.connection
595
+ });
596
+ if (!captured) return void 0;
597
+ const base = args.describe(args.before, args.id);
598
+ const prep = args.connectionPreposition ?? "on";
599
+ const label = args.connectionLabel ?? args.connection;
600
+ return undoEnvelope({
601
+ fidelity: args.fidelity,
602
+ warning: args.warning,
603
+ describes: label ? `${base} ${prep} ${label}` : base,
604
+ resource: args.resource,
605
+ ref: captured.ref
606
+ });
607
+ }
608
+
609
+ // src/write-protection/require-approval.ts
610
+ function rawIdOf(args, keys) {
611
+ for (const k of keys) {
612
+ const v = args[k];
613
+ if (typeof v === "string" && v || typeof v === "number") return String(v);
614
+ }
615
+ return "";
616
+ }
617
+ function isOk(result) {
618
+ return !!result && typeof result === "object" && result.ok !== false;
619
+ }
620
+ function requireApproval(handlers, specs, opts) {
621
+ const gated = {};
622
+ for (const [name, spec] of Object.entries(specs)) {
623
+ const inner = handlers[name];
624
+ if (!inner) {
625
+ throw new Error(
626
+ `requireApproval: "${name}" has an approval spec but is not in the handler registry. It was probably renamed or removed; update the specs in the same change, or a destructive tool ships ungated.`
627
+ );
628
+ }
629
+ if (spec.undo && !opts.journal) {
630
+ throw new Error(`requireApproval: "${name}" declares undo but no journal was supplied.`);
631
+ }
632
+ gated[name] = async (args, env, ctx) => {
633
+ const a = args ?? {};
634
+ if (a[APPROVAL_GRANTED_KEY] === true) {
635
+ const { [APPROVAL_GRANTED_KEY]: _granted, ...rest } = a;
636
+ return granted(name, spec, rest, env, ctx, inner, opts);
637
+ }
638
+ const connection = await opts.resolveConnection(env, a);
639
+ const rawId = rawIdOf(a, spec.keys);
640
+ const pinned = connection ? opts.pinEnv(env, connection) : env;
641
+ const target = rawId && opts.describeTarget ? await safeLabel(opts.describeTarget, pinned, rawId) : rawId || "(unknown id)";
642
+ const extra = spec.hash ? spec.hash(a) : [];
643
+ return {
644
+ ok: false,
645
+ _approval: {
646
+ ...spec.describe(target, a, connection),
647
+ hash: approvalHash(rawId, connection, ...extra)
648
+ }
649
+ };
650
+ };
651
+ }
652
+ return gated;
653
+ }
654
+ async function safeLabel(fn, env, id) {
655
+ try {
656
+ const label = await fn(env, id);
657
+ return label && label.trim() ? label : id;
658
+ } catch {
659
+ return id;
660
+ }
661
+ }
662
+ async function granted(name, spec, args, env, ctx, inner, opts) {
663
+ const connection = await opts.resolveConnection(env, args);
664
+ const stamp = (r) => connection && opts.stampConnection ? opts.stampConnection(r, connection) : r;
665
+ const undo = spec.undo;
666
+ const id = rawIdOf(args, spec.keys);
667
+ if (!undo || !id) return stamp(await inner(args, env, ctx));
668
+ const pinned = connection ? opts.pinEnv(env, connection) : env;
669
+ const before = await safeCapture(opts.scope, name, id, () => undo.capture(pinned, id, args));
670
+ const result = await inner(args, env, ctx);
671
+ if (!isOk(result) || !before) return stamp(result);
672
+ const envelope = await offerUndo({
673
+ journal: opts.journal(env),
674
+ entity: name,
675
+ id,
676
+ before,
677
+ connection: connection || null,
678
+ fidelity: undo.fidelity,
679
+ resource: undo.resource,
680
+ describe: undo.describe,
681
+ warning: undo.warning(id, before)
682
+ });
683
+ if (!envelope) return stamp(result);
684
+ return { ...stamp(result), _undo: envelope };
685
+ }
686
+
687
+ // src/write-protection/undo-apply.ts
688
+ async function runUndoApply(args, opts) {
689
+ const ref = typeof args.ref === "string" ? args.ref.trim() : "";
690
+ if (!ref) return { ok: false, error: "missing_ref", note: "No before-image reference was supplied. Nothing was changed." };
691
+ const row = await opts.journal.loadBefore(ref);
692
+ if (!row) {
693
+ return {
694
+ ok: false,
695
+ error: "before_image_missing",
696
+ note: "The saved copy of this record is no longer available, so it cannot be restored. Nothing was changed."
697
+ };
698
+ }
699
+ const spec = opts.specs[row.entity];
700
+ if (!spec) {
701
+ return {
702
+ ok: false,
703
+ error: "undo_no_longer_supported",
704
+ note: `This app no longer knows how to reverse a "${row.entity}" change. Nothing was changed.`
705
+ };
706
+ }
707
+ const before = row.before;
708
+ if (!before || typeof before !== "object") {
709
+ return { ok: false, error: "before_image_unreadable", note: "The saved copy could not be read back. Nothing was changed." };
710
+ }
711
+ let pinned;
712
+ try {
713
+ pinned = await opts.pin(opts.env, row.connection, args);
714
+ } catch (err) {
715
+ return {
716
+ ok: false,
717
+ error: "connection_unavailable",
718
+ note: `Could not reach the connection this change was made in${row.connection ? ` (${row.connection})` : ""}: ${String(err)}. Nothing was changed.`
719
+ };
720
+ }
721
+ if (pinned === null || pinned === void 0) {
722
+ return {
723
+ ok: false,
724
+ error: "connection_mismatch",
725
+ note: `This change was made in ${row.connection ?? "a different connection"}, which is not the one currently selected. Switch to it and retry. Nothing was changed.`
726
+ };
727
+ }
728
+ let result;
729
+ try {
730
+ result = await spec.restore(pinned, before, row) ?? { ok: true };
731
+ } catch (err) {
732
+ result = { ok: false, error: String(err) };
733
+ }
734
+ if (!result.ok) {
735
+ return {
736
+ ok: false,
737
+ error: "restore_failed",
738
+ note: `The provider refused to restore the ${spec.resource}: ${result.error ?? "unknown error"}. Nothing was restored.`
739
+ };
740
+ }
741
+ await opts.journal.dropBefore(ref);
742
+ const recreated = spec.fidelity === "recreated";
743
+ return {
744
+ ok: true,
745
+ resource: spec.resource,
746
+ original_id: row.original_id,
747
+ connection: row.connection,
748
+ new_id: result.newId ?? null,
749
+ fidelity: spec.fidelity,
750
+ ...spec.notRestored ? { not_restored: spec.notRestored } : {},
751
+ note: recreated ? `Created a NEW ${spec.resource}${result.newId ? ` (${result.newId})` : ""} from the saved copy. The original ${row.original_id} is still gone and its id will never be reissued.${spec.notRestored ? ` Not restored: ${spec.notRestored}.` : ""} Report this as a replacement, not as an undo.` : `Restored ${spec.resource} ${row.original_id} to its saved values.${spec.notRestored ? ` Not restored: ${spec.notRestored}.` : ""} Anything changed since the capture was overwritten.`
752
+ };
753
+ }
754
+
755
+ // src/write-protection/confirmation-policy.ts
756
+ function buildConfirmationPolicy(src) {
757
+ const actions = {};
758
+ const put = (name, rule, group) => {
759
+ if (actions[name]) throw new Error(`buildConfirmationPolicy: "${name}" is declared twice (second time in ${group})`);
760
+ actions[name] = rule;
761
+ };
762
+ for (const [name, describe] of Object.entries(src.irreversible ?? {})) put(name, { always: true, describe, irreversible: true }, "irreversible");
763
+ for (const [name, describe] of Object.entries(src.always ?? {})) put(name, { always: true, describe }, "always");
764
+ for (const [name, rule] of Object.entries(src.rules ?? {})) put(name, rule, "rules");
765
+ return { actions: Object.fromEntries(Object.entries(actions).sort(([a], [b]) => a.localeCompare(b))) };
766
+ }
767
+ var DEFAULT_WRITE_PREFIXES = [
768
+ "create_",
769
+ "update_",
770
+ "delete_",
771
+ "remove_",
772
+ "set_",
773
+ "add_",
774
+ "archive_",
775
+ "unarchive_",
776
+ "void_",
777
+ "refund_",
778
+ "cancel_",
779
+ "send_",
780
+ "email_",
781
+ "post_",
782
+ "publish_",
783
+ "unpublish_",
784
+ "approve_",
785
+ "reject_",
786
+ "submit_",
787
+ "allocate_",
788
+ "mark_",
789
+ "complete_",
790
+ "reopen_",
791
+ "convert_",
792
+ "clone_",
793
+ "upload_",
794
+ "attach_",
795
+ "detach_",
796
+ "move_",
797
+ "merge_",
798
+ "assign_",
799
+ "unassign_",
800
+ "pay_",
801
+ "capture_",
802
+ "process_",
803
+ "apply_",
804
+ "authorise_",
805
+ "authorize_",
806
+ "register_",
807
+ "unregister_",
808
+ "rotate_",
809
+ "revoke_",
810
+ "clear_",
811
+ "reset_",
812
+ "purge_",
813
+ "force_",
814
+ "import_",
815
+ "push_",
816
+ "invite_",
817
+ "swap_",
818
+ "bulk_",
819
+ "batch_",
820
+ "unship_",
821
+ "offboard_",
822
+ "hold_",
823
+ "release_",
824
+ "retry_",
825
+ "trigger_",
826
+ "record_",
827
+ "adjust_",
828
+ "receive_",
829
+ "dispatch_",
830
+ "transfer_",
831
+ "link_",
832
+ "unlink_",
833
+ "enable_",
834
+ "disable_",
835
+ "pause_",
836
+ "resume_",
837
+ "restore_",
838
+ "replace_",
839
+ "insert_",
840
+ "patch_",
841
+ "put_",
842
+ "write_",
843
+ "save_",
844
+ "book_",
845
+ "schedule_",
846
+ "reschedule_",
847
+ "escalate_",
848
+ "resolve_",
849
+ "dismiss_",
850
+ "accept_",
851
+ "decline_",
852
+ "follow_",
853
+ "unfollow_",
854
+ "like_",
855
+ "comment_",
856
+ "deactivate_",
857
+ "activate_",
858
+ "suspend_",
859
+ "generate_",
860
+ "issue_",
861
+ "charge_"
862
+ ];
863
+ var DEFAULT_MONEY_FIELDS = ["amount", "total", "unit_amount", "sub_total", "price", "budget"];
864
+ var PLACEHOLDER = /\{([a-zA-Z0-9_.]+)\}/g;
865
+ function conditions(rule) {
866
+ return !rule.when ? [] : Array.isArray(rule.when) ? rule.when : [rule.when];
867
+ }
868
+ function checkConfirmationPolicy(input) {
869
+ const registry = new Set(input.registry);
870
+ const ungated = input.ungated ?? [];
871
+ const prefixes = input.writePrefixes ?? DEFAULT_WRITE_PREFIXES;
872
+ const nested = input.nestedUnderInput ?? true;
873
+ const money = new Set(input.moneyFields ?? DEFAULT_MONEY_FIELDS);
874
+ const actions = input.policy.actions ?? {};
875
+ const out = [];
876
+ for (const [name, rule] of Object.entries(actions)) {
877
+ if (!registry.has(name)) out.push(`dead rule: "${name}" is not in the registry, so it gates nothing`);
878
+ if (!rule.always && conditions(rule).length === 0) out.push(`${name}: rule says nothing (no always, no when)`);
879
+ if (!rule.describe || !rule.describe.trim()) out.push(`${name}: describe missing`);
880
+ else if (rule.describe.trim().endsWith(".")) out.push(`${name}: describe must not end in a period (platform renders "Action: <text>.")`);
881
+ const required = new Set(input.requiredInput?.(name) ?? []);
882
+ for (const c of conditions(rule)) {
883
+ if (!(c.atLeast >= 1)) out.push(`${name}: atLeast must be >= 1 (${c.atLeast} is always true; use always: true)`);
884
+ if (nested && !c.count.startsWith("input.")) out.push(`${name}: count "${c.count}" must be dotted through input.`);
885
+ const leaf = c.count.split(".").pop() ?? "";
886
+ if (money.has(leaf)) out.push(`${name}: threshold on money field "${c.count}" is bypassed by a numeric string; use always`);
887
+ required.add(c.count.replace(/^input\./, "").split(".")[0]);
888
+ }
889
+ for (const m of (rule.describe ?? "").matchAll(PLACEHOLDER)) {
890
+ const path = m[1];
891
+ if (nested && !path.startsWith("input.")) {
892
+ out.push(`${name}: placeholder {${path}} is not under input. and will render as (unset)`);
893
+ continue;
894
+ }
895
+ if (input.requiredInput) {
896
+ const field = path.replace(/^input\./, "").replace(/\.length$/, "").split(".")[0];
897
+ if (!required.has(field)) out.push(`${name}: placeholder {${path}} reads "${field}", which the action does not require, so it renders as (unset) when omitted`);
898
+ }
899
+ }
900
+ }
901
+ for (const name of ungated) {
902
+ if (!registry.has(name)) out.push(`ungated list: "${name}" is not in the registry`);
903
+ if (actions[name]) out.push(`ungated list: "${name}" is also gated; remove one`);
904
+ }
905
+ for (const name of registry) {
906
+ if (!prefixes.some((p) => name.startsWith(p))) continue;
907
+ if (actions[name] || ungated.includes(name)) continue;
908
+ out.push(`unclassified write: "${name}" is neither gated nor listed as deliberately ungated`);
909
+ }
910
+ return out.sort();
911
+ }
488
912
  export {
913
+ APPROVAL_GRANTED_KEY,
489
914
  DEFAULT_EMIT_TIMEOUT_MS,
490
915
  DEFAULT_MAX_FILE_BYTES,
916
+ DEFAULT_MONEY_FIELDS,
917
+ DEFAULT_WRITE_PREFIXES,
918
+ InvalidUndoEnvelopeError,
491
919
  NotConnectedError,
920
+ UNIT_SEP,
492
921
  actorKey,
493
922
  actorTool,
494
923
  appFileUrl,
924
+ approvalHash,
925
+ archiveOfferRefusal,
495
926
  base64ToBytes,
927
+ buildConfirmationPolicy,
496
928
  buildMarketplaceWebhookUrl,
497
929
  bytesToBase64,
498
930
  canEmit,
931
+ checkConfirmationPolicy,
499
932
  constantTimeEqual,
500
933
  createMarketplaceEmitter,
501
934
  createToolWrappers,
@@ -507,22 +940,32 @@ export {
507
940
  fetchFileAsBase64,
508
941
  fetchFileBytes,
509
942
  fetchWithRetry,
943
+ fullWarning,
510
944
  getAppFile,
511
945
  hmacSha256Hex,
512
946
  installTokenPost,
513
947
  isNotConnectedError,
514
948
  isStagingHost,
515
949
  listAppFiles,
950
+ offerUndo,
516
951
  overlaySprigr,
517
952
  ownerFromActorKey,
518
953
  parseActor,
519
954
  putAppFile,
520
955
  putAppFileStream,
521
956
  randomHex,
957
+ recreatedWarning,
958
+ refuseWithoutForce,
959
+ requireApproval,
522
960
  resolveInstallBridge,
523
961
  resolveOAuthBouncerBase,
524
962
  resolveOAuthBouncerCallbackUrl,
525
963
  resolvePlatformWebhookBase,
964
+ runUndoApply,
965
+ safeCapture,
966
+ seq,
967
+ set,
526
968
  tool,
969
+ undoEnvelope,
527
970
  withSprigrEmitFallback
528
971
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprigr/apps-app-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -30,6 +30,10 @@
30
30
  },
31
31
  "files": [
32
32
  "dist",
33
+ "bin",
33
34
  "README.md"
34
- ]
35
+ ],
36
+ "bin": {
37
+ "sprigr-check-write-protection": "./bin/check-write-protection.mjs"
38
+ }
35
39
  }