second-read 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/dist/claude.js +8 -1
- package/dist/gh.js +95 -7
- package/dist/main.js +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -127,11 +127,13 @@ second-read shim install # writes a gh shim, and says which folder to put ah
|
|
|
127
127
|
`SECOND_READ_ALLOWLIST_REVIEWED="<id>"`. An id covers only that change, so a later one is refused again.
|
|
128
128
|
- **The `gh` shim** checks `--title`, `--body`, `--body-file` and a body read from stdin, for `gh issue` and
|
|
129
129
|
`gh pr` `create`, `edit` and `comment`, and `gh pr review`. It also checks the `--comment` that `close` and
|
|
130
|
-
`reopen` leave, and the `--subject
|
|
131
|
-
notes and tag,
|
|
132
|
-
|
|
133
|
-
is
|
|
134
|
-
`gh`
|
|
130
|
+
`reopen` leave, and the `--subject`, body and `--author-email` of `gh pr merge`'s merge commit. It checks a
|
|
131
|
+
release's title, notes and tag, and the file name and any label of each asset `gh release create` or
|
|
132
|
+
`gh release upload` sends, a gist's description and the files it uploads, stdin included, with the name
|
|
133
|
+
each is uploaded under, a label's name and description, and a repository's name and description, on
|
|
134
|
+
`gh repo rename` too. A name is checked as a branch name is, and only the new one, so a leaked name can be renamed
|
|
135
|
+
away. Then it runs the real `gh` with the same arguments and input. Every other command, `gh api` included, passes
|
|
136
|
+
straight through.
|
|
135
137
|
|
|
136
138
|
Each guard refuses on a finding, and also when the check itself fails. A hook that was already in place is set aside
|
|
137
139
|
and still runs after the check; `second-read hooks uninstall` removes only second-read's hooks and puts it back. The
|
|
@@ -183,7 +185,8 @@ A body can come from a heredoc (`--body "$(cat <<'EOF' … EOF)"`), a here-strin
|
|
|
183
185
|
`printf` piped in. A file the same command writes first, as in `cat > body.md <<'EOF'`, is read from the command,
|
|
184
186
|
since the hook runs before it does. Posts inside `$(…)` and backticks are checked too. A body the shell fills in
|
|
185
187
|
only when the command runs, such as `--body "$BODY"` or what `xargs` adds, can't be read beforehand, so the call is
|
|
186
|
-
denied.
|
|
188
|
+
denied. So is a release asset given as a glob, such as `dist/*.tgz`, whose files may not be there yet. Put the text
|
|
189
|
+
in the command, or in a file and pass its path, and name each asset.
|
|
187
190
|
|
|
188
191
|
The plugin never approves a call. A clean post goes on to Claude Code's usual permission check. Its deny holds in
|
|
189
192
|
`bypassPermissions` mode too: a session run with `--permission-mode bypassPermissions` on Claude Code 2.1.302 had
|
package/dist/claude.js
CHANGED
|
@@ -148,8 +148,15 @@ function ghPosts(args, stdin, dir, written) {
|
|
|
148
148
|
const post = parseGh(args);
|
|
149
149
|
if (!post)
|
|
150
150
|
return [];
|
|
151
|
+
// gh expands a glob among a release's assets only when it runs, past files the command may make first.
|
|
151
152
|
return [
|
|
152
|
-
...postNames(post).map(([where, text]) => ({
|
|
153
|
+
...postNames(post, (pattern) => [unknown(pattern)]).map(([where, text]) => ({
|
|
154
|
+
where,
|
|
155
|
+
kind: post.kind,
|
|
156
|
+
text,
|
|
157
|
+
cwd: dir,
|
|
158
|
+
name: true,
|
|
159
|
+
})),
|
|
153
160
|
...postTexts(post, reader(stdin, dir, written)).map(([where, text]) => ({ where, kind: post.kind, text, cwd: dir })),
|
|
154
161
|
];
|
|
155
162
|
}
|
package/dist/gh.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { accessSync, closeSync, constants, openSync, readSync, realpathSync, statSync } from "node:fs";
|
|
1
|
+
import { accessSync, closeSync, constants, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, delimiter, join, resolve } from "node:path";
|
|
3
3
|
import { KIND_NAMES } from "./checks.js";
|
|
4
4
|
import { shQuote } from "./git.js";
|
|
5
|
+
import { escapeRegExp } from "./normalize.js";
|
|
6
|
+
import { UNKNOWN } from "./shell.js";
|
|
5
7
|
export const SHIM_MARKER = "# Added by second-read: the gh shim.";
|
|
6
8
|
const TEXT_FLAGS = {
|
|
7
9
|
"--title": "title",
|
|
@@ -13,7 +15,7 @@ const TEXT_FLAGS = {
|
|
|
13
15
|
};
|
|
14
16
|
// Closing or reopening with a comment posts the comment.
|
|
15
17
|
const COMMENT_FLAGS = { "--comment": "body", "-c": "body" };
|
|
16
|
-
// A merge commit's message
|
|
18
|
+
// A merge commit's message, its subject and body, and its author's address.
|
|
17
19
|
const MERGE_FLAGS = {
|
|
18
20
|
"--subject": "title",
|
|
19
21
|
"-t": "title",
|
|
@@ -21,6 +23,8 @@ const MERGE_FLAGS = {
|
|
|
21
23
|
"-b": "body",
|
|
22
24
|
"--body-file": "bodyFile",
|
|
23
25
|
"-F": "bodyFile",
|
|
26
|
+
"--author-email": "email",
|
|
27
|
+
"-A": "email",
|
|
24
28
|
};
|
|
25
29
|
const RELEASE_FLAGS = {
|
|
26
30
|
"--title": "title",
|
|
@@ -49,12 +53,24 @@ const release = (posting) => ({
|
|
|
49
53
|
nameName: "tag",
|
|
50
54
|
...posting,
|
|
51
55
|
});
|
|
56
|
+
// Each asset is "<file>#<label>" or "<file>", and is uploaded under its file's name, which is in its download URL
|
|
57
|
+
// even when a label is shown on the release page. One that starts with "#" has no label, and one the shell fills in
|
|
58
|
+
// is read whole, to be refused unread.
|
|
59
|
+
const assets = (post, args) => {
|
|
60
|
+
for (const arg of args) {
|
|
61
|
+
post.assets.push(arg);
|
|
62
|
+
const at = arg.includes(UNKNOWN) ? -1 : arg.indexOf("#");
|
|
63
|
+
if (at > 0)
|
|
64
|
+
post.labels.push(arg.slice(at + 1));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
52
67
|
// gh release create [<tag>] [<asset>...]
|
|
53
68
|
const releaseCreate = release({
|
|
54
69
|
valued: ["--discussion-category", "--notes-start-tag", "--target"],
|
|
55
|
-
args: (post, [tag]) => {
|
|
70
|
+
args: (post, [tag, ...files]) => {
|
|
56
71
|
if (tag !== undefined)
|
|
57
72
|
post.names.push(tag);
|
|
73
|
+
assets(post, files);
|
|
58
74
|
},
|
|
59
75
|
});
|
|
60
76
|
// A repository's name, from gh repo create [<name>] or gh repo rename [<new name>].
|
|
@@ -136,7 +152,7 @@ const POSTS = {
|
|
|
136
152
|
merge: {
|
|
137
153
|
kind: "commit",
|
|
138
154
|
flags: MERGE_FLAGS,
|
|
139
|
-
valued: ["--
|
|
155
|
+
valued: ["--match-head-commit"],
|
|
140
156
|
noun: "this merge commit",
|
|
141
157
|
titleName: "subject",
|
|
142
158
|
},
|
|
@@ -146,6 +162,8 @@ const POSTS = {
|
|
|
146
162
|
new: releaseCreate,
|
|
147
163
|
// The tag it's given now is checked, not the one it had, so a release's tag can be renamed.
|
|
148
164
|
edit: release({ flags: { ...RELEASE_FLAGS, "--tag": "name" } }),
|
|
165
|
+
// gh release upload <tag> <asset>...: the release is already there, under its tag.
|
|
166
|
+
upload: release({ flags: {}, valued: [], args: (post, [, ...files]) => assets(post, files) }),
|
|
149
167
|
},
|
|
150
168
|
gist: {
|
|
151
169
|
create: gistCreate,
|
|
@@ -211,6 +229,9 @@ export function parseGh(args) {
|
|
|
211
229
|
body: [],
|
|
212
230
|
bodyFiles: [],
|
|
213
231
|
names: [],
|
|
232
|
+
emails: [],
|
|
233
|
+
labels: [],
|
|
234
|
+
assets: [],
|
|
214
235
|
};
|
|
215
236
|
const takesValue = (flag) => flag in posting.flags || posting.valued.includes(flag) || flag === "-R" || flag === "--repo";
|
|
216
237
|
// The arguments, the command words first.
|
|
@@ -253,6 +274,8 @@ export function parseGh(args) {
|
|
|
253
274
|
post.bodyFiles.push(value);
|
|
254
275
|
else if (field === "name")
|
|
255
276
|
post.names.push(value);
|
|
277
|
+
else if (field === "email")
|
|
278
|
+
post.emails.push(value);
|
|
256
279
|
else
|
|
257
280
|
post[field].push(value);
|
|
258
281
|
}
|
|
@@ -284,11 +307,76 @@ export function postTexts(post, read) {
|
|
|
284
307
|
f === "-" ? `the ${post.fileName} of ${noun}` : `the ${post.fileName} of ${noun}, from ${f}`,
|
|
285
308
|
read(f),
|
|
286
309
|
]),
|
|
310
|
+
...post.emails.map((t) => [`the author email of ${noun}`, t]),
|
|
311
|
+
...post.labels.map((t) => [`the label of an asset of ${noun}`, t]),
|
|
287
312
|
];
|
|
288
313
|
}
|
|
289
|
-
// Each name a posting gh command gives, with where it goes. A name is checked as a branch name is.
|
|
290
|
-
|
|
291
|
-
|
|
314
|
+
// Each name a posting gh command gives, with where it goes. A name is checked as a branch name is. gh expands an
|
|
315
|
+
// asset with no "#" as a glob, and `glob` gives the names of the files it matches.
|
|
316
|
+
export function postNames(post, glob) {
|
|
317
|
+
const files = post.assets.flatMap((a) => {
|
|
318
|
+
if (a.includes(UNKNOWN))
|
|
319
|
+
return [a];
|
|
320
|
+
const at = a.indexOf("#");
|
|
321
|
+
if (at > 0)
|
|
322
|
+
return [basename(a.slice(0, at))];
|
|
323
|
+
return at < 0 && GLOB_META.test(a) ? glob(a) : [basename(a)];
|
|
324
|
+
});
|
|
325
|
+
return [
|
|
326
|
+
...post.names.map((n) => [`the ${post.nameName} of ${post.noun}`, n]),
|
|
327
|
+
...files.map((n) => [`the file name of an asset of ${post.noun}`, n]),
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
// The characters Go's filepath.Glob reads as a pattern, outside Windows.
|
|
331
|
+
const GLOB_META = /[*?[\\]/;
|
|
332
|
+
// The names of the files a pattern matches, read as Go's filepath.Glob reads it, which gh runs on an asset. "*"
|
|
333
|
+
// matches any run of characters but "/", "?" one, and "\" makes the next character plain; a dot file is matched too.
|
|
334
|
+
// A class in brackets is read as any one character, so a pattern never takes in fewer files than gh uploads.
|
|
335
|
+
export function globNames(pattern, cwd) {
|
|
336
|
+
let paths = [pattern.startsWith("/") ? "/" : ""];
|
|
337
|
+
for (const segment of pattern.split("/")) {
|
|
338
|
+
if (segment === "")
|
|
339
|
+
continue;
|
|
340
|
+
if (!GLOB_META.test(segment)) {
|
|
341
|
+
paths = paths.map((p) => join(p, segment));
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const re = globSegment(segment);
|
|
345
|
+
paths = paths.flatMap((p) => {
|
|
346
|
+
try {
|
|
347
|
+
return readdirSync(resolve(cwd, p || ".")).filter((name) => re.test(name)).map((name) => join(p, name));
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return paths.map((p) => basename(p));
|
|
355
|
+
}
|
|
356
|
+
function globSegment(segment) {
|
|
357
|
+
let source = "";
|
|
358
|
+
for (let i = 0; i < segment.length; i++) {
|
|
359
|
+
const c = segment[i];
|
|
360
|
+
if (c === "*")
|
|
361
|
+
source += ".*";
|
|
362
|
+
else if (c === "?")
|
|
363
|
+
source += ".";
|
|
364
|
+
else if (c === "\\")
|
|
365
|
+
source += escapeRegExp(segment[++i] ?? "\\");
|
|
366
|
+
else if (c === "[") {
|
|
367
|
+
// A class ends at the first "]" past its first character, which may be "]" itself.
|
|
368
|
+
let j = segment[i + 1] === "^" ? i + 2 : i + 1;
|
|
369
|
+
for (let first = true; j < segment.length && (first || segment[j] !== "]"); j++, first = false) {
|
|
370
|
+
if (segment[j] === "\\")
|
|
371
|
+
j++;
|
|
372
|
+
}
|
|
373
|
+
source += ".";
|
|
374
|
+
i = j;
|
|
375
|
+
}
|
|
376
|
+
else
|
|
377
|
+
source += escapeRegExp(c);
|
|
378
|
+
}
|
|
379
|
+
return new RegExp(`^${source}$`, "su");
|
|
292
380
|
}
|
|
293
381
|
export function shimScript(command) {
|
|
294
382
|
return `#!/bin/sh
|
package/dist/main.js
CHANGED
|
@@ -4,7 +4,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import { check, checkTerms, KIND_NAMES, KINDS, } from "./checks.js";
|
|
5
5
|
import { configDir, exemptMatcher, findRepoConfig, loadRepoConfig, loadUserConfig, mergeRules, USER_FILE, USER_FILE_TEMPLATE, } from "./config.js";
|
|
6
6
|
import { bashPosts, firstUnknown, isGithubTool, mcpPosts } from "./claude.js";
|
|
7
|
-
import { findRealGh, isShim, parseGh, postNames, postTexts, shimScript } from "./gh.js";
|
|
7
|
+
import { findRealGh, globNames, isShim, parseGh, postNames, postTexts, shimScript } from "./gh.js";
|
|
8
8
|
import { allowlistChanges, commitMessage, exitStatus, HOOKS, hooksDir, hooksInWorkTree, installHooks, installManaged, isManagedDir, pushRange, readPushed, refreshManaged, runPrevious, uninstallHooks, uninstallManaged, } from "./git.js";
|
|
9
9
|
import { UNKNOWN } from "./shell.js";
|
|
10
10
|
export const EXIT = { clean: 0, findings: 1, failed: 2 };
|
|
@@ -341,7 +341,10 @@ async function runShim(io, args) {
|
|
|
341
341
|
const stdin = input?.toString("utf8") ?? "";
|
|
342
342
|
const texts = postTexts(post, (file) => (file === "-" ? stdin : readFileSync(resolve(io.cwd, file), "utf8")));
|
|
343
343
|
const sections = [
|
|
344
|
-
...postNames(post).map(([where, name]) => ({
|
|
344
|
+
...postNames(post, (pattern) => globNames(pattern, io.cwd)).map(([where, name]) => ({
|
|
345
|
+
where,
|
|
346
|
+
findings: checkName(name, identity, rules),
|
|
347
|
+
})),
|
|
345
348
|
...texts.map(([where, text]) => ({ where, findings: check(text, { identity, rules, kind: post.kind }) })),
|
|
346
349
|
].filter((s) => s.findings.length > 0);
|
|
347
350
|
if (sections.length > 0) {
|