@kaydash9999/banker-plugins 0.14.0 → 0.15.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 (80) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +47 -0
  4. package/README.md +23 -11
  5. package/codex/manifest.json +5 -5
  6. package/codex/transform-matrix.md +5 -5
  7. package/commands/front-qa.md +2 -2
  8. package/commands/setup.md +13 -4
  9. package/hooks/graceful-pause.mjs +222 -0
  10. package/hooks/hooks.json +2 -1
  11. package/hooks/register.mjs +12 -0
  12. package/package.json +4 -1
  13. package/skills/3d-intro-build/SKILL.md +1 -1
  14. package/skills/3d-intro-setup/SKILL.md +1 -1
  15. package/skills/all-in-one/SKILL.md +1 -1
  16. package/skills/append_wiki/SKILL.md +1 -1
  17. package/skills/arch-diagram/SKILL.md +1 -1
  18. package/skills/audit-mock/SKILL.md +1 -1
  19. package/skills/audit-security/SKILL.md +1 -1
  20. package/skills/audit-web-page/SKILL.md +1 -1
  21. package/skills/cleansing-memory/SKILL.md +1 -1
  22. package/skills/compact-copy/SKILL.md +1 -1
  23. package/skills/compact-wiki/SKILL.md +1 -1
  24. package/skills/curation/SKILL.md +1 -1
  25. package/skills/deep-init/SKILL.md +1 -1
  26. package/skills/deep-research/SKILL.md +1 -1
  27. package/skills/docs-setup/SKILL.md +1 -1
  28. package/skills/humanizer/SKILL.md +1 -1
  29. package/skills/interval-report/SKILL.md +1 -1
  30. package/skills/lineage/SKILL.md +146 -16
  31. package/skills/lineage/lineage.py +1091 -107
  32. package/skills/make-notion-guide/SKILL.md +1 -1
  33. package/skills/motion-graphic-make/SKILL.md +1 -1
  34. package/skills/motion-graphic-setup/SKILL.md +1 -1
  35. package/skills/nothing-design/SKILL.md +1 -1
  36. package/skills/obsidizer/SKILL.md +1 -1
  37. package/skills/omc-reference/SKILL.md +1 -1
  38. package/skills/payload-mon/SKILL.md +1 -1
  39. package/skills/pdf-vision-extract/SKILL.md +1 -1
  40. package/skills/play-qa/SKILL.md +1 -1
  41. package/skills/ralph-qa/SKILL.md +263 -112
  42. package/skills/ralph-qa/references/gemini-read-only.toml +9 -0
  43. package/skills/ralph-qa/references/gemini-seat.mjs +175 -0
  44. package/skills/ralph-qa/references/payload-scan.mjs +130 -0
  45. package/skills/ralph-qa/references/verifier-probe.mjs +872 -780
  46. package/skills/ready-compact/SKILL.md +1 -1
  47. package/skills/refresh-git-ignore/SKILL.md +1 -1
  48. package/skills/refresh-readme/SKILL.md +1 -1
  49. package/skills/rfp-author/SKILL.md +1 -1
  50. package/skills/setup-bypass-permissions/SKILL.md +123 -0
  51. package/skills/setup-bypass-permissions/agents/openai.yaml +4 -0
  52. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +381 -0
  53. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.ps1 +247 -0
  54. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +241 -0
  55. package/skills/{harness-factory → setup-harness-factory}/SKILL.md +3 -3
  56. package/skills/setup-insane-search/SKILL.md +1 -1
  57. package/skills/setup-java/SKILL.md +1 -1
  58. package/skills/setup-lsp/SKILL.md +1 -1
  59. package/skills/setup-mcp/SKILL.md +1 -1
  60. package/skills/setup-node/SKILL.md +1 -1
  61. package/skills/setup-omc/SKILL.md +1 -1
  62. package/skills/setup-omc-hud/SKILL.md +15 -1
  63. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +247 -0
  64. package/skills/setup-playwright/SKILL.md +1 -1
  65. package/skills/setup-pwsh/SKILL.md +1 -1
  66. package/skills/setup-python/SKILL.md +1 -1
  67. package/skills/setup-sandbox/SKILL.md +1 -1
  68. package/skills/setup-stitch/SKILL.md +1 -1
  69. package/skills/setup-tmux/SKILL.md +1 -1
  70. package/skills/smart-compact/SKILL.md +1 -1
  71. package/skills/summary-wiki/SKILL.md +1 -1
  72. package/skills/tone-compact/SKILL.md +1 -1
  73. package/skills/ultra-init/SKILL.md +1 -1
  74. package/skills/ultra-interview/SKILL.md +1 -1
  75. package/skills/ultra-ui-qa/SKILL.md +1 -1
  76. package/skills/update-banker/SKILL.md +1 -1
  77. package/skills/vertical-pptx/SKILL.md +1 -1
  78. package/skills/vertical-pptx-setup/SKILL.md +1 -1
  79. package/skills/visual-ralph/SKILL.md +1 -1
  80. package/skills/graceful_pause/SKILL.md +0 -95
@@ -0,0 +1,9 @@
1
+ # ralph-qa gemini seat: an admin-tier policy (gemini --admin-policy) that denies every tool. The
2
+ # payload comes on stdin, so the seat needs no tool, and a read tool would also read the folders
3
+ # the settings add to the workspace. The admin tier outranks the user's policies, settings
4
+ # `tools.allowed`, trusted MCP servers and the approval mode (gemini-cli 0.62.0).
5
+ # gemini-seat.mjs check refuses to start the seat when the rules below are changed.
6
+ [[rule]]
7
+ toolName = "*"
8
+ decision = "deny"
9
+ priority = 100
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gemini-seat: what ralph-qa checks before a gemini seat runs and removes after it ran.
4
+ *
5
+ * node gemini-seat.mjs check <seat folder> prints the read-only policy's path (exit 0), or
6
+ * says why the seat must not run (exit 1)
7
+ * node gemini-seat.mjs clean <seat folder> removes gemini's record of that folder's sessions
8
+ * node gemini-seat.mjs sweep <base folder> removes the records of seat folders under it that
9
+ * are gone (a run cut short before its clean)
10
+ *
11
+ * check: the admin policy next to this script must be the shipped deny-all one, as gemini reads
12
+ * a missing or changed policy file without a word and opens its tools (0.62.0). And no folder
13
+ * above the seat folder, outside the home, may hold `.gemini/.env` or `.env`: gemini reads the
14
+ * first one it finds walking up to the root, and a GOOGLE_GEMINI_BASE_URL there sends the
15
+ * payload elsewhere. On Windows any account may create such a folder under C:\.
16
+ * clean: gemini 0.62.0 records every headless session, payload and answer, under
17
+ * <home>/.gemini/tmp/<id>/chats, kept 30 days by default, and no setting stops it. The folders
18
+ * whose `.project_root` names the seat folder go: tmp/<id> and history/<id>, and the same under
19
+ * <home>/.cache/.gemini, where gemini keeps them in its macOS sandbox. projects.json keeps the
20
+ * seat folder's path and nothing else. <home> is $GEMINI_CLI_HOME when set, as gemini reads it.
21
+ * The seat folder may be gone already (a run cut short): clean matches its path as written, and
22
+ * sweep finds every <base>/ralph-qa.<id>/gemini record whose folder is gone. A run still going
23
+ * keeps its folder, so sweep leaves its records. On Windows the paths are compared by their
24
+ * long names (TEMP may be spelt with an 8.3 short one).
25
+ * Exit 2 on a usage error, or for check, a seat folder that does not exist.
26
+ */
27
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { basename, dirname, join, resolve, sep } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ export const POLICY = fileURLToPath(new URL("./gemini-read-only.toml", import.meta.url));
33
+
34
+ // The policy's rules as gemini reads them, comments and blank lines aside: one rule, every tool
35
+ // denied. Any other rule could open a tool to the seat.
36
+ const POLICY_LINES = ["[[rule]]", 'toolName = "*"', 'decision = "deny"', "priority = 100"];
37
+
38
+ // ASCII only, and only spaces and tabs around a rule: gemini's TOML parser drops a file with a
39
+ // BOM or a no-break space, and then opens every tool, while trim() would let one through.
40
+ export function policyIntact(text) {
41
+ if (typeof text !== "string" || /[^\t\r\n\x20-\x7e]/.test(text)) return false;
42
+ const lines = text.split(/\r?\n/).map((l) => l.replace(/#.*/, "").replace(/^[ \t]+|[ \t\r]+$/g, "")).filter(Boolean);
43
+ return lines.length === POLICY_LINES.length && lines.every((l, i) => l === POLICY_LINES[i]);
44
+ }
45
+
46
+ // realpath as the OS gives it: on Windows that spells an 8.3 short name (C:\Users\RUNNER~1) long,
47
+ // as a home folder is. A path that does not exist stays as written.
48
+ const OS_REALPATH = process.platform === "win32" ? realpathSync.native : realpathSync;
49
+ const realWith = (realpath) => (p) => {
50
+ try {
51
+ return realpath(p);
52
+ } catch {
53
+ return resolve(p);
54
+ }
55
+ };
56
+ const real = realWith(OS_REALPATH);
57
+ const norm = (p, platform) => (platform === "win32" ? resolve(p).toLowerCase() : resolve(p));
58
+ const geminiHome = (env) => env.GEMINI_CLI_HOME || homedir();
59
+
60
+ function inside(dir, root, platform) {
61
+ const d = norm(dir, platform);
62
+ const r = norm(root, platform);
63
+ return d === r || d.startsWith(r.endsWith(sep) ? r : r + sep);
64
+ }
65
+
66
+ // The .env files gemini could read for a run in `seat` that sit in folders above it outside the
67
+ // home ($GEMINI_CLI_HOME and the account's home both count as the home).
68
+ export function strayEnvFiles(seat, { env = process.env, platform = process.platform, exists = existsSync, realpath = OS_REALPATH } = {}) {
69
+ const longName = realWith(realpath);
70
+ const homes = [geminiHome(env), homedir()].map(longName);
71
+ const found = [];
72
+ for (let dir = longName(seat); ; dir = dirname(dir)) {
73
+ if (!homes.some((h) => inside(dir, h, platform))) {
74
+ found.push(...[join(dir, ".gemini", ".env"), join(dir, ".env")].filter((f) => exists(f)));
75
+ }
76
+ if (dirname(dir) === dir) return found;
77
+ }
78
+ }
79
+
80
+ const listDir = (dir) => {
81
+ try {
82
+ return readdirSync(dir);
83
+ } catch {
84
+ return [];
85
+ }
86
+ };
87
+
88
+ // The folder a session folder's .project_root names, or null for a link, a file or no marker.
89
+ function projectRoot(dir) {
90
+ try {
91
+ if (!lstatSync(dir).isDirectory()) return null;
92
+ return readFileSync(join(dir, ".project_root"), "utf8").trim();
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ // Each folder gemini keeps under tmp/ or history/, here and under .cache/.gemini, with the
99
+ // folder its .project_root names.
100
+ function projectFolders(env) {
101
+ const home = geminiHome(env);
102
+ const bases = [join(home, ".gemini"), join(home, ".cache", ".gemini")].flatMap((g) => [join(g, "tmp"), join(g, "history")]);
103
+ return bases.flatMap((base) => listDir(base).map((name) => join(base, name)))
104
+ .map((dir) => ({ dir, owner: projectRoot(dir) })).filter((p) => p.owner);
105
+ }
106
+
107
+ // The folders gemini keeps for sessions run in `seat`, matched by the path as written and as
108
+ // resolved; the seat folder may be gone already.
109
+ export function sessionFolders(seat, { env = process.env, platform = process.platform } = {}) {
110
+ const want = new Set([norm(seat, platform), norm(real(seat), platform)]);
111
+ return projectFolders(env).filter((p) => want.has(norm(p.owner, platform))).map((p) => p.dir);
112
+ }
113
+
114
+ // The session folders of seat folders under `base` that are gone: .project_root names
115
+ // <base>/ralph-qa.<id>/gemini and that folder no longer exists (a run cut short before its clean).
116
+ // A run still going has its seat folder, so its records stay.
117
+ const SEAT_PARENT = /^ralph-qa\.[A-Za-z0-9]+$/;
118
+ export function staleSessionFolders(base, { env = process.env, platform = process.platform, exists = existsSync } = {}) {
119
+ const roots = new Set([norm(base, platform), norm(real(base), platform)]);
120
+ const stale = ({ owner }) => {
121
+ const o = norm(owner, platform);
122
+ return basename(o) === "gemini" && SEAT_PARENT.test(basename(dirname(o))) && roots.has(dirname(dirname(o))) && !exists(owner);
123
+ };
124
+ return projectFolders(env).filter(stale).map((p) => p.dir);
125
+ }
126
+
127
+ function check(seat) {
128
+ let text = null;
129
+ try {
130
+ text = readFileSync(POLICY, "utf8");
131
+ } catch {
132
+ // reported as not intact below
133
+ }
134
+ if (!policyIntact(text)) {
135
+ process.stderr.write(`gemini-seat: ${POLICY} is missing or is not the shipped deny-all policy; the seat would run with its tools open\n`);
136
+ return 1;
137
+ }
138
+ const stray = strayEnvFiles(seat);
139
+ if (stray.length) {
140
+ process.stderr.write(`gemini-seat: gemini would read ${stray.join(", ")} (above the seat folder, outside the home); remove it or make the seat folder elsewhere\n`);
141
+ return 1;
142
+ }
143
+ process.stdout.write(POLICY + "\n");
144
+ return 0;
145
+ }
146
+
147
+ function remove(dirs) {
148
+ for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
149
+ process.stdout.write(JSON.stringify({ removed: dirs }) + "\n");
150
+ return 0;
151
+ }
152
+
153
+ function main(argv) {
154
+ const [cmd, folder, ...rest] = argv;
155
+ if (!["check", "clean", "sweep"].includes(cmd) || !folder || rest.length) {
156
+ process.stderr.write("usage: node gemini-seat.mjs check|clean <seat folder> | sweep <base folder>\n");
157
+ return 2;
158
+ }
159
+ if (cmd === "check" && !existsSync(folder)) {
160
+ process.stderr.write(`gemini-seat: no such folder: ${folder}\n`);
161
+ return 2;
162
+ }
163
+ if (cmd === "check") return check(folder);
164
+ return remove(cmd === "clean" ? sessionFolders(folder) : staleSessionFolders(folder));
165
+ }
166
+
167
+ const invokedDirectly = () => {
168
+ try {
169
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
170
+ } catch {
171
+ return false;
172
+ }
173
+ };
174
+
175
+ if (invokedDirectly()) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * payload-scan: secret-like strings in a file that is about to go to an external CLI seat.
4
+ *
5
+ * node payload-scan.mjs <file>
6
+ *
7
+ * Prints {"file", "count", "hits": [{"line", "kind"}]} and exits 0 when nothing was found,
8
+ * 1 when something was, 2 when the file cannot be read or the arguments are not one file. A
9
+ * private key hit also carries `end`, the line of its END marker (null when there is none):
10
+ * the whole block is the secret, not just the BEGIN line. The matched text itself is never
11
+ * printed, so the report cannot become the leak. The patterns cover common key and token
12
+ * formats, private key blocks (PGP too), secret assignments (quoted, or unquoted with letters
13
+ * and digits), passwords (quoted, with a symbol, alone on a config or env-file line, or after a
14
+ * Dockerfile `ENV KEY value`), secrets given as command-line flags, curl's user and password flag,
15
+ * hex keys, Basic and Bearer credentials, credentials in URLs and
16
+ * long high-entropy strings (a lockfile's sha512- integrity hash aside); they do not prove a
17
+ * payload clean. Every repeat and lookahead is
18
+ * bounded or anchored, so a long line takes linear time.
19
+ */
20
+ import { readFileSync, realpathSync } from "node:fs";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const SECRET_KEY = String.raw`(?:api[_-]?key|secret|token|passw(?:or)?d|pwd|access[_-]?key|credential|비밀번호|패스워드)[\w-]{0,64}["']?\s*[:=]\s*`;
24
+ // `pass` and `passphrase` count only as a word of their own (DB_PASS, "pass"), not inside bypass.
25
+ const PASS_WORD = String.raw`(?:passw(?:or)?d|pwd|passcode|(?<![a-z])pass(?:phrase)?(?![a-z]))`;
26
+ const PASSWORD_KEY = String.raw`(?:${PASS_WORD}|비밀번호|패스워드)[\w-]{0,64}["']?\s*[:=]\s*`;
27
+ // An unquoted value that is not code: none of the characters an expression or a reference
28
+ // holds, and at least one symbol (an identifier has none).
29
+ const BARE = String.raw`[^\s;,"'().\[\]{}$<>]`;
30
+ const PRIVATE_KEY = String.raw`(?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----`;
31
+
32
+ export const PATTERNS = [
33
+ ["aws-access-key", /(?<![A-Z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])/],
34
+ ["github-token", /(?<![A-Za-z0-9])(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/],
35
+ ["gitlab-token", /(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,}/],
36
+ ["anthropic-key", /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,}/],
37
+ ["openai-key", /(?<![A-Za-z0-9-])sk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/],
38
+ ["stripe-key", /(?<![A-Za-z0-9])[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/],
39
+ ["npm-token", /(?<![A-Za-z0-9])npm_[A-Za-z0-9]{36}/],
40
+ ["huggingface-token", /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{30,}/],
41
+ ["google-api-key", /(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}/],
42
+ ["slack-token", /(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}/],
43
+ ["slack-webhook", /hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/],
44
+ ["private-key", new RegExp(`-----BEGIN ${PRIVATE_KEY}`)],
45
+ ["jwt", /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/],
46
+ ["bearer-token", /\bbearer\s+[A-Za-z0-9._~+/-]{20,}/i],
47
+ ["basic-auth", /\bauthorization["']?\s*[:=]\s*["']?basic\s+[A-Za-z0-9+/=]{8,}/i],
48
+ ["url-credentials", /[a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/]{0,256}:[^\s@/]{3,256}@/i],
49
+ // quoted: 12 characters or more, or 8 to 11 holding a digit (a word like "development" is not one)
50
+ ["assigned-secret", new RegExp(SECRET_KEY + String.raw`["'](?:[^"'\s]{12,256}|(?=[^"'\s]{0,10}\d)[^"'\s]{8,11})["']`, "i")],
51
+ ["assigned-secret-bare", new RegExp(SECRET_KEY + String.raw`(?=[^\s"']{0,256}\d)(?=[^\s"']{0,256}[A-Za-z])[^\s"'(\[{]{12,256}`, "i")],
52
+ ["assigned-password", new RegExp(PASSWORD_KEY + String.raw`(?:["'][^"'\s]{6,256}["']|(?=${BARE}{0,256}[^\w\s;,"'().\[\]{}$<>])${BARE}{6,256})`, "i")],
53
+ ["env-password", /^[+\- ]?\s*(?:export\s+)?[A-Za-z0-9_]{0,64}(?:passw(?:or)?d|pwd|passcode)[A-Za-z0-9_]{0,64}=[^\s$"'][^\s"']{5,256}/i],
54
+ // a password alone on a config line: YAML, compose lists, .properties, Dockerfile ENV, diff marks
55
+ ["config-password", new RegExp(String.raw`^[+\- ]?\s*(?:-\s+|export\s+|ENV\s+)?[\w.-]{0,64}${PASS_WORD}[\w.-]{0,64}["']?\s*[:=]\s*["']?(?![-$<{%])[^\s"',;()\[\]{}]{6,256}["']?\s*(?:#.*)?$`, "i")],
56
+ // a Dockerfile's legacy `ENV KEY value` form, the value alone after the space
57
+ ["dockerfile-env-password", new RegExp(String.raw`^[+\- ]?\s*ENV\s+[\w.-]{0,64}${PASS_WORD}[\w.-]{0,64}\s+["']?(?![-$<{%])[^\s"',;()\[\]{}=]{6,256}["']?\s*(?:#.*)?$`, "i")],
58
+ ["flag-secret", /--(?:password|passwd|pass|token|api-key|apikey|secret|client-secret)(?:=|\s+)(?![-$])[^\s"']{6,256}/i],
59
+ // curl's -u flag with a user and password pair, glued to it or not; a uid:gid (docker) or a
60
+ // host:/path (rsync) is no credential
61
+ ["curl-user", /(?<!\S)(?:-u\s*|--user(?:\s+|=))(?!\d{1,10}:\d{1,10}\b(?![:@]))[^\s:=]{1,256}:(?!\/)\S{3,256}/],
62
+ ["assigned-hex", /(?:key|auth|token|secret)[\w-]{0,64}["']?\s*[:=]\s*["']?[0-9a-f]{32,}(?![0-9a-z])/i],
63
+ ];
64
+
65
+ // Long runs of base64-like characters whose Shannon entropy is that of random data, as
66
+ // lineage's redaction measures them (32 characters or more, 4.5 bits per character or more).
67
+ const LONG_RUN = /[A-Za-z0-9+/=]{32,}/g;
68
+ const ENTROPY_FLOOR = 4.5;
69
+
70
+ export function entropy(s) {
71
+ const counts = new Map();
72
+ for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
73
+ let bits = 0;
74
+ for (const n of counts.values()) bits -= (n / s.length) * Math.log2(n / s.length);
75
+ return bits;
76
+ }
77
+
78
+ // A subresource-integrity hash (`sha512-<base64>`, as lockfiles hold) is a public checksum.
79
+ const SRI_PREFIX = /sha(?:256|384|512)-$/;
80
+ const highEntropy = (line) => [...line.matchAll(LONG_RUN)]
81
+ .some((m) => !SRI_PREFIX.test(line.slice(Math.max(0, m.index - 7), m.index)) && entropy(m[0]) >= ENTROPY_FLOOR);
82
+
83
+ const KEY_END = new RegExp(`-----END ${PRIVATE_KEY}`);
84
+
85
+ // Each line's secret-like kinds, once per kind and line. A private key closes at the first END
86
+ // marker at or after its line: the END lines are found once and walked with the lines, so many
87
+ // BEGIN lines without an END still take linear time.
88
+ export function scan(text) {
89
+ const hits = [];
90
+ const lines = String(text).split(/\r?\n/);
91
+ const ends = lines.flatMap((l, i) => (KEY_END.test(l) ? [i + 1] : []));
92
+ let next = 0;
93
+ lines.forEach((line, i) => {
94
+ while (next < ends.length && ends[next] < i + 1) next += 1;
95
+ for (const [kind, re] of PATTERNS) {
96
+ if (!re.test(line)) continue;
97
+ hits.push(kind === "private-key" ? { line: i + 1, kind, end: ends[next] ?? null } : { line: i + 1, kind });
98
+ }
99
+ if (highEntropy(line)) hits.push({ line: i + 1, kind: "high-entropy" });
100
+ });
101
+ return { count: hits.length, hits };
102
+ }
103
+
104
+ function main(argv) {
105
+ if (argv.length !== 1) {
106
+ process.stderr.write("usage: node payload-scan.mjs <file> (exactly one file)\n");
107
+ return 2;
108
+ }
109
+ const file = argv[0];
110
+ let text;
111
+ try {
112
+ text = readFileSync(file, "utf8");
113
+ } catch (e) {
114
+ process.stderr.write(`payload-scan: cannot read ${file}: ${e.code ?? e.message}\n`);
115
+ return 2;
116
+ }
117
+ const result = scan(text);
118
+ process.stdout.write(JSON.stringify({ file, ...result }, null, 2) + "\n");
119
+ return result.count === 0 ? 0 : 1;
120
+ }
121
+
122
+ const invokedDirectly = () => {
123
+ try {
124
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
125
+ } catch {
126
+ return false;
127
+ }
128
+ };
129
+
130
+ if (invokedDirectly()) process.exitCode = main(process.argv.slice(2));