second-read 0.1.0 → 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 +14 -8
- package/dist/claude.js +17 -5
- package/dist/cli.js +1 -0
- package/dist/gh.js +286 -27
- package/dist/main.js +11 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,8 @@ It isn't a secrets scanner. Tools like gitleaks find keys and tokens, and second
|
|
|
36
36
|
## Where it runs
|
|
37
37
|
|
|
38
38
|
- **git hooks:** commit messages, and the lines a push adds.
|
|
39
|
-
- **A `gh` shim:** the
|
|
39
|
+
- **A `gh` shim:** the text of issues, pull requests, comments, releases, gists, labels and repositories, and the
|
|
40
|
+
names they're given, before the real `gh` sends it.
|
|
40
41
|
- **Agent adapters**, for posts that don't go through `git` or `gh`, such as a GitHub tool an agent calls directly.
|
|
41
42
|
Claude Code comes first. Other agents follow, each written from its tool's own hook docs.
|
|
42
43
|
- **A sweep** of what's already posted to a repository, edit histories included.
|
|
@@ -106,7 +107,7 @@ exempt_files = ["src/**/*.test.ts", "fixtures/"]
|
|
|
106
107
|
repository: `*` and `?` stay within one folder, `**` takes in any number of them, and a trailing `/` takes in a
|
|
107
108
|
whole folder. A glob made only of wildcards and dots, such as `**` or `**/*.*`, is an error, since it takes in nearly
|
|
108
109
|
every file; one for dotfiles, such as `**/.*`, is fine. Commit and tag messages, branch and tag names, and everything
|
|
109
|
-
the `gh` shim sends are still checked in full.
|
|
110
|
+
the `gh` shim sends are still checked in full, a gist's files included: a gist is a post of its own.
|
|
110
111
|
|
|
111
112
|
## The guards
|
|
112
113
|
|
|
@@ -126,8 +127,13 @@ second-read shim install # writes a gh shim, and says which folder to put ah
|
|
|
126
127
|
`SECOND_READ_ALLOWLIST_REVIEWED="<id>"`. An id covers only that change, so a later one is refused again.
|
|
127
128
|
- **The `gh` shim** checks `--title`, `--body`, `--body-file` and a body read from stdin, for `gh issue` and
|
|
128
129
|
`gh pr` `create`, `edit` and `comment`, and `gh pr review`. It also checks the `--comment` that `close` and
|
|
129
|
-
`reopen` leave, and the `--subject
|
|
130
|
-
|
|
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.
|
|
131
137
|
|
|
132
138
|
Each guard refuses on a finding, and also when the check itself fails. A hook that was already in place is set aside
|
|
133
139
|
and still runs after the check; `second-read hooks uninstall` removes only second-read's hooks and puts it back. The
|
|
@@ -166,9 +172,8 @@ the call and gives Claude the report as the reason, so Claude can fix the text a
|
|
|
166
172
|
|
|
167
173
|
It checks:
|
|
168
174
|
|
|
169
|
-
- **Bash commands that post through `gh`:** the same `gh
|
|
170
|
-
`
|
|
171
|
-
that send fields (`-f`, `-F`, including `@file`) or an `--input` body, unless they're `GET`. A GraphQL query
|
|
175
|
+
- **Bash commands that post through `gh`:** the same `gh` commands as the shim, behind `env`, `sudo` and the like,
|
|
176
|
+
and `gh api` calls that send fields (`-f`, `-F`, including `@file`) or an `--input` body, unless they're `GET`. A GraphQL query
|
|
172
177
|
is checked when it's a mutation.
|
|
173
178
|
- **`git commit` and `git tag` messages**, from `-m`, `-F` and `--trailer`. What a push sends is the `pre-push`
|
|
174
179
|
hook's to check.
|
|
@@ -180,7 +185,8 @@ A body can come from a heredoc (`--body "$(cat <<'EOF' … EOF)"`), a here-strin
|
|
|
180
185
|
`printf` piped in. A file the same command writes first, as in `cat > body.md <<'EOF'`, is read from the command,
|
|
181
186
|
since the hook runs before it does. Posts inside `$(…)` and backticks are checked too. A body the shell fills in
|
|
182
187
|
only when the command runs, such as `--body "$BODY"` or what `xargs` adds, can't be read beforehand, so the call is
|
|
183
|
-
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.
|
|
184
190
|
|
|
185
191
|
The plugin never approves a call. A clean post goes on to Claude Code's usual permission check. Its deny holds in
|
|
186
192
|
`bypassPermissions` mode too: a session run with `--permission-mode bypassPermissions` on Claude Code 2.1.302 had
|
package/dist/claude.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { basename, resolve } from "node:path";
|
|
5
5
|
import { KIND_NAMES } from "./checks.js";
|
|
6
|
-
import { parseGh, postTexts } from "./gh.js";
|
|
6
|
+
import { parseGh, postNames, postTexts, shortFlag } from "./gh.js";
|
|
7
7
|
import { output, parseShell, UNKNOWN, UNKNOWN_END, unknown } from "./shell.js";
|
|
8
8
|
// The texts each command in a Bash tool call would post, read against the folder each one runs in.
|
|
9
9
|
export function bashPosts(command, cwd, home) {
|
|
@@ -148,8 +148,19 @@ function ghPosts(args, stdin, dir, written) {
|
|
|
148
148
|
const post = parseGh(args);
|
|
149
149
|
if (!post)
|
|
150
150
|
return [];
|
|
151
|
-
|
|
151
|
+
// gh expands a glob among a release's assets only when it runs, past files the command may make first.
|
|
152
|
+
return [
|
|
153
|
+
...postNames(post, (pattern) => [unknown(pattern)]).map(([where, text]) => ({
|
|
154
|
+
where,
|
|
155
|
+
kind: post.kind,
|
|
156
|
+
text,
|
|
157
|
+
cwd: dir,
|
|
158
|
+
name: true,
|
|
159
|
+
})),
|
|
160
|
+
...postTexts(post, reader(stdin, dir, written)).map(([where, text]) => ({ where, kind: post.kind, text, cwd: dir })),
|
|
161
|
+
];
|
|
152
162
|
}
|
|
163
|
+
// gh api's flags that take a value, as gh 2.92 has them. The rest are switches, -i among them.
|
|
153
164
|
const GH_API_VALUE_FLAGS = new Set(["-X", "--method", "-f", "--raw-field", "-F", "--field", "-H", "--header", "-q",
|
|
154
165
|
"--jq", "-t", "--template", "-p", "--preview", "--hostname", "--cache", "--input"]);
|
|
155
166
|
// gh api sends its fields, or the --input file, and switches to POST when it has either. A GraphQL query that
|
|
@@ -170,9 +181,10 @@ function ghApiPosts(args, read, dir) {
|
|
|
170
181
|
value = arg.slice(eq + 1);
|
|
171
182
|
}
|
|
172
183
|
else if (arg.startsWith("-") && arg.length > 1) {
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
184
|
+
const flag = shortFlag(arg, (f) => GH_API_VALUE_FLAGS.has(f));
|
|
185
|
+
if (flag === undefined)
|
|
186
|
+
continue;
|
|
187
|
+
({ name, value } = flag);
|
|
176
188
|
}
|
|
177
189
|
else {
|
|
178
190
|
endpoint ??= arg;
|
package/dist/cli.js
CHANGED
|
@@ -46,6 +46,7 @@ process.exitCode = await main({
|
|
|
46
46
|
identity: systemIdentity,
|
|
47
47
|
command: [process.execPath, fileURLToPath(import.meta.url)],
|
|
48
48
|
readStdin,
|
|
49
|
+
stdinIsTTY: process.stdin.isTTY === true,
|
|
49
50
|
out: (text) => process.stdout.write(text),
|
|
50
51
|
err: (text) => process.stderr.write(text),
|
|
51
52
|
});
|
package/dist/gh.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { accessSync, closeSync, constants, openSync, readSync, realpathSync, statSync } from "node:fs";
|
|
2
|
-
import { delimiter, join, resolve } from "node:path";
|
|
1
|
+
import { accessSync, closeSync, constants, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
|
|
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,27 +23,183 @@ 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
|
-
const
|
|
26
|
-
|
|
29
|
+
const RELEASE_FLAGS = {
|
|
30
|
+
"--title": "title",
|
|
31
|
+
"-t": "title",
|
|
32
|
+
"--notes": "body",
|
|
33
|
+
"-n": "body",
|
|
34
|
+
"--notes-file": "bodyFile",
|
|
35
|
+
"-F": "bodyFile",
|
|
36
|
+
};
|
|
37
|
+
const DESCRIPTION_FLAGS = { "--description": "body", "-d": "body" };
|
|
38
|
+
const text = (kind, valued = []) => ({ kind, flags: TEXT_FLAGS, valued });
|
|
39
|
+
const comment = (valued = []) => ({ kind: "comment", flags: COMMENT_FLAGS, valued });
|
|
40
|
+
const ISSUE_CREATE = ["--assignee", "-a", "--label", "-l", "--milestone", "-m", "--project", "-p", "--recover",
|
|
41
|
+
"--template", "-T"];
|
|
42
|
+
const ISSUE_EDIT = ["--add-assignee", "--add-label", "--add-project", "--milestone", "-m", "--remove-assignee",
|
|
43
|
+
"--remove-label", "--remove-project"];
|
|
44
|
+
const PR_CREATE = [...ISSUE_CREATE, "--base", "-B", "--head", "-H", "--reviewer", "-r"];
|
|
45
|
+
const PR_EDIT = [...ISSUE_EDIT, "--add-reviewer", "--base", "-B", "--remove-reviewer"];
|
|
46
|
+
// A post's kind only picks how it's checked, and these check like a comment; each has its own noun.
|
|
47
|
+
const release = (posting) => ({
|
|
48
|
+
kind: "comment",
|
|
49
|
+
flags: RELEASE_FLAGS,
|
|
50
|
+
valued: ["--discussion-category", "--target"],
|
|
51
|
+
noun: "this release",
|
|
52
|
+
bodyName: "notes",
|
|
53
|
+
nameName: "tag",
|
|
54
|
+
...posting,
|
|
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
|
+
};
|
|
67
|
+
// gh release create [<tag>] [<asset>...]
|
|
68
|
+
const releaseCreate = release({
|
|
69
|
+
valued: ["--discussion-category", "--notes-start-tag", "--target"],
|
|
70
|
+
args: (post, [tag, ...files]) => {
|
|
71
|
+
if (tag !== undefined)
|
|
72
|
+
post.names.push(tag);
|
|
73
|
+
assets(post, files);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
// A repository's name, from gh repo create [<name>] or gh repo rename [<new name>].
|
|
77
|
+
const repository = (posting) => ({
|
|
78
|
+
kind: "comment",
|
|
79
|
+
flags: DESCRIPTION_FLAGS,
|
|
80
|
+
valued: [],
|
|
81
|
+
noun: "this repository",
|
|
82
|
+
bodyName: "description",
|
|
83
|
+
...posting,
|
|
84
|
+
});
|
|
85
|
+
const repoName = (post, [name]) => {
|
|
86
|
+
if (name !== undefined)
|
|
87
|
+
post.names.push(name);
|
|
88
|
+
};
|
|
89
|
+
const repoCreate = repository({
|
|
90
|
+
valued: ["--gitignore", "-g", "--homepage", "-h", "--license", "-l", "--remote", "-r", "--source", "-s", "--team",
|
|
91
|
+
"-t", "--template", "-p"],
|
|
92
|
+
args: repoName,
|
|
93
|
+
});
|
|
94
|
+
// A gist's files are a new post of their own, not lines added to the repository's files, so exempt_files never
|
|
95
|
+
// applies to them.
|
|
96
|
+
const gist = (posting) => ({
|
|
97
|
+
kind: "comment",
|
|
98
|
+
flags: { "--desc": "body", "-d": "body" },
|
|
99
|
+
valued: [],
|
|
100
|
+
noun: "this gist",
|
|
101
|
+
bodyName: "description",
|
|
102
|
+
fileName: "contents",
|
|
103
|
+
nameName: "file name",
|
|
104
|
+
...posting,
|
|
105
|
+
});
|
|
106
|
+
// Each argument is a file to upload, "-" being stdin. With none, it uploads stdin when it's piped in. A file is
|
|
107
|
+
// uploaded under its own name, and stdin under the name --filename gives.
|
|
108
|
+
const gistCreate = gist({
|
|
109
|
+
flags: { "--desc": "body", "-d": "body", "--filename": "name", "-f": "name" },
|
|
110
|
+
args: (post, files) => {
|
|
111
|
+
const named = post.names.at(-1);
|
|
112
|
+
post.names = [];
|
|
113
|
+
if (files.length === 0) {
|
|
114
|
+
files = ["-"];
|
|
115
|
+
post.stdinIfPiped = true;
|
|
116
|
+
}
|
|
117
|
+
post.bodyFiles.push(...files);
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
if (file !== "-")
|
|
120
|
+
post.names.push(basename(file));
|
|
121
|
+
else if (named !== undefined)
|
|
122
|
+
post.names.push(named);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
const label = (posting) => ({
|
|
127
|
+
kind: "comment",
|
|
128
|
+
flags: DESCRIPTION_FLAGS,
|
|
129
|
+
valued: ["--color", "-c"],
|
|
130
|
+
noun: "this label",
|
|
131
|
+
titleName: "name",
|
|
132
|
+
bodyName: "description",
|
|
133
|
+
...posting,
|
|
134
|
+
});
|
|
27
135
|
const POSTS = {
|
|
28
136
|
issue: {
|
|
29
|
-
create: text("issue"),
|
|
30
|
-
new: text("issue"),
|
|
31
|
-
edit: text("issue"),
|
|
137
|
+
create: text("issue", ISSUE_CREATE),
|
|
138
|
+
new: text("issue", ISSUE_CREATE),
|
|
139
|
+
edit: text("issue", ISSUE_EDIT),
|
|
32
140
|
comment: text("comment"),
|
|
33
|
-
close: comment,
|
|
34
|
-
reopen: comment,
|
|
141
|
+
close: comment(["--duplicate-of", "--reason", "-r"]),
|
|
142
|
+
reopen: comment(),
|
|
35
143
|
},
|
|
36
144
|
pr: {
|
|
37
|
-
create: text("pr"),
|
|
38
|
-
new: text("pr"),
|
|
39
|
-
edit: text("pr"),
|
|
145
|
+
create: text("pr", PR_CREATE),
|
|
146
|
+
new: text("pr", PR_CREATE),
|
|
147
|
+
edit: text("pr", PR_EDIT),
|
|
40
148
|
comment: text("comment"),
|
|
41
149
|
review: text("review"),
|
|
42
|
-
close: comment,
|
|
43
|
-
reopen: comment,
|
|
44
|
-
merge: {
|
|
150
|
+
close: comment(),
|
|
151
|
+
reopen: comment(),
|
|
152
|
+
merge: {
|
|
153
|
+
kind: "commit",
|
|
154
|
+
flags: MERGE_FLAGS,
|
|
155
|
+
valued: ["--match-head-commit"],
|
|
156
|
+
noun: "this merge commit",
|
|
157
|
+
titleName: "subject",
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
release: {
|
|
161
|
+
create: releaseCreate,
|
|
162
|
+
new: releaseCreate,
|
|
163
|
+
// The tag it's given now is checked, not the one it had, so a release's tag can be renamed.
|
|
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) }),
|
|
167
|
+
},
|
|
168
|
+
gist: {
|
|
169
|
+
create: gistCreate,
|
|
170
|
+
new: gistCreate,
|
|
171
|
+
// gh gist edit <gist> [<file>]: the file replaces the one --filename picks, or is uploaded under the name --add
|
|
172
|
+
// gives. Without it, --add uploads the file it names.
|
|
173
|
+
edit: gist({
|
|
174
|
+
flags: { "--desc": "body", "-d": "body", "--add": "bodyFile", "-a": "bodyFile" },
|
|
175
|
+
valued: ["--filename", "-f", "--remove", "-r"],
|
|
176
|
+
args: (post, [, file]) => {
|
|
177
|
+
const added = post.bodyFiles.at(-1);
|
|
178
|
+
if (added !== undefined)
|
|
179
|
+
post.names.push(basename(added));
|
|
180
|
+
if (file !== undefined)
|
|
181
|
+
post.bodyFiles = [file];
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
label: {
|
|
186
|
+
create: label({
|
|
187
|
+
args: (post, [name]) => {
|
|
188
|
+
if (name !== undefined)
|
|
189
|
+
post.title.push(name);
|
|
190
|
+
},
|
|
191
|
+
}),
|
|
192
|
+
// The name it's given now is checked, not the one it had, so a label can be renamed.
|
|
193
|
+
edit: label({ flags: { ...DESCRIPTION_FLAGS, "--name": "title", "-n": "title" } }),
|
|
194
|
+
},
|
|
195
|
+
repo: {
|
|
196
|
+
create: repoCreate,
|
|
197
|
+
new: repoCreate,
|
|
198
|
+
edit: repository({
|
|
199
|
+
valued: ["--add-topic", "--default-branch", "--homepage", "-h", "--remove-topic",
|
|
200
|
+
"--squash-merge-commit-message", "--visibility"],
|
|
201
|
+
}),
|
|
202
|
+
rename: repository({ flags: {}, args: repoName }),
|
|
45
203
|
},
|
|
46
204
|
};
|
|
47
205
|
// The text a posting gh command sends, or undefined for any other command, which passes straight through.
|
|
@@ -64,17 +222,25 @@ export function parseGh(args) {
|
|
|
64
222
|
kind: posting.kind,
|
|
65
223
|
noun: posting.noun ?? KIND_NAMES[posting.kind],
|
|
66
224
|
titleName: posting.titleName ?? "title",
|
|
225
|
+
bodyName: posting.bodyName ?? "body",
|
|
226
|
+
fileName: posting.fileName ?? posting.bodyName ?? "body",
|
|
227
|
+
nameName: posting.nameName ?? "name",
|
|
67
228
|
title: [],
|
|
68
229
|
body: [],
|
|
69
230
|
bodyFiles: [],
|
|
231
|
+
names: [],
|
|
232
|
+
emails: [],
|
|
233
|
+
labels: [],
|
|
234
|
+
assets: [],
|
|
70
235
|
};
|
|
236
|
+
const takesValue = (flag) => flag in posting.flags || posting.valued.includes(flag) || flag === "-R" || flag === "--repo";
|
|
237
|
+
// The arguments, the command words first.
|
|
238
|
+
const positional = [];
|
|
71
239
|
for (let i = 0; i < args.length; i++) {
|
|
72
240
|
const arg = args[i];
|
|
73
|
-
if (arg === "--")
|
|
241
|
+
if (arg === "--") {
|
|
242
|
+
positional.push(...args.slice(i + 1));
|
|
74
243
|
break;
|
|
75
|
-
if (arg === "-R" || arg === "--repo") {
|
|
76
|
-
i++;
|
|
77
|
-
continue;
|
|
78
244
|
}
|
|
79
245
|
let name;
|
|
80
246
|
let value;
|
|
@@ -85,40 +251,133 @@ export function parseGh(args) {
|
|
|
85
251
|
value = arg.slice(eq + 1);
|
|
86
252
|
}
|
|
87
253
|
else if (arg.startsWith("-") && arg.length > 1) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
254
|
+
const flag = shortFlag(arg, takesValue);
|
|
255
|
+
if (flag === undefined)
|
|
256
|
+
continue;
|
|
257
|
+
({ name, value } = flag);
|
|
92
258
|
}
|
|
93
259
|
else {
|
|
260
|
+
positional.push(arg);
|
|
94
261
|
continue;
|
|
95
262
|
}
|
|
96
263
|
const field = posting.flags[name];
|
|
97
|
-
if (field === undefined)
|
|
264
|
+
if (field === undefined) {
|
|
265
|
+
if (value === undefined && takesValue(name))
|
|
266
|
+
i++;
|
|
98
267
|
continue;
|
|
268
|
+
}
|
|
99
269
|
if (value === undefined)
|
|
100
270
|
value = args[++i];
|
|
101
271
|
if (value === undefined)
|
|
102
272
|
continue;
|
|
103
273
|
if (field === "bodyFile")
|
|
104
274
|
post.bodyFiles.push(value);
|
|
275
|
+
else if (field === "name")
|
|
276
|
+
post.names.push(value);
|
|
277
|
+
else if (field === "email")
|
|
278
|
+
post.emails.push(value);
|
|
105
279
|
else
|
|
106
280
|
post[field].push(value);
|
|
107
281
|
}
|
|
282
|
+
posting.args?.(post, positional.slice(2));
|
|
108
283
|
return post;
|
|
109
284
|
}
|
|
285
|
+
// A group of short flags, read as gh reads them: the first that takes a value takes the rest of the argument, past
|
|
286
|
+
// an "=", or the next argument, which `value` is left undefined for. Those before it are switches, and a switch
|
|
287
|
+
// given "=" takes the rest as its own. Undefined when the group is all switches.
|
|
288
|
+
export function shortFlag(arg, takesValue) {
|
|
289
|
+
let j = 1;
|
|
290
|
+
for (; j < arg.length && !takesValue(`-${arg[j]}`); j++) {
|
|
291
|
+
if (arg[j + 1] === "=" && j + 2 < arg.length)
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
if (j === arg.length)
|
|
295
|
+
return undefined;
|
|
296
|
+
if (j + 1 === arg.length)
|
|
297
|
+
return { name: `-${arg[j]}`, value: undefined };
|
|
298
|
+
return { name: `-${arg[j]}`, value: arg.slice(arg[j + 1] === "=" && j + 2 < arg.length ? j + 2 : j + 1) };
|
|
299
|
+
}
|
|
110
300
|
// Each text a posting gh command sends, with where it goes. `read` gives a body file's text, "-" being stdin.
|
|
111
301
|
export function postTexts(post, read) {
|
|
112
302
|
const noun = post.noun;
|
|
113
303
|
return [
|
|
114
304
|
...post.title.map((t) => [`the ${post.titleName} of ${noun}`, t]),
|
|
115
|
-
...post.body.map((t) => [`the
|
|
305
|
+
...post.body.map((t) => [`the ${post.bodyName} of ${noun}`, t]),
|
|
116
306
|
...post.bodyFiles.map((f) => [
|
|
117
|
-
f === "-" ? `the
|
|
307
|
+
f === "-" ? `the ${post.fileName} of ${noun}` : `the ${post.fileName} of ${noun}, from ${f}`,
|
|
118
308
|
read(f),
|
|
119
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]),
|
|
120
312
|
];
|
|
121
313
|
}
|
|
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");
|
|
380
|
+
}
|
|
122
381
|
export function shimScript(command) {
|
|
123
382
|
return `#!/bin/sh
|
|
124
383
|
${SHIM_MARKER} It checks what gh is about to post, then runs the real gh.
|
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, 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 };
|
|
@@ -336,13 +336,17 @@ async function runShim(io, args) {
|
|
|
336
336
|
let input;
|
|
337
337
|
if (post) {
|
|
338
338
|
const { identity, rules } = loadRules(io);
|
|
339
|
-
if (post.bodyFiles.includes("-"))
|
|
339
|
+
if (post.bodyFiles.includes("-") && !(post.stdinIfPiped && io.stdinIsTTY))
|
|
340
340
|
input = await io.readStdin();
|
|
341
341
|
const stdin = input?.toString("utf8") ?? "";
|
|
342
342
|
const texts = postTexts(post, (file) => (file === "-" ? stdin : readFileSync(resolve(io.cwd, file), "utf8")));
|
|
343
|
-
const sections =
|
|
344
|
-
.map(([where,
|
|
345
|
-
|
|
343
|
+
const sections = [
|
|
344
|
+
...postNames(post, (pattern) => globNames(pattern, io.cwd)).map(([where, name]) => ({
|
|
345
|
+
where,
|
|
346
|
+
findings: checkName(name, identity, rules),
|
|
347
|
+
})),
|
|
348
|
+
...texts.map(([where, text]) => ({ where, findings: check(text, { identity, rules, kind: post.kind }) })),
|
|
349
|
+
].filter((s) => s.findings.length > 0);
|
|
346
350
|
if (sections.length > 0) {
|
|
347
351
|
io.err(report(sections, FIX_TEXT));
|
|
348
352
|
return EXIT.findings;
|
|
@@ -412,6 +416,8 @@ function claudeRefusal(io, call) {
|
|
|
412
416
|
const { identity, rules: merged, exemptFiles } = rules.get(post.cwd);
|
|
413
417
|
const exempt = post.file !== undefined && exemptMatcher(exemptFiles)(post.file);
|
|
414
418
|
const options = { identity, rules: merged, kind: post.kind };
|
|
419
|
+
if (post.name)
|
|
420
|
+
return { where: post.where, findings: checkName(post.text, identity, merged) };
|
|
415
421
|
return { where: post.where, findings: exempt ? checkTerms(post.text, options) : check(post.text, options) };
|
|
416
422
|
})
|
|
417
423
|
.filter((s) => s.findings.length > 0);
|