@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.
- package/CHANGELOG.md +256 -0
- package/README.md +101 -1
- package/SECURITY.md +190 -52
- package/bin/sheleg-dev.js +18 -0
- package/package.json +2 -2
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/sheleg-dev/hooks/hooks.json +29 -0
- package/plugins/sheleg-dev/hooks/lib/moneygate.js +587 -0
- package/plugins/sheleg-dev/hooks/money-gate.js +68 -0
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +7 -3
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +76 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +249 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/manifest.json +75 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-from-thank-you-page.json +32 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-server.json +26 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-pixel-browser.json +12 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/reference-emitter.mjs +129 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +13 -1
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +68 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +110 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +404 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +38 -2
- 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.
|
|
265
|
-
reloading the thank-you page:
|
|
266
|
-
|
|
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
|