@ssheleg/sheleg-dev 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +101 -1
  3. package/SECURITY.md +190 -52
  4. package/bin/sheleg-dev.js +18 -0
  5. package/package.json +2 -2
  6. package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
  7. package/plugins/sheleg-dev/hooks/hooks.json +29 -0
  8. package/plugins/sheleg-dev/hooks/lib/moneygate.js +587 -0
  9. package/plugins/sheleg-dev/hooks/money-gate.js +68 -0
  10. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +7 -3
  11. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +76 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +249 -0
  13. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/manifest.json +75 -0
  14. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-from-thank-you-page.json +32 -0
  15. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-server.json +26 -0
  16. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-pixel-browser.json +12 -0
  17. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/reference-emitter.mjs +129 -0
  18. package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +13 -1
  19. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +68 -0
  20. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
  21. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
  22. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +110 -0
  23. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +404 -0
  24. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
  25. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
  26. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
  27. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
  28. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
  29. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
  30. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
  31. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
  32. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
  33. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
  34. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
  35. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +38 -2
  36. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +43 -1
@@ -0,0 +1,587 @@
1
+ 'use strict';
2
+ /**
3
+ * The manual gate, as a decision a hook can make.
4
+ *
5
+ * Four of this pack's own rules are prose that stops nothing:
6
+ *
7
+ * - `crypto-payments/SKILL.md` — "Never auto-refund from the webhook. Route holds and
8
+ * refunds to a queue a human can see."
9
+ * - `stripe-billing/references/webhook-events.md` — "`charge.dispute.created` is money
10
+ * already gone plus a fee. Treat it as a refund for entitlement purposes and route it
11
+ * to a human — evidence has a deadline."
12
+ * - `crypto-payments/SKILL.md` — "`SKIP_BILLING=true` in production is not a shortcut,
13
+ * it is a free-money path."
14
+ * - `crypto-payments/references/heleket-provider.md` — the boot assertion
15
+ * `assertHeleketEnv()` refuses a run that merely *holds* a live merchant credential
16
+ * while declaring `test`.
17
+ *
18
+ * The fourth is a control, and it lives in the reader's application. It cannot see a
19
+ * SHELL that exports the same credential before any application starts — which is the
20
+ * gap this module closes. The other three were sentences.
21
+ *
22
+ * **The pure part is here on purpose.** Everything below is a function of a payload and
23
+ * an environment object handed in as an argument: no `process`, no `HOME`, no
24
+ * filesystem, no clock. `hooks/money-gate.js` moves bytes and this decides. That split
25
+ * is what makes a refusal reproducible in `test/moneygate_test.js` without a live
26
+ * session, and it is the umbrella's invariant ("a guard decides in a pure module; the
27
+ * hook only moves bytes").
28
+ *
29
+ * Two defects measured elsewhere in this program shaped the reading:
30
+ *
31
+ * - **UM-09** — the umbrella's gate decided ownership by asking whether anything was
32
+ * staged, at `PreToolUse`, *before* the command ran; an `add`-then-commit line
33
+ * bypassed it. So nothing here reads state the command is about to change. The only
34
+ * inputs are the payload and the environment the hook was spawned with.
35
+ * - **UM-10** — its `isCommit` matched the target verb inside a quoted JSON payload, an
36
+ * argument to `node` rather than an invocation, and denied it. So `executablePart()`
37
+ * drops the data (a heredoc body fed to something that is not a shell, whole-line
38
+ * comments) and `simpleCommands()` reads the invocation rather than the text: a token
39
+ * in a `grep` pattern is not a call.
40
+ */
41
+
42
+ // ---------------------------------------------------------------- lexing
43
+
44
+ /**
45
+ * The part of a payload that could actually run, with data stripped out.
46
+ *
47
+ * Adapted from the umbrella's `lib/hygiene.js` — same boundary, and the boundary is the
48
+ * whole design:
49
+ *
50
+ * - **A heredoc body fed to something that is not a shell** is data. `cat > .env <<EOF …
51
+ * EOF` and `python3 - <<'PY' … PY` cannot charge a card. `bash <<EOF … EOF` is a
52
+ * script, so its body is KEPT — stripping every heredoc would be a documented bypass,
53
+ * which is worse than the false positive it fixes.
54
+ * - **A whole-line comment** does not run.
55
+ *
56
+ * **Quoted strings are deliberately NOT stripped.** `bash -c 'stripe refunds create'` is
57
+ * a real invocation living inside quotes. `simpleCommands()` expands that instead of
58
+ * guessing.
59
+ */
60
+ const HEREDOC_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'eval', 'source', '.']);
61
+
62
+ function bareName(token) {
63
+ // Shell quoting is not part of a name. The umbrella lost a real invocation to a
64
+ // trailing quote here (B-59's sibling bypass, 2026-08-16), so strip both ends.
65
+ let t = String(token || '').replace(/^['"]+/, '').replace(/['"]+$/, '');
66
+ t = t.replace(/@[^/]*$/, '').replace(/^.*\//, '');
67
+ return t;
68
+ }
69
+
70
+ function executablePart(command) {
71
+ const lines = String(command || '').split('\n');
72
+ const out = [];
73
+ let terminator = null;
74
+ let dropping = false;
75
+ for (const line of lines) {
76
+ if (terminator !== null) {
77
+ // The terminator may be indented when the heredoc used `<<-`.
78
+ if (line.trim() === terminator) { terminator = null; dropping = false; continue; }
79
+ if (dropping) continue;
80
+ out.push(line);
81
+ continue;
82
+ }
83
+ if (/^\s*#/.test(line)) continue;
84
+ const here = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line);
85
+ if (here) {
86
+ terminator = here[2];
87
+ // Which command is being fed? The last simple-command word before the `<<`, taken
88
+ // after the final pipe or `&&` so `foo | python3 - <<EOF` reads as python3.
89
+ const before = line.slice(0, here.index);
90
+ const segment = before.split(/\|\||&&|[|;]/).pop();
91
+ const words = segment.trim().split(/\s+/).filter(Boolean).map(bareName);
92
+ const cmd = words.find((w) => !w.includes('=')) || '';
93
+ dropping = !HEREDOC_SHELLS.has(cmd);
94
+ out.push(before);
95
+ continue;
96
+ }
97
+ out.push(line);
98
+ }
99
+ return out.join('\n');
100
+ }
101
+
102
+ /** Split on the operators that end one simple command, ignoring quoted text. */
103
+ function segments(text) {
104
+ const out = [];
105
+ let cur = '';
106
+ let quote = null;
107
+ const s = String(text || '');
108
+ for (let i = 0; i < s.length; i += 1) {
109
+ const c = s[i];
110
+ if (quote) {
111
+ cur += c;
112
+ if (c === '\\' && quote === '"') { cur += s[i + 1] || ''; i += 1; continue; }
113
+ if (c === quote) quote = null;
114
+ continue;
115
+ }
116
+ if (c === "'" || c === '"') { quote = c; cur += c; continue; }
117
+ if (c === '\n' || c === ';') { out.push(cur); cur = ''; continue; }
118
+ if ((c === '&' && s[i + 1] === '&') || (c === '|' && s[i + 1] === '|')) {
119
+ out.push(cur); cur = ''; i += 1; continue;
120
+ }
121
+ if (c === '|' || c === '&') { out.push(cur); cur = ''; continue; }
122
+ cur += c;
123
+ }
124
+ out.push(cur);
125
+ return out.filter((x) => x.trim());
126
+ }
127
+
128
+ /** Words, with their quotes removed and the fact that they were quoted remembered. */
129
+ function tokenize(text) {
130
+ const out = [];
131
+ const s = String(text || '');
132
+ let cur = '';
133
+ let has = false;
134
+ let quoted = false;
135
+ for (let i = 0; i < s.length; i += 1) {
136
+ const c = s[i];
137
+ if (c === '\\' && i + 1 < s.length && !/\s/.test(s[i + 1])) {
138
+ cur += s[i + 1]; has = true; i += 1; continue;
139
+ }
140
+ if (c === "'" || c === '"') {
141
+ const q = c;
142
+ quoted = true; has = true;
143
+ i += 1;
144
+ while (i < s.length && s[i] !== q) {
145
+ if (q === '"' && s[i] === '\\' && i + 1 < s.length) { cur += s[i + 1]; i += 2; continue; }
146
+ cur += s[i]; i += 1;
147
+ }
148
+ continue;
149
+ }
150
+ if (/\s/.test(c)) {
151
+ if (has) { out.push({ value: cur, quoted }); cur = ''; has = false; quoted = false; }
152
+ continue;
153
+ }
154
+ cur += c; has = true;
155
+ }
156
+ if (has) out.push({ value: cur, quoted });
157
+ return out;
158
+ }
159
+
160
+ const ASSIGN = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/;
161
+
162
+ /**
163
+ * Wrappers that stand in front of the command that matters. `env` and `sudo` also carry
164
+ * the prefix assignments, so peeling them is what makes `env HELEKET_API_KEY=… node app`
165
+ * read the same as `HELEKET_API_KEY=… node app`.
166
+ */
167
+ const RUNNERS = new Set([
168
+ 'sudo', 'doas', 'env', 'time', 'nice', 'nohup', 'command', 'exec', 'stdbuf',
169
+ 'npx', 'pnpx', 'bunx', 'pipx', 'uvx', 'dlx',
170
+ ]);
171
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish']);
172
+
173
+ /**
174
+ * A payload as the list of simple commands it would run.
175
+ *
176
+ * Each entry is `{cmd, argv, assignments, positionals, text}`:
177
+ *
178
+ * - `assignments` is a Map of the PREFIX assignments only — the ones the shell applies to
179
+ * this command. `grep 'HELEKET_API_KEY=' f` therefore has an empty map, because `grep`
180
+ * is the command word and everything after it is an argument. That distinction is the
181
+ * whole of UM-10's lesson: an assignment-shaped string in an argument sets nothing.
182
+ * - `cmd` is the command word with any path and version suffix removed.
183
+ * - `argv` keeps every remaining word; `positionals` drops the flags.
184
+ *
185
+ * `bash -c '<payload>'` is expanded rather than guessed at, to `depth` 3. A gate that
186
+ * ignored the inside of `-c` would ship with a one-token bypass.
187
+ */
188
+ function simpleCommands(command, depth) {
189
+ const limit = typeof depth === 'number' ? depth : 3;
190
+ const out = [];
191
+ for (const seg of segments(executablePart(command))) {
192
+ const tokens = tokenize(seg);
193
+ const assignments = new Map();
194
+ let i = 0;
195
+ let cmd = '';
196
+ while (i < tokens.length) {
197
+ const raw = tokens[i].value;
198
+ const m = ASSIGN.exec(raw);
199
+ if (m) { assignments.set(m[1], m[2]); i += 1; continue; }
200
+ if (raw === 'export' || RUNNERS.has(bareName(raw))) { i += 1; continue; }
201
+ if (/^-/.test(raw)) { i += 1; continue; } // a runner's own flag
202
+ cmd = bareName(raw); i += 1; break;
203
+ }
204
+ // `export FOO=bar` puts the assignment after the keyword, so keep collecting.
205
+ while (i < tokens.length && ASSIGN.test(tokens[i].value) && !cmd) {
206
+ const m = ASSIGN.exec(tokens[i].value);
207
+ assignments.set(m[1], m[2]);
208
+ i += 1;
209
+ }
210
+ const argv = tokens.slice(i);
211
+ // `export A=1 B=2` — no command word at all, only assignments.
212
+ if (!cmd) {
213
+ for (const t of argv) {
214
+ const m = ASSIGN.exec(t.value);
215
+ if (m) assignments.set(m[1], m[2]);
216
+ }
217
+ }
218
+ const positionals = argv.filter((t) => !/^-/.test(t.value)).map((t) => t.value);
219
+ out.push({
220
+ cmd,
221
+ argv: argv.map((t) => t.value),
222
+ assignments,
223
+ positionals,
224
+ text: seg,
225
+ });
226
+ if (SHELLS.has(cmd) && limit > 0) {
227
+ const at = argv.findIndex((t) => /^-[a-z]*c$/.test(t.value));
228
+ if (at >= 0 && argv[at + 1]) {
229
+ for (const inner of simpleCommands(argv[at + 1].value, limit - 1)) out.push(inner);
230
+ }
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+
236
+ // -------------------------------------------------------------- the rules
237
+
238
+ /**
239
+ * Command words that read rather than run.
240
+ *
241
+ * A **denylist**, not an allowlist, and the direction is deliberate: an invoker this
242
+ * module has never heard of must be gated, not waved through. The price is that the
243
+ * documented false positives are enumerated here instead of being discovered — which is
244
+ * the trade the audit asks for, because a guard that cannot be watched failing is no
245
+ * evidence and a guard that refuses correct input gets switched off.
246
+ */
247
+ const READERS = new Set([
248
+ 'echo', 'printf', 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'tee',
249
+ 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'cut', 'tr', 'column',
250
+ 'sort', 'uniq', 'wc', 'diff', 'comm', 'ls', 'find', 'fd', 'tree', 'stat', 'file',
251
+ 'git', 'jq', 'yq', 'man', 'glow', 'xxd', 'md5sum', 'shasum', 'sha256sum', 'true',
252
+ ]);
253
+
254
+ /**
255
+ * Credentials whose provider issues **no test variant**, so any shell holding one holds
256
+ * production. Established from the document rather than assumed —
257
+ * `crypto-payments/references/heleket-provider.md` §1 and §7: one key per merchant,
258
+ * which is also the webhook signing secret, one host, and no `test`/`live` marker.
259
+ *
260
+ * **`HELEKET_LIVE_MERCHANT_ID` and `HELEKET_LIVE_KEY_FINGERPRINT` are NOT here**, and
261
+ * leaving them out is a reading of SD-02 rather than an omission: they are non-secret
262
+ * pins, and `assertHeleketEnv()` *refuses* a test run that does not set one
263
+ * (`HELEKET_ENV_UNPINNED`). A gate refusing to export them would contradict the boot
264
+ * assertion it exists to extend.
265
+ *
266
+ * **`STRIPE_SECRET_KEY` is not here either**, for the opposite reason: Stripe stamps the
267
+ * environment into the key, so `STRIPE_SECRET_KEY=sk_test_…` is correct and common. The
268
+ * live half is caught by its shape (`LIVE_KEY`), not by the variable's name.
269
+ */
270
+ const NO_TEST_VARIANT = new Map([
271
+ ['HELEKET_API_KEY',
272
+ 'Heleket issues one key per merchant, it is also the webhook signing secret, and it ' +
273
+ 'carries no test/live marker — so there is no test value this can hold ' +
274
+ '(crypto-payments/references/heleket-provider.md, "The test/live boundary").'],
275
+ ]);
276
+
277
+ /**
278
+ * A live Stripe key, by shape rather than by prefix.
279
+ *
280
+ * The prefix alone is documentation: this repository writes `sk_live_…` in
281
+ * `stripe-billing/references/stripe-agent-toolchain.md` and sweeps for
282
+ * `sk_live_[A-Za-z0-9]` in `SECURITY.md`. Requiring a key-shaped body is what separates
283
+ * the artifact from the sentence about it — the same distinction UM-10 got wrong.
284
+ */
285
+ const LIVE_KEY = /\b(sk|rk)_live_[A-Za-z0-9]{16,}/;
286
+
287
+ /** The environment a run DECLARES. Nothing is inferred from the credential itself. */
288
+ const ENV_VARS = ['SHELEG_DEV_ENV', 'HELEKET_ENV', 'STRIPE_ENV', 'PAYMENTS_ENV', 'APP_ENV', 'NODE_ENV'];
289
+
290
+ function normaliseEnv(value) {
291
+ const s = String(value == null ? '' : value).trim().replace(/^['"]|['"]$/g, '').toLowerCase();
292
+ if (!s) return null;
293
+ if (['production', 'prod', 'live'].includes(s)) return 'production';
294
+ // Everything that is not production is not production. SD-02's rule, kept verbatim:
295
+ // "Could not prove it was safe" must never read as "it was safe".
296
+ return 'test';
297
+ }
298
+
299
+ /**
300
+ * `test` | `production` | `null` (undeclared).
301
+ *
302
+ * A declaration made **inline in the payload** wins over the session's environment,
303
+ * because it describes the run that is about to happen: `HELEKET_ENV=test node app.js`
304
+ * declares test no matter what the operator exported yesterday.
305
+ */
306
+ function declaredEnv(env, commands) {
307
+ for (const c of commands || []) {
308
+ for (const v of ENV_VARS) {
309
+ if (c.assignments.has(v)) {
310
+ const got = normaliseEnv(c.assignments.get(v));
311
+ if (got) return got;
312
+ }
313
+ }
314
+ }
315
+ for (const v of ENV_VARS) {
316
+ const got = normaliseEnv((env || {})[v]);
317
+ if (got) return got;
318
+ }
319
+ return null;
320
+ }
321
+
322
+ const AUTH_VAR = 'SHELEG_DEV_LIVE_AUTHORISED';
323
+ const GATE_VAR = 'SHELEG_DEV_MONEY_GATE';
324
+
325
+ /** The categories the authorised person has signed off for this session. */
326
+ function authorised(env) {
327
+ const raw = (env || {})[AUTH_VAR];
328
+ if (!raw) return new Set();
329
+ return new Set(String(raw).split(/[,\s]+/).map((x) => x.trim().toLowerCase()).filter(Boolean));
330
+ }
331
+
332
+ /**
333
+ * The categories this gate stops, in the order they are checked.
334
+ *
335
+ * `authorisable: false` means no environment variable makes it pass. Both cases are
336
+ * about the gate itself rather than about money: granting yourself authority and running
337
+ * a free-money path in production are not decisions an operator can pre-approve for an
338
+ * agent, because approving them removes the gate rather than passing it.
339
+ */
340
+ const CATEGORIES = {
341
+ 'live-key': { authorisable: true },
342
+ credential: { authorisable: true },
343
+ refund: { authorisable: true },
344
+ payout: { authorisable: true },
345
+ dispute: { authorisable: true },
346
+ 'live-flag': { authorisable: true },
347
+ 'self-authorisation': { authorisable: false },
348
+ 'skip-billing': { authorisable: false },
349
+ };
350
+
351
+ /** A money-moving path on a provider's API, as a URL rather than as a bare path. */
352
+ const MONEY_URL = [
353
+ { category: 'refund', re: /(?:https?:\/\/|\$\{?[A-Z_][A-Z0-9_]*\}?|[a-z0-9-]+(?:\.[a-z0-9-]+)+)\/v1\/refunds\b/ },
354
+ { category: 'payout', re: /(?:https?:\/\/|\$\{?[A-Z_][A-Z0-9_]*\}?|[a-z0-9-]+(?:\.[a-z0-9-]+)+)\/v1\/(?:payouts?|transfers)\b/ },
355
+ { category: 'dispute', re: /(?:https?:\/\/|\$\{?[A-Z_][A-Z0-9_]*\}?|[a-z0-9-]+(?:\.[a-z0-9-]+)+)\/v1\/disputes\/[^\s"']+\/close\b/ },
356
+ ];
357
+
358
+ /** `stripe <noun> <verb>` — the CLI shape, read from the positionals of a `stripe` call. */
359
+ const MONEY_CLI = [
360
+ { category: 'refund', nouns: ['refunds', 'refund'], verbs: ['create'] },
361
+ { category: 'payout', nouns: ['payouts', 'payout', 'transfers', 'transfer'], verbs: ['create'] },
362
+ { category: 'dispute', nouns: ['disputes', 'dispute'], verbs: ['close'] },
363
+ ];
364
+
365
+ /**
366
+ * Tool names that move money by themselves.
367
+ *
368
+ * Matched on the NAME only, never on the tool's input. A tool input is where a document
369
+ * quoting `sk_live_…` arrives — `Write`, `Edit`, `NotebookEdit` all carry file text — and
370
+ * scanning it would refuse this repository's own references. The name is enough for the
371
+ * case that matters: `mcp__plugin_stripe_stripe__create_refund` is a refund whatever its
372
+ * arguments say.
373
+ */
374
+ const MONEY_TOOL = [
375
+ { category: 'refund', re: /(?:^|_)(?:create_refunds?|refunds?_create|issue_refund)$/i },
376
+ { category: 'payout', re: /(?:^|_)(?:create_(?:payouts?|transfers?)|(?:payouts?|transfers?)_create)$/i },
377
+ { category: 'dispute', re: /(?:^|_)(?:close_disputes?|disputes?_close)$/i },
378
+ ];
379
+
380
+ const LIVE_FLAG = /^--live(?:-?mode)?$/;
381
+
382
+ // ------------------------------------------------------------- the verdict
383
+
384
+ /**
385
+ * The one sentence a refusal must carry. Manifesto M-30, `manifesto.md:204`.
386
+ *
387
+ * A refusal with no next step is how an operator learns to switch a hook off, so every
388
+ * denial names the remedy AND the fact that the remedy is somebody else's to apply.
389
+ */
390
+ const DECIDES = 'The agent prepares the decision and its evidence. The authorised person decides.';
391
+
392
+ function remedy(category, runEnv) {
393
+ const spec = CATEGORIES[category] || { authorisable: true };
394
+ if (!spec.authorisable) {
395
+ if (category === 'self-authorisation') {
396
+ return `Authorisation is not this run's to grant. Ask the operator to export ` +
397
+ `${AUTH_VAR} in the shell that starts the session, then start a new one — a value ` +
398
+ 'set from inside a tool call is not the environment this gate reads.';
399
+ }
400
+ return 'Run the mock path with a non-production declaration, or take the production ' +
401
+ 'path with real billing. There is no flag that makes this one pass.';
402
+ }
403
+ if (runEnv === 'test') {
404
+ return 'This run declares a non-production environment, and a test-declaring run cannot ' +
405
+ 'be authorised for a live operation — no variable overrides that. Re-declare the ' +
406
+ 'environment deliberately, or hand the prepared decision to the authorised person.';
407
+ }
408
+ return `If this is intended, the authorised person exports ` +
409
+ `${AUTH_VAR}="${category}" (or "all") in the shell that starts the session and starts a ` +
410
+ 'new one. Setting it from inside a tool call does not reach this gate.';
411
+ }
412
+
413
+ function deny(category, what, evidence, runEnv) {
414
+ return {
415
+ allow: false,
416
+ category,
417
+ reason: [
418
+ `[sheleg-dev] refused: ${what}`,
419
+ ` ${evidence}`,
420
+ ` ${DECIDES}`,
421
+ ` ${remedy(category, runEnv)}`,
422
+ ].join('\n'),
423
+ };
424
+ }
425
+
426
+ const ALLOW = { allow: true, category: null, reason: '' };
427
+
428
+ /**
429
+ * Would this payload cross a manual gate?
430
+ *
431
+ * `payload` is the `PreToolUse` hook input; `env` is the environment the hook was spawned
432
+ * with, passed in rather than read, so a fixture needs no `HOME`.
433
+ *
434
+ * Order matters only in what the operator is told first, never in what passes: every rule
435
+ * is checked against the same commands and the first hit is reported.
436
+ */
437
+ function decide(payload, env) {
438
+ const e = env || {};
439
+ const data = payload || {};
440
+ const tool = String(data.tool_name || '');
441
+ const input = data.tool_input || {};
442
+
443
+ // The off switch is honest about being one: an operator who sets it has decided, and a
444
+ // gate with no documented off switch gets removed instead of disabled. It is read from
445
+ // the environment only — a payload that sets it is `self-authorisation` below.
446
+ if (String(e[GATE_VAR] || '').trim().toLowerCase() === 'off') return ALLOW;
447
+
448
+ // Not Bash: decide from the tool NAME alone. See MONEY_TOOL for why the input is never
449
+ // read — that is where a reference document quoting a live key arrives.
450
+ if (tool !== 'Bash') {
451
+ const sessionEnv = declaredEnv(e, []);
452
+ for (const m of MONEY_TOOL) {
453
+ if (!m.re.test(tool)) continue;
454
+ if (allowedFor(m.category, e, sessionEnv)) return ALLOW;
455
+ return deny(m.category, `\`${tool}\` moves money and no authorisation covers it`,
456
+ 'The tool name is the whole evidence — its arguments were not read.', sessionEnv);
457
+ }
458
+ return ALLOW;
459
+ }
460
+
461
+ const command = String(input.command || '');
462
+ const commands = simpleCommands(command);
463
+ const runEnv = declaredEnv(e, commands);
464
+
465
+ for (const c of commands) {
466
+ // 7. Granting yourself the authority the gate exists to withhold.
467
+ for (const v of [AUTH_VAR, GATE_VAR]) {
468
+ if (!c.assignments.has(v)) continue;
469
+ return deny('self-authorisation',
470
+ `this command sets ${v}`,
471
+ `${v} is the authorised person's switch. A run that can set it is a run with no gate.`,
472
+ runEnv);
473
+ }
474
+
475
+ // 8. The free-money path, in production. crypto-payments/SKILL.md names it as such.
476
+ if (String(c.assignments.get('SKIP_BILLING') || '').replace(/['"]/g, '').toLowerCase() === 'true'
477
+ && runEnv === 'production') {
478
+ return deny('skip-billing',
479
+ 'SKIP_BILLING=true in a run declaring production',
480
+ 'crypto-payments/SKILL.md: "SKIP_BILLING=true in production is not a shortcut, it is ' +
481
+ 'a free-money path." The branch credits without a payment.', runEnv);
482
+ }
483
+
484
+ // 2. A credential whose provider issues no test value, in a test or undeclared run.
485
+ for (const [name, why] of NO_TEST_VARIANT) {
486
+ if (!c.assignments.has(name)) continue;
487
+ if (allowedFor('credential', e, runEnv)) break;
488
+ if (runEnv === 'production') {
489
+ return deny('credential',
490
+ `this command exports ${name} — a live merchant credential`, why, runEnv);
491
+ }
492
+ return deny('credential',
493
+ `this command exports ${name} in a run declaring ${runEnv || 'nothing at all'}`,
494
+ `${why} SD-02's boot assertion refuses the same pair as ` +
495
+ 'HELEKET_ENV_TEST_HOLDS_LIVE_CREDENTIAL, but it runs inside the application — a shell ' +
496
+ 'that merely holds the key never reaches it.', runEnv);
497
+ }
498
+ }
499
+
500
+ // 1. A live-shaped key anywhere in what would run.
501
+ //
502
+ // **No reader exemption here, deliberately.** The other rules skip `grep` and `cat`
503
+ // because a path or a flag inside a search pattern invokes nothing; a live key is
504
+ // different — `stripe-agent-toolchain.md` calls one in a repository "an incident, not a
505
+ // lint", and that is as true of a search pattern as of an export. The shape is what
506
+ // separates the artifact from the sentence about it, so `SECURITY.md`'s own sweep for
507
+ // `sk_live_[A-Za-z0-9]` passes on the prefix while a key-shaped body does not. Both
508
+ // directions are fixtured; overlapping the two mechanisms here once left each of them
509
+ // individually unproven.
510
+ const keyHit = commands.find((c) => LIVE_KEY.test(c.text));
511
+ if (keyHit && !allowedFor('live-key', e, runEnv)) {
512
+ const m = LIVE_KEY.exec(keyHit.text);
513
+ return deny('live-key',
514
+ `a live ${m[1] === 'sk' ? 'secret' : 'restricted'} key (${m[1]}_live_…) reaches \`${keyHit.cmd || 'the shell'}\``,
515
+ 'stripe-billing/references/stripe-agent-toolchain.md: "sk_live_… or rk_live_… in a ' +
516
+ 'repository is an incident, not a lint." The key itself is not echoed here.', runEnv);
517
+ }
518
+
519
+ for (const c of commands) {
520
+ if (READERS.has(c.cmd)) continue;
521
+
522
+ // 3-5. A money-moving call through the Stripe CLI.
523
+ if (bareName(c.cmd) === 'stripe') {
524
+ for (const m of MONEY_CLI) {
525
+ const nouns = m.nouns.some((n) => c.positionals.includes(n));
526
+ const verbs = m.verbs.some((v) => c.positionals.includes(v));
527
+ if (!nouns || !verbs) continue;
528
+ if (allowedFor(m.category, e, runEnv)) continue;
529
+ return deny(m.category, `\`stripe ${c.positionals.slice(0, 2).join(' ')}\``,
530
+ categoryEvidence(m.category), runEnv);
531
+ }
532
+ }
533
+
534
+ // 3-5. The same operations over HTTP. A URL, not a bare path, so that grepping for
535
+ // `/v1/refunds` in a document is not a refund.
536
+ for (const m of MONEY_URL) {
537
+ if (!c.argv.some((a) => m.re.test(a))) continue;
538
+ if (allowedFor(m.category, e, runEnv)) continue;
539
+ return deny(m.category, `a call to a ${m.category} endpoint from \`${c.cmd || 'the shell'}\``,
540
+ categoryEvidence(m.category), runEnv);
541
+ }
542
+
543
+ // 6. The explicit statement of live intent.
544
+ if (c.argv.some((a) => LIVE_FLAG.test(a)) && !allowedFor('live-flag', e, runEnv)) {
545
+ return deny('live-flag', `an explicit --live flag on \`${c.cmd || 'the shell'}\``,
546
+ 'A --live flag is a request for production access, which manifesto M-30 puts on the ' +
547
+ 'manual-gate side by name.', runEnv);
548
+ }
549
+ }
550
+
551
+ return ALLOW;
552
+ }
553
+
554
+ function categoryEvidence(category) {
555
+ if (category === 'refund') {
556
+ return 'crypto-payments/SKILL.md: "Never auto-refund from the webhook. Route holds and ' +
557
+ 'refunds to a queue a human can see." A refund is money movement and it is not undoable.';
558
+ }
559
+ if (category === 'payout') {
560
+ return 'A payout or transfer moves money out. Manifesto M-30 names money movement and ' +
561
+ 'irreversible action as manual-gate territory.';
562
+ }
563
+ return 'stripe-billing/references/webhook-events.md: a dispute is "money already gone plus a ' +
564
+ 'fee … route it to a human — evidence has a deadline." Closing one accepts the loss.';
565
+ }
566
+
567
+ /**
568
+ * Is this category signed off for this run?
569
+ *
570
+ * Two conditions, and the second is not negotiable by any variable: the category must be
571
+ * authorisable at all, and **a test-declaring run can never be authorised for a live
572
+ * operation**. A run that says it is a test and then refunds a real card is incoherent
573
+ * whichever half is true, and SD-02's rule applies — refuse on the test side.
574
+ */
575
+ function allowedFor(category, env, runEnv) {
576
+ const spec = CATEGORIES[category];
577
+ if (spec && !spec.authorisable) return false;
578
+ if (runEnv === 'test') return false;
579
+ const set = authorised(env);
580
+ return set.has('all') || set.has(category);
581
+ }
582
+
583
+ module.exports = {
584
+ decide, allowedFor, declaredEnv, normaliseEnv, authorised,
585
+ executablePart, segments, tokenize, simpleCommands, bareName,
586
+ CATEGORIES, NO_TEST_VARIANT, LIVE_KEY, READERS, AUTH_VAR, GATE_VAR, ENV_VARS, DECIDES,
587
+ };
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * The manual gate, at the one moment it can still prevent something.
5
+ *
6
+ * `PreToolUse`, because manifesto M-30 (`pod-manifesto/manifesto.md:200`) says a
7
+ * precondition is stronger than a warning, and `:204` says the categories — money
8
+ * movement, irreversible action, production access, destructive operations — are the
9
+ * authorised person's to decide. A report after the refund has cleared is a warning.
10
+ *
11
+ * **This file moves bytes and nothing else.** The verdict is `lib/moneygate.js`, a pure
12
+ * function of the payload and an environment object, fixtured in
13
+ * `test/moneygate_test.js` without a `HOME`. Three invariants from the family umbrella's
14
+ * `CLAUDE.md` are load-bearing here and all three are honoured:
15
+ *
16
+ * 1. **A guard decides in a pure module; the hook only moves bytes.** Nothing below
17
+ * inspects the filesystem, the process table or the clock.
18
+ * 2. **A hook fails silent, and a refusal names its remedy.** Every throw is swallowed
19
+ * and the exit code is always 0 — a hook that throws breaks every turn in every
20
+ * session, including sessions of packs that never asked for this one. The refusal text
21
+ * always carries the next step, because a refusal with no remedy is how an operator
22
+ * learns to switch a hook off.
23
+ * 3. **No reliance on a hook entry's `if` filter.** `hooks.json` here declares a matcher
24
+ * and no `if`: the reference calls `if` best-effort and says it FAILS OPEN on a command
25
+ * it cannot parse, so a gate resting on it ships with a documented bypass. The filter
26
+ * would also have been the natural place to write `Bash(stripe refunds*)`, which is
27
+ * exactly the shape a `bash -c '…'` wrapper defeats.
28
+ *
29
+ * Also: nothing here reads state the command is about to change (the UM-09 lesson — the
30
+ * umbrella's own gate asked "is anything staged?" at `PreToolUse`, and an add-then-commit
31
+ * line walked straight through). The payload and the spawning environment are the only
32
+ * inputs, and the command cannot alter either before this decides.
33
+ */
34
+
35
+ const path = require('path');
36
+
37
+ let gate = null;
38
+ try {
39
+ gate = require(path.join(__dirname, 'lib', 'moneygate.js'));
40
+ } catch (e) {
41
+ /* A gate that cannot load must not break the session. It exits 0 below. */
42
+ }
43
+
44
+ function deny(reason) {
45
+ process.stdout.write(`${JSON.stringify({
46
+ hookSpecificOutput: {
47
+ hookEventName: 'PreToolUse',
48
+ permissionDecision: 'deny',
49
+ permissionDecisionReason: reason,
50
+ },
51
+ })}\n`);
52
+ }
53
+
54
+ let raw = '';
55
+ process.stdin.setEncoding('utf8');
56
+ process.stdin.on('error', () => process.exit(0));
57
+ process.stdin.on('data', (chunk) => { raw += chunk; });
58
+ process.stdin.on('end', () => {
59
+ try {
60
+ if (!gate) return process.exit(0);
61
+ const data = raw.trim().startsWith('{') ? JSON.parse(raw) : {};
62
+ const verdict = gate.decide(data, process.env);
63
+ if (verdict && !verdict.allow) deny(verdict.reason);
64
+ } catch (e) {
65
+ /* Silence, on purpose. See invariant 2 above. */
66
+ }
67
+ process.exit(0);
68
+ });
@@ -261,9 +261,13 @@ schema and the full purchase / add_to_cart / begin_checkout set.
261
261
 
262
262
  **Deduplication is the part that silently doubles revenue.** A purchase fired
263
263
  from both the browser and the server (Conversions API, server-side GA4) must
264
- carry the SAME `event_id` / `transaction_id`, or both are counted. Test it by
265
- reloading the thank-you page: a purchase that increments twice is a
266
- deduplication bug, not a tracking success.
264
+ carry the SAME `event_id` / `transaction_id`, or both are counted. Run
265
+ `fixtures/assert-dedup-contract.mjs` rather than reloading the thank-you page:
266
+ `pixel-and-capi-carry-one-event-id` and `pixel-and-capi-carry-one-event-name` fail an
267
+ emitter that shares one field and not the other,
268
+ `no-purchase-reported-before-the-charge-cleared` fails one that reports from the redirect,
269
+ and `the-browser-event-is-kept` fails one that drops the pixel. `--self-test` deletes each
270
+ rule and watches the matching assertion go red.
267
271
 
268
272
  **A purchase is not a browser event, and that decides where it comes from.**
269
273
  Every other event on the list is something a person did in a tab, so the browser