@kaydash9999/banker-plugins 0.23.3 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +56 -0
- package/README.md +14 -6
- package/codex/manifest.json +6 -2
- package/codex/skills/teamwork/scripts/teamwork.mjs +3 -3
- package/codex/transform-matrix.md +6 -2
- package/package.json +1 -1
- package/skills/build-testbox/SKILL.md +213 -0
- package/skills/build-testbox/scripts/args.mjs +156 -0
- package/skills/build-testbox/scripts/browser-cell.mjs +181 -0
- package/skills/build-testbox/scripts/browsers.mjs +164 -0
- package/skills/build-testbox/scripts/containers.mjs +237 -0
- package/skills/build-testbox/scripts/detect.mjs +166 -0
- package/skills/build-testbox/scripts/proc.mjs +91 -0
- package/skills/build-testbox/scripts/report.mjs +109 -0
- package/skills/build-testbox/scripts/setup.mjs +144 -0
- package/skills/build-testbox/scripts/testbox.mjs +148 -0
- package/skills/git-init/SKILL.md +152 -0
- package/skills/git-init/scripts/git-init.mjs +440 -0
- package/skills/git-init/scripts/marker-block.mjs +321 -0
- package/skills/git-init/scripts/payload-scan.mjs +186 -0
- package/skills/init-dir/SKILL.md +172 -0
- package/skills/init-dir/scripts/init-dir.mjs +308 -0
- package/skills/init-dir/scripts/marker-block.mjs +321 -0
- package/skills/init-dir/scripts/templates.mjs +100 -0
- package/skills/init-dir/templates/KB/README.md +15 -0
- package/skills/init-dir/templates/docs/README.md +14 -0
- package/skills/init-dir/templates/logs/README.md +14 -0
- package/skills/init-dir/templates/logs/gitignore +3 -0
- package/skills/init-dir/templates/mockup/README.md +7 -0
- package/skills/init-dir/templates/scripts/README.md +39 -0
- package/skills/init-dir/templates/scripts/lib/service.mjs +596 -0
- package/skills/init-dir/templates/scripts/restart.cmd +13 -0
- package/skills/init-dir/templates/scripts/restart.sh +10 -0
- package/skills/init-dir/templates/scripts/service.conf +5 -0
- package/skills/init-dir/templates/scripts/start.cmd +13 -0
- package/skills/init-dir/templates/scripts/start.sh +10 -0
- package/skills/init-dir/templates/scripts/status.cmd +13 -0
- package/skills/init-dir/templates/scripts/status.sh +10 -0
- package/skills/init-dir/templates/scripts/stop.cmd +13 -0
- package/skills/init-dir/templates/scripts/stop.sh +10 -0
- package/skills/init-dir/templates/tests/README.md +15 -0
- package/skills/init-dir/templates/tests/run.cmd +13 -0
- package/skills/init-dir/templates/tests/run.mjs +160 -0
- package/skills/init-dir/templates/tests/run.sh +10 -0
- package/skills/init-dir/templates/tests/tests.conf +7 -0
- package/skills/init-dir/templates/work/README.md +8 -0
- package/skills/progress-mon/README.md +11 -8
- package/skills/progress-mon/SKILL.md +53 -16
- package/skills/progress-mon/assets/fixture/README.md +1 -1
- package/skills/progress-mon/assets/fixture/adr.json +35 -0
- package/skills/progress-mon/assets/fixture/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/flow.json +2 -2
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/variants/legacy-init/rules.json +6 -0
- package/skills/progress-mon/assets/fixture/variants/legacy-init/state.json +5 -0
- package/skills/progress-mon/references/adr.md +184 -0
- package/skills/progress-mon/references/authoring.md +13 -5
- package/skills/progress-mon/scripts/pm-adr-view.mjs +101 -0
- package/skills/progress-mon/scripts/pm-adr.mjs +515 -0
- package/skills/progress-mon/scripts/pm-check.mjs +59 -22
- package/skills/progress-mon/scripts/pm-data.mjs +1 -1
- package/skills/progress-mon/scripts/pm-html.mjs +1 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +113 -28
- package/skills/progress-mon/scripts/pm-render.mjs +6 -4
- package/skills/progress-mon/scripts/pm-server.mjs +2 -1
- package/skills/progress-mon/scripts/pm-sources.mjs +10 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +3 -2
- package/skills/tune-tmux/SKILL.md +235 -0
- package/skills/tune-tmux/scripts/app-notes.mjs +38 -0
- package/skills/tune-tmux/scripts/marker-block.mjs +321 -0
- package/skills/tune-tmux/scripts/psmux-caps.json +71 -0
- package/skills/tune-tmux/scripts/tmux-config.mjs +251 -0
- package/skills/tune-tmux/scripts/tune-tmux.mjs +284 -0
- package/skills/ultra-chain/SKILL.md +52 -22
- package/skills/ultra-chain/references/chain-ledger.mjs +93 -11
- package/skills/ultra-chain/references/chain-report.mjs +13 -2
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* marker-block: puts one marked block of lines into a text file, replaces it or takes it out, and leaves every
|
|
3
|
+
* byte outside the block as it was.
|
|
4
|
+
*
|
|
5
|
+
* The canonical copy is skills/init-dir/scripts/marker-block.mjs. tune-tmux and git-init carry byte-identical
|
|
6
|
+
* copies, since Codex installs every skill folder on its own; scripts/sync-adapter.js mirrors and checks them.
|
|
7
|
+
* Edit the canonical copy only.
|
|
8
|
+
*
|
|
9
|
+
* Policy (one place for every skill that edits a user's file):
|
|
10
|
+
* - Markers are whole lines `<open> <id>:start <close>` and `<open> <id>:end <close>`, with the comment syntax
|
|
11
|
+
* given (`#` or `<!-- -->`). A marker inside a sentence, an unpaired marker, an end before a start or a second
|
|
12
|
+
* block is refused: nothing is cut on a guess.
|
|
13
|
+
* - Bytes outside the block stay as they were. CRLF files get CRLF blocks, a byte order mark stays in front.
|
|
14
|
+
* A file that is not UTF-8 is refused.
|
|
15
|
+
* - A folder, FIFO, socket or device at the name is refused without being read.
|
|
16
|
+
* - A symbolic link is refused (refused: "symlink") unless followSymlink is set; then the file it leads to is
|
|
17
|
+
* written and the link stays a link. A link to nothing is refused either way ("dangling").
|
|
18
|
+
* - A file with two or more hard links is refused ("hardlink"): the rename below would split them.
|
|
19
|
+
* - A file owned by another user is refused ("owner"). The permission bits of the file carry over to the new
|
|
20
|
+
* file. Windows has no owner or permission bits to check.
|
|
21
|
+
* - Writes go to a temp file in the same folder and a rename. A failed write removes the temp file.
|
|
22
|
+
* - When the bytes change, the first original is kept in `<file>.<skill>.bak`; an existing backup is never
|
|
23
|
+
* overwritten.
|
|
24
|
+
*
|
|
25
|
+
* Results are { ok, changed, refused?, message, path, file }. Callers map refused to exit status 1.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
chmodSync,
|
|
29
|
+
closeSync,
|
|
30
|
+
constants,
|
|
31
|
+
fstatSync,
|
|
32
|
+
lstatSync,
|
|
33
|
+
openSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
readlinkSync,
|
|
36
|
+
realpathSync,
|
|
37
|
+
renameSync,
|
|
38
|
+
rmSync,
|
|
39
|
+
statSync,
|
|
40
|
+
writeFileSync,
|
|
41
|
+
} from "node:fs";
|
|
42
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
43
|
+
|
|
44
|
+
/** Comment syntaxes. */
|
|
45
|
+
export const HTML = Object.freeze({ open: "<!--", close: "-->" });
|
|
46
|
+
export const HASH = Object.freeze({ open: "#", close: "" });
|
|
47
|
+
|
|
48
|
+
const BOM = "\u{FEFF}";
|
|
49
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
50
|
+
const MAX_BYTES = 32 * 1024 * 1024;
|
|
51
|
+
const NEW_FILE_MODE = 0o644;
|
|
52
|
+
const BACKUP_MODE = 0o600;
|
|
53
|
+
|
|
54
|
+
/** The start and end marker lines for id in a comment syntax. */
|
|
55
|
+
export function markers(comment, id) {
|
|
56
|
+
const tail = comment.close ? ` ${comment.close}` : "";
|
|
57
|
+
return { start: `${comment.open} ${id}:start${tail}`, end: `${comment.open} ${id}:end${tail}` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const refusal = (code, message, path) => ({ ok: false, changed: false, refused: code, message, path });
|
|
61
|
+
const bare = (line) => line.replace(/\r?\n$/, "").replace(/[ \t]+$/, "");
|
|
62
|
+
const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
|
|
63
|
+
const readlinkOr = (path) => {
|
|
64
|
+
try {
|
|
65
|
+
return readlinkSync(path);
|
|
66
|
+
} catch {
|
|
67
|
+
return "?";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const nativeReal = (path) => {
|
|
71
|
+
try {
|
|
72
|
+
return realpathSync.native(path);
|
|
73
|
+
} catch {
|
|
74
|
+
return resolve(path);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const realOrNull = (path) => {
|
|
78
|
+
try {
|
|
79
|
+
return realpathSync(path);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Where the bytes live: the path itself, or with followSymlink the file a link leads to.
|
|
86
|
+
function locate(path, follow) {
|
|
87
|
+
const lst = lstatSync(path, { throwIfNoEntry: false });
|
|
88
|
+
if (!lst) return { file: path, exists: false };
|
|
89
|
+
if (!lst.isSymbolicLink()) return { file: path, exists: true };
|
|
90
|
+
const target = readlinkOr(path);
|
|
91
|
+
if (!follow) return { refuse: refusal("symlink", `심볼릭 링크라 그대로 둠: ${path} -> ${target}`, path) };
|
|
92
|
+
const real = realOrNull(path);
|
|
93
|
+
if (real === null) return { refuse: refusal("dangling", `끊어진 링크라 그대로 둠: ${path} -> ${target}`, path) };
|
|
94
|
+
return { file: real, exists: true, link: path };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Why the file at file may not be read or replaced, or null.
|
|
98
|
+
function fileProblem(st, file, uid) {
|
|
99
|
+
if (!st.isFile()) return refusal("special", `일반 파일이 아니라 그대로 둠: ${file}`, file);
|
|
100
|
+
if (st.nlink > 1) return refusal("hardlink", `하드 링크가 ${st.nlink}개라 그대로 둠: ${file}`, file);
|
|
101
|
+
if (uid !== undefined && st.uid !== uid) return refusal("owner", `다른 사용자 소유라 그대로 둠: ${file}`, file);
|
|
102
|
+
if (st.size > MAX_BYTES) return refusal("too-big", `32MiB 보다 커서 그대로 둠: ${file}`, file);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The file's stat and bytes, opened without blocking so a FIFO planted after the look never waits. id: the same
|
|
107
|
+
// file's bigint stat for telling files apart (an NTFS file id passes 2^53, so as a Number two files can share one).
|
|
108
|
+
function readChecked(file, uid) {
|
|
109
|
+
const first = statSync(file);
|
|
110
|
+
const problem = fileProblem(first, file, uid);
|
|
111
|
+
if (problem) return { refuse: problem };
|
|
112
|
+
const fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
113
|
+
try {
|
|
114
|
+
const st = fstatSync(fd);
|
|
115
|
+
const late = fileProblem(st, file, uid);
|
|
116
|
+
return late ? { refuse: late } : { st, id: fstatSync(fd, { bigint: true }), bytes: readFileSync(fd) };
|
|
117
|
+
} finally {
|
|
118
|
+
closeSync(fd);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// What one line is: "start", "end", "inline" (a marker inside other text) or null.
|
|
123
|
+
function kindOf(line, mk) {
|
|
124
|
+
const b = bare(line);
|
|
125
|
+
if (b === mk.start) return "start";
|
|
126
|
+
if (b === mk.end) return "end";
|
|
127
|
+
return line.includes(mk.start) || line.includes(mk.end) ? "inline" : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Takes line i of kind into state; returns why the markers are broken, or null.
|
|
131
|
+
function step(state, kind, i) {
|
|
132
|
+
if (kind === "inline") return `문장 안에 표지가 있음(${i + 1}행)`;
|
|
133
|
+
if (kind === "start") {
|
|
134
|
+
if (state.open !== -1 || state.found) return `표지 짝이 맞지 않거나 블록이 둘 이상(${i + 1}행)`;
|
|
135
|
+
state.open = i;
|
|
136
|
+
} else if (kind === "end") {
|
|
137
|
+
if (state.open === -1) return `끝 표지가 시작 표지보다 먼저(${i + 1}행)`;
|
|
138
|
+
state.found = { first: state.open, last: i };
|
|
139
|
+
state.open = -1;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The block's first and last line in lines, null when there is none, or { error } when markers are broken. */
|
|
145
|
+
export function findBlock(lines, mk) {
|
|
146
|
+
const state = { open: -1, found: null };
|
|
147
|
+
for (let i = 0; i < lines.length; i++) {
|
|
148
|
+
const error = step(state, kindOf(lines[i], mk), i);
|
|
149
|
+
if (error) return { error };
|
|
150
|
+
}
|
|
151
|
+
if (state.open !== -1) return { error: `짝 없는 시작 표지(${state.open + 1}행)` };
|
|
152
|
+
return state.found;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Whether line i sits inside an open wrap region (more wrap opens than closes before it).
|
|
156
|
+
function insideWrap(lines, i, wrap) {
|
|
157
|
+
let depth = 0;
|
|
158
|
+
for (let j = 0; j < i; j++) {
|
|
159
|
+
const b = bare(lines[j]);
|
|
160
|
+
if (b === wrap.open) depth++;
|
|
161
|
+
else if (b === wrap.close && depth > 0) depth--;
|
|
162
|
+
}
|
|
163
|
+
return depth > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The span [first, last] widened to wrap lines directly around it, and whether new text must bring its own wrap.
|
|
167
|
+
function spanOf(lines, found, wrap) {
|
|
168
|
+
if (!wrap) return { ...found, wrapNew: false };
|
|
169
|
+
const wrapped = found.first > 0 && bare(lines[found.first - 1]) === wrap.open && bare(lines[found.last + 1] ?? "") === wrap.close;
|
|
170
|
+
if (wrapped) return { first: found.first - 1, last: found.last + 1, wrapNew: true };
|
|
171
|
+
return { ...found, wrapNew: !insideWrap(lines, found.first, wrap) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const blockText = (mk, body, wrap, eol) => {
|
|
175
|
+
const inner = [mk.start, ...body.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"), mk.end];
|
|
176
|
+
return [...(wrap ? [wrap.open, ...inner, wrap.close] : inner), ""].join(eol);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// The text with the block put in, replaced or (body null) taken out. Appending puts one line break between the
|
|
180
|
+
// text and the block; taking a block out at the end of the file takes that break back, so add then remove gives
|
|
181
|
+
// the bytes from before.
|
|
182
|
+
function render(text, found, o) {
|
|
183
|
+
const lines = text.split(/(?<=\n)/);
|
|
184
|
+
const eol = eolOf(text);
|
|
185
|
+
if (!found) {
|
|
186
|
+
if (o.body === null) return text;
|
|
187
|
+
return text === "" ? blockText(o.mk, o.body, o.wrap, eol) : text + eol + blockText(o.mk, o.body, o.wrap, eol);
|
|
188
|
+
}
|
|
189
|
+
const span = spanOf(lines, found, o.wrap);
|
|
190
|
+
const before = lines.slice(0, span.first).join("");
|
|
191
|
+
const after = lines.slice(span.last + 1).join("");
|
|
192
|
+
if (o.body !== null) return before + blockText(o.mk, o.body, span.wrapNew ? o.wrap : null, eol) + after;
|
|
193
|
+
return after === "" ? before.replace(/\r?\n$/, "") : before + after;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkBody(o) {
|
|
197
|
+
if (o.body === null) return;
|
|
198
|
+
const forbidden = [o.mk.start, o.mk.end, ...(o.wrap ? [o.wrap.open, o.wrap.close] : [])];
|
|
199
|
+
if (o.body.split(/\r?\n/).some((line) => forbidden.includes(bare(line)))) throw new Error("block body holds a marker line");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The new bytes for the old ones, or { refuse } when the old ones may not be edited.
|
|
203
|
+
function nextBytes(bytes, o, file) {
|
|
204
|
+
let text;
|
|
205
|
+
try {
|
|
206
|
+
text = UTF8.decode(bytes);
|
|
207
|
+
} catch {
|
|
208
|
+
return { refuse: refusal("encoding", `UTF-8 이 아니라 그대로 둠: ${file}`, file) };
|
|
209
|
+
}
|
|
210
|
+
const bom = text.startsWith(BOM) ? BOM : "";
|
|
211
|
+
const plain = text.slice(bom.length);
|
|
212
|
+
const found = findBlock(plain.split(/(?<=\n)/), o.mk);
|
|
213
|
+
if (found?.error) return { refuse: refusal("markers", `표지 손상이라 그대로 둠: ${file}: ${found.error}`, file) };
|
|
214
|
+
return { bytes: Buffer.from(bom + render(plain, found, o), "utf8") };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const options = (opts) => ({
|
|
218
|
+
mk: markers(opts.comment, opts.id),
|
|
219
|
+
body: opts.body ?? null,
|
|
220
|
+
wrap: opts.wrap ?? null,
|
|
221
|
+
skill: opts.skill,
|
|
222
|
+
follow: opts.followSymlink === true,
|
|
223
|
+
uid: "uid" in opts ? opts.uid : process.getuid?.(),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Works out what applyBlock would write, without writing: { ok, changed, refused?, message, path, file, key,
|
|
228
|
+
* before, after, mode }. key names the file (device and inode, or the path of a file not there yet).
|
|
229
|
+
*/
|
|
230
|
+
export function planBlock(path, opts) {
|
|
231
|
+
const o = options(opts);
|
|
232
|
+
checkBody(o);
|
|
233
|
+
const at = locate(path, o.follow);
|
|
234
|
+
if (at.refuse) return at.refuse;
|
|
235
|
+
if (!at.exists) {
|
|
236
|
+
const after = o.body === null ? null : Buffer.from(blockText(o.mk, o.body, o.wrap, "\n"), "utf8");
|
|
237
|
+
return { ok: true, changed: after !== null, path, file: at.file, key: `path:${resolve(at.file)}`, before: null, after, mode: NEW_FILE_MODE };
|
|
238
|
+
}
|
|
239
|
+
const read = readChecked(at.file, o.uid);
|
|
240
|
+
if (read.refuse) return read.refuse;
|
|
241
|
+
const next = nextBytes(read.bytes, o, at.file);
|
|
242
|
+
if (next.refuse) return next.refuse;
|
|
243
|
+
const changed = !next.bytes.equals(read.bytes);
|
|
244
|
+
// ino 0 (a file system without file ids) tells nothing apart: then the path as the file system spells it is the
|
|
245
|
+
// key (realpathSync.native gives one spelling for names that differ only in case where case is ignored).
|
|
246
|
+
const key = read.id.ino === 0n ? `path:${nativeReal(at.file)}` : `ino:${read.id.dev}:${read.id.ino}`;
|
|
247
|
+
return { ok: true, changed, path, file: at.file, key, before: read.bytes, after: next.bytes, mode: read.st.mode & 0o7777 };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Writes data to a fresh temp file beside file and renames it over file.
|
|
251
|
+
function writeAtomic(file, data, mode) {
|
|
252
|
+
const tmp = join(dirname(file), `.${basename(file)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
253
|
+
let fd = openSync(tmp, "wx", mode);
|
|
254
|
+
try {
|
|
255
|
+
writeFileSync(fd, data);
|
|
256
|
+
closeSync(fd);
|
|
257
|
+
fd = null;
|
|
258
|
+
if (process.platform !== "win32") chmodSync(tmp, mode);
|
|
259
|
+
renameSync(tmp, file);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (fd !== null) closeSync(fd);
|
|
262
|
+
rmSync(tmp, { force: true });
|
|
263
|
+
throw e;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Keeps the first original: writes <file>.<skill>.bak only when nothing is at that name yet.
|
|
268
|
+
function backup(file, skill, bytes) {
|
|
269
|
+
const bak = `${file}.${skill}.bak`;
|
|
270
|
+
if (lstatSync(bak, { throwIfNoEntry: false })) return null;
|
|
271
|
+
const fd = openSync(bak, "wx", BACKUP_MODE);
|
|
272
|
+
try {
|
|
273
|
+
writeFileSync(fd, bytes);
|
|
274
|
+
} finally {
|
|
275
|
+
closeSync(fd);
|
|
276
|
+
}
|
|
277
|
+
return bak;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Writes what planBlock worked out. A plan that changes nothing or was refused is returned as a result. */
|
|
281
|
+
export function commitBlock(plan, skill) {
|
|
282
|
+
if (!plan.ok) return plan;
|
|
283
|
+
const result = { ok: true, changed: plan.changed, path: plan.path, file: plan.file };
|
|
284
|
+
if (!plan.changed) return { ...result, message: `변경 없음: ${plan.path}` };
|
|
285
|
+
if (plan.before === null && plan.after !== null) {
|
|
286
|
+
const fd = openSync(plan.file, "wx", plan.mode);
|
|
287
|
+
try {
|
|
288
|
+
writeFileSync(fd, plan.after);
|
|
289
|
+
} finally {
|
|
290
|
+
closeSync(fd);
|
|
291
|
+
}
|
|
292
|
+
return { ...result, created: true, message: `새로 만듦: ${plan.path}` };
|
|
293
|
+
}
|
|
294
|
+
const bak = backup(plan.file, skill, plan.before);
|
|
295
|
+
writeAtomic(plan.file, plan.after, plan.mode);
|
|
296
|
+
return { ...result, backup: bak, message: `바꿈: ${plan.path}` };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Puts in, replaces or (body null) takes out the block in one file. dryRun plans only. */
|
|
300
|
+
export function applyBlock(path, opts) {
|
|
301
|
+
const plan = planBlock(path, opts);
|
|
302
|
+
if (opts.dryRun || !plan.ok) return plan;
|
|
303
|
+
return commitBlock(plan, opts.skill);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Applies blocks to several files, each entry { path, ...opts }. Every file is planned first: one refusal and
|
|
308
|
+
* none is written. Two entries naming one file (after links are followed) write it once, by the first entry.
|
|
309
|
+
*/
|
|
310
|
+
export function applyBlocks(entries, { dryRun = false } = {}) {
|
|
311
|
+
const plans = entries.map((e) => ({ entry: e, plan: planBlock(e.path, e) }));
|
|
312
|
+
const refused = plans.filter((p) => !p.plan.ok).map((p) => p.plan);
|
|
313
|
+
if (refused.length > 0) return { ok: false, refused, results: plans.map((p) => p.plan) };
|
|
314
|
+
const seen = new Map();
|
|
315
|
+
const results = plans.map(({ entry, plan }) => {
|
|
316
|
+
if (seen.has(plan.key)) return { ok: true, changed: false, path: plan.path, file: plan.file, sameAs: seen.get(plan.key), message: `같은 파일이라 한 번만 씀: ${plan.path}` };
|
|
317
|
+
seen.set(plan.key, plan.path);
|
|
318
|
+
return dryRun ? plan : commitBlock(plan, entry.skill);
|
|
319
|
+
});
|
|
320
|
+
return { ok: true, refused: [], results };
|
|
321
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
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 scanned or the arguments are not one
|
|
9
|
+
* file. A private key hit also carries `end`, the line of its END marker (null when there is
|
|
10
|
+
* none): 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, a KEY=value
|
|
14
|
+
* word after a space, a quote, `(` or `=` as in `env`, `docker -e`, `--from-literal=`, or after a Dockerfile
|
|
15
|
+
* `ENV KEY value`; the shell's own PWD or OLDPWD holding a path is no password),
|
|
16
|
+
* secrets given as command-line flags, curl's user and password flag,
|
|
17
|
+
* hex keys, Basic and Bearer credentials, credentials in URLs, a .netrc password (on its machine line or on a line
|
|
18
|
+
* of its own), a .pgpass line, docker's config.json "auth" (base64 of user:password) and
|
|
19
|
+
* long high-entropy strings (a lockfile's sha512- integrity hash and a Go or Terraform h1: hash
|
|
20
|
+
* aside); they do not prove a payload clean. A Docker secret file's path after a *_FILE key is
|
|
21
|
+
* not read as the password it points to, and a value masked as a whole `[REDACTED]` word (the mask
|
|
22
|
+
* SKILL.md asks for) is no value, so a masked payload scans clean; a mask glued to the rest of a value is not. Every repeat and lookahead is bounded (a run longer than
|
|
23
|
+
* 65536 characters is read in pieces) or anchored, so a long line takes linear time and cannot
|
|
24
|
+
* overflow the regex stack.
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
const SECRET_KEY = String.raw`(?:api[_-]?key|secret|token|passw(?:or)?d|pwd|access[_-]?key|credential|비밀번호|패스워드)[\w-]{0,64}["']?\s*[:=]\s*`;
|
|
30
|
+
// `pass` and `passphrase` count only as a word of their own (DB_PASS, "pass"), not inside bypass.
|
|
31
|
+
const PASS_WORD = String.raw`(?:passw(?:or)?d|pwd|passcode|(?<![a-z])pass(?:phrase)?(?![a-z]))`;
|
|
32
|
+
// An env-style key that holds a password word, up to its `=`, and a value that is no reference or whole placeholder
|
|
33
|
+
// (`$X`, `<your-password>`, `{{ x }}`, `%(x)s`, `%X%`). A value that only starts with `<` or `{` is still read.
|
|
34
|
+
const ENV_KEY = String.raw`[A-Za-z0-9_]{0,64}(?:passw(?:or)?d|pwd|passcode)[A-Za-z0-9_]{0,64}=(?!-|\$|<[^>\s]{1,64}>|\{\{|%\(|%[A-Za-z_][A-Za-z0-9_]{0,64}%)`;
|
|
35
|
+
const PASSWORD_KEY = String.raw`(?:${PASS_WORD}|비밀번호|패스워드)[\w-]{0,64}["']?\s*[:=]\s*`;
|
|
36
|
+
// An unquoted value that is not code: none of the characters an expression or a reference
|
|
37
|
+
// holds, and at least one symbol (an identifier has none).
|
|
38
|
+
const BARE = String.raw`[^\s;,"'().\[\]{}$<>]`;
|
|
39
|
+
// at most 8 words before PRIVATE KEY (RSA, EC, OPENSSH, ENCRYPTED, PGP): an open nested repeat
|
|
40
|
+
// overflows V8's regex stack on a key marker before millions of words
|
|
41
|
+
const PRIVATE_KEY = String.raw`(?:[A-Z0-9]{1,32} ){0,8}PRIVATE KEY(?: BLOCK)?-----`;
|
|
42
|
+
|
|
43
|
+
export const PATTERNS = [
|
|
44
|
+
["aws-access-key", /(?<![A-Z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])/],
|
|
45
|
+
["github-token", /(?<![A-Za-z0-9])(?:gh[pousr]_[A-Za-z0-9]{36,65536}|github_pat_[A-Za-z0-9_]{22,65536})/],
|
|
46
|
+
["gitlab-token", /(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,65536}/],
|
|
47
|
+
["anthropic-key", /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,65536}/],
|
|
48
|
+
["openai-key", /(?<![A-Za-z0-9-])sk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,65536}/],
|
|
49
|
+
["stripe-key", /(?<![A-Za-z0-9])[rs]k_(?:live|test)_[A-Za-z0-9]{16,65536}/],
|
|
50
|
+
["npm-token", /(?<![A-Za-z0-9])npm_[A-Za-z0-9]{36}/],
|
|
51
|
+
["huggingface-token", /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{30,65536}/],
|
|
52
|
+
["google-api-key", /(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}/],
|
|
53
|
+
["slack-token", /(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,65536}/],
|
|
54
|
+
["slack-webhook", /hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/],
|
|
55
|
+
["private-key", new RegExp(`-----BEGIN ${PRIVATE_KEY}`)],
|
|
56
|
+
["jwt", /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,65536}\.eyJ[A-Za-z0-9_-]{10,65536}\.[A-Za-z0-9_-]{10,65536}/],
|
|
57
|
+
["bearer-token", /\bbearer\s+[A-Za-z0-9._~+/-]{20,65536}/i],
|
|
58
|
+
["basic-auth", /\bauthorization["']?\s*[:=]\s*["']?basic\s+[A-Za-z0-9+/=]{8,65536}/i],
|
|
59
|
+
["url-credentials", /[a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/]{0,256}:[^\s@/]{3,256}@/i],
|
|
60
|
+
// quoted: 12 characters or more, or 8 to 11 holding a digit (a word like "development" is not one)
|
|
61
|
+
["assigned-secret", new RegExp(SECRET_KEY + String.raw`["'](?:[^"'\s]{12,256}|(?=[^"'\s]{0,10}\d)[^"'\s]{8,11})["']`, "i")],
|
|
62
|
+
["assigned-secret-bare", new RegExp(SECRET_KEY + String.raw`(?=[^\s"']{0,256}\d)(?=[^\s"']{0,256}[A-Za-z])[^\s"'(\[{]{12,256}`, "i")],
|
|
63
|
+
["assigned-password", new RegExp(PASSWORD_KEY + String.raw`(?:["'][^"'\s]{6,256}["']|(?=${BARE}{0,256}[^\w\s;,"'().\[\]{}$<>])${BARE}{6,256})`, "i")],
|
|
64
|
+
// at the head of a line, or as a word after a space, a quote, `(` or `=` (env, docker -e, --from-literal=, a subshell),
|
|
65
|
+
// its value ending the word
|
|
66
|
+
["env-password", new RegExp(String.raw`^[+\- ]?\s*(?:export\s+)?${ENV_KEY}[^\s"']{6,256}|(?<=[\s"'(=])${ENV_KEY}[^\s"',;()\[\]{}]{6,256}(?=[\s"']|$)`, "i")],
|
|
67
|
+
// a password alone on a config line: YAML, compose lists, .properties, Dockerfile ENV, diff marks
|
|
68
|
+
["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")],
|
|
69
|
+
// a Dockerfile's legacy `ENV KEY value` form, the value alone after the space
|
|
70
|
+
["dockerfile-env-password", new RegExp(String.raw`^[+\- ]?\s*ENV\s+[\w.-]{0,64}${PASS_WORD}[\w.-]{0,64}\s+["']?(?![-$<{%])[^\s"',;()\[\]{}=]{6,256}["']?\s*(?:#.*)?$`, "i")],
|
|
71
|
+
["flag-secret", /--(?:password|passwd|pass|token|api-key|apikey|secret|client-secret)(?:=|\s+)(?![-$])[^\s"']{6,256}/i],
|
|
72
|
+
// curl's -u flag with a user and password pair, glued to it or not; a uid:gid (docker) that is
|
|
73
|
+
// the whole value, quoted or not, a host:/path (rsync), a date format (date -u +%H:%M) or an id
|
|
74
|
+
// substitution ($(id -u):$(id -g)) is no credential. A user:group of names is not told apart:
|
|
75
|
+
// that would let admin:admin through.
|
|
76
|
+
["curl-user", /(?<!\S)(?:-u\s*|--user(?:\s+|=))(?!(["']?)\d{1,10}:\d{1,10}\1(?=["'`\s;|&)]|$))(?!["']?\+%|\))[^\s:=]{1,256}:(?!\/)\S{3,256}/],
|
|
77
|
+
["assigned-hex", /(?:key|auth|token|secret)[\w-]{0,64}["']?\s*[:=]\s*["']?[0-9a-f]{32,65536}(?![0-9a-z])/i],
|
|
78
|
+
// .netrc: `machine <host> ... password <value>` on one line, or a `password <value>` line of its own (the file's
|
|
79
|
+
// other layout, one token pair a line); a value that is a reference or a placeholder is none
|
|
80
|
+
["netrc-password", /^[+\- ]?\s*(?:(?:machine\s+\S{1,256}|default)(?:\s+\S{1,256}){0,16}?\s+password\s+(?![$<{%])\S|password\s+(?![$<{%])\S{1,256}\s*$)/],
|
|
81
|
+
// .pgpass: host:port:database:user:password, the port a number or *, no empty field. The host is not digits alone
|
|
82
|
+
// and the password holds no colon but an escaped one (\:), so a MAC of digits or a time (12:30:45:00) is no hit.
|
|
83
|
+
["pgpass-line", /^[+\- ]?(?=[^\s:#]*[^\d\s:#])[^\s:#+\-][^\s:#]{0,255}:(?:\d{1,5}|\*):[^\s:]{1,256}:[^\s:]{1,256}:(?!\*?$)(?:\\.|[^\s:\\]){1,256}$/],
|
|
84
|
+
// docker's config.json: "auth" holds base64 of user:password (checked by decoding it, below)
|
|
85
|
+
["docker-auth", /"auth"\s*:\s*"[A-Za-z0-9+/]{4,65536}={0,2}"/],
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// A kind whose pattern alone reads too wide: the line counts only when this holds as well.
|
|
89
|
+
const DOCKER_AUTH = /"auth"\s*:\s*"([A-Za-z0-9+/]{4,65536}={0,2})"/;
|
|
90
|
+
const CONFIRM = {
|
|
91
|
+
"docker-auth": (line) => Buffer.from(DOCKER_AUTH.exec(line)[1], "base64").toString("latin1").includes(":"),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Long runs of base64-like characters whose Shannon entropy is that of random data, as
|
|
95
|
+
// lineage's redaction measures them (32 characters or more, 4.5 bits per character or more).
|
|
96
|
+
const LONG_RUN = /[A-Za-z0-9+/=]{32,65536}/g;
|
|
97
|
+
const ENTROPY_FLOOR = 4.5;
|
|
98
|
+
|
|
99
|
+
export function entropy(s) {
|
|
100
|
+
const counts = new Map();
|
|
101
|
+
for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
102
|
+
let bits = 0;
|
|
103
|
+
for (const n of counts.values()) bits -= (n / s.length) * Math.log2(n / s.length);
|
|
104
|
+
return bits;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A subresource-integrity hash (`sha512-<base64>`, as lockfiles hold) and a Go module or
|
|
108
|
+
// Terraform lock hash (`h1:` and 43 base64 characters and `=`, as go.sum and .terraform.lock.hcl
|
|
109
|
+
// hold) are public checksums.
|
|
110
|
+
const SRI_PREFIX = /sha(?:256|384|512)-$/;
|
|
111
|
+
const H1_PREFIX = /(?:^|[^A-Za-z0-9+/=])h1:$/;
|
|
112
|
+
const publicHash = (line, m) => SRI_PREFIX.test(line.slice(Math.max(0, m.index - 7), m.index))
|
|
113
|
+
|| (/^[A-Za-z0-9+/]{43}=$/.test(m[0]) && H1_PREFIX.test(line.slice(Math.max(0, m.index - 4), m.index)));
|
|
114
|
+
const highEntropy = (line) => [...line.matchAll(LONG_RUN)].some((m) => !publicHash(line, m) && entropy(m[0]) >= ENTROPY_FLOOR);
|
|
115
|
+
|
|
116
|
+
// A *_FILE key's absolute path, the Docker secret file official images take a password from, is
|
|
117
|
+
// not that password: the kinds that read a value after a key name see a `-` there instead. The
|
|
118
|
+
// other kinds still read the line as it is, so a key's own format shows wherever it sits.
|
|
119
|
+
const SECRET_FILE_PATH = /(?<![\w.-])([\w.-]{0,128}_FILE["']?(?:[ \t]*[:=][ \t]*|[ \t]+)["']?)\/[\w.@-]{1,128}(?:\/[\w.@-]{1,128}){0,32}(?=["']?(?:[\s,;#]|$))/gi;
|
|
120
|
+
// The shell's own working folder (PWD, OLDPWD, as `env`, `declare -x` or a JSON dump shows it) holding a plain path:
|
|
121
|
+
// no password, though PWD reads like one. A key that only ends in PWD (MYSQL_PWD) and a value that is no plain path
|
|
122
|
+
// stay. Taken out before the keyed patterns read the line, as lineage does.
|
|
123
|
+
const SHELL_DIR = /(?<=^[+\- ]?|[\s"'$])((?:OLD)?PWD["']?[ \t]*[:=][ \t]*["']?)(?:~|\/|[A-Za-z]:[\\/])[^\s"'!#$%&*;|<>()[\]{}`^?=]{0,4096}(?=["']?(?:[\s,]|$))/g;
|
|
124
|
+
const KEYED = new Set(["assigned-secret", "assigned-secret-bare", "assigned-password", "env-password", "config-password",
|
|
125
|
+
"dockerfile-env-password"]);
|
|
126
|
+
|
|
127
|
+
const KEY_END = new RegExp(`-----END ${PRIVATE_KEY}`);
|
|
128
|
+
// The mask SKILL.md asks for, as a word of its own. One glued to the rest of a value is no mask: what is left is read.
|
|
129
|
+
const WHOLE_MASK = /(?<!\w)\[REDACTED\](?!\w)/g;
|
|
130
|
+
|
|
131
|
+
const caught = (kind, re, plain, keyed) => re.test(KEYED.has(kind) ? keyed : plain) && (!CONFIRM[kind] || CONFIRM[kind](plain));
|
|
132
|
+
|
|
133
|
+
// Each line's secret-like kinds, once per kind and line. A private key closes at the first END
|
|
134
|
+
// marker at or after its line: the END lines are found once and walked with the lines, so many
|
|
135
|
+
// BEGIN lines without an END still take linear time.
|
|
136
|
+
export function scan(text) {
|
|
137
|
+
const hits = [];
|
|
138
|
+
const lines = String(text).split(/\r?\n/);
|
|
139
|
+
const ends = lines.flatMap((l, i) => (KEY_END.test(l) ? [i + 1] : []));
|
|
140
|
+
let next = 0;
|
|
141
|
+
lines.forEach((line, i) => {
|
|
142
|
+
while (next < ends.length && ends[next] < i + 1) next += 1;
|
|
143
|
+
const plain = line.replace(WHOLE_MASK, "-");
|
|
144
|
+
const keyed = plain.replace(SECRET_FILE_PATH, "$1-").replace(SHELL_DIR, "$1-");
|
|
145
|
+
for (const [kind, re] of PATTERNS) {
|
|
146
|
+
if (!caught(kind, re, plain, keyed)) continue;
|
|
147
|
+
hits.push(kind === "private-key" ? { line: i + 1, kind, end: ends[next] ?? null } : { line: i + 1, kind });
|
|
148
|
+
}
|
|
149
|
+
if (highEntropy(plain)) hits.push({ line: i + 1, kind: "high-entropy" });
|
|
150
|
+
});
|
|
151
|
+
return { count: hits.length, hits };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function main(argv) {
|
|
155
|
+
if (argv.length !== 1) {
|
|
156
|
+
process.stderr.write("usage: node payload-scan.mjs <file> (exactly one file)\n");
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
const file = argv[0];
|
|
160
|
+
let text;
|
|
161
|
+
try {
|
|
162
|
+
text = readFileSync(file, "utf8");
|
|
163
|
+
} catch (e) {
|
|
164
|
+
process.stderr.write(`payload-scan: cannot read ${file}: ${e.code ?? e.message}\n`);
|
|
165
|
+
return 2;
|
|
166
|
+
}
|
|
167
|
+
let result;
|
|
168
|
+
try {
|
|
169
|
+
result = scan(text);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
process.stderr.write(`payload-scan: cannot scan ${file}: ${e.message}\n`);
|
|
172
|
+
return 2;
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write(JSON.stringify({ file, ...result }, null, 2) + "\n");
|
|
175
|
+
return result.count === 0 ? 0 : 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const invokedDirectly = () => {
|
|
179
|
+
try {
|
|
180
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (invokedDirectly()) process.exitCode = main(process.argv.slice(2));
|