second-read 0.1.0 → 0.1.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/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 title and body of `gh issue`, `gh pr` and comment commands, before the real `gh` sends them.
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,11 @@ 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` and body of `gh pr merge`'s merge commit. Then it runs the real `gh` with the
130
- same arguments and input. Every other command, `gh api` included, passes straight through.
130
+ `reopen` leave, and the `--subject` and body of `gh pr merge`'s merge commit. It checks a release's title,
131
+ notes and tag, a gist's description and the files it uploads, stdin included, with the name each is uploaded
132
+ under, a label's name and description, and a repository's name and description, on `gh repo rename` too. A name
133
+ is checked as a branch name is, and only the new one, so a leaked name can be renamed away. Then it runs the real
134
+ `gh` with the same arguments and input. Every other command, `gh api` included, passes straight through.
131
135
 
132
136
  Each guard refuses on a finding, and also when the check itself fails. A hook that was already in place is set aside
133
137
  and still runs after the check; `second-read hooks uninstall` removes only second-read's hooks and puts it back. The
@@ -166,9 +170,8 @@ the call and gives Claude the report as the reason, so Claude can fix the text a
166
170
 
167
171
  It checks:
168
172
 
169
- - **Bash commands that post through `gh`:** the same `gh issue` and `gh pr` commands as the shim, behind `env`,
170
- `sudo` and the like, and `gh api` calls
171
- that send fields (`-f`, `-F`, including `@file`) or an `--input` body, unless they're `GET`. A GraphQL query
173
+ - **Bash commands that post through `gh`:** the same `gh` commands as the shim, behind `env`, `sudo` and the like,
174
+ and `gh api` calls that send fields (`-f`, `-F`, including `@file`) or an `--input` body, unless they're `GET`. A GraphQL query
172
175
  is checked when it's a mutation.
173
176
  - **`git commit` and `git tag` messages**, from `-m`, `-F` and `--trailer`. What a push sends is the `pre-push`
174
177
  hook's to check.
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,12 @@ function ghPosts(args, stdin, dir, written) {
148
148
  const post = parseGh(args);
149
149
  if (!post)
150
150
  return [];
151
- return postTexts(post, reader(stdin, dir, written)).map(([where, text]) => ({ where, kind: post.kind, text, cwd: dir }));
151
+ return [
152
+ ...postNames(post).map(([where, text]) => ({ where, kind: post.kind, text, cwd: dir, name: true })),
153
+ ...postTexts(post, reader(stdin, dir, written)).map(([where, text]) => ({ where, kind: post.kind, text, cwd: dir })),
154
+ ];
152
155
  }
156
+ // gh api's flags that take a value, as gh 2.92 has them. The rest are switches, -i among them.
153
157
  const GH_API_VALUE_FLAGS = new Set(["-X", "--method", "-f", "--raw-field", "-F", "--field", "-H", "--header", "-q",
154
158
  "--jq", "-t", "--template", "-p", "--preview", "--hostname", "--cache", "--input"]);
155
159
  // gh api sends its fields, or the --input file, and switches to POST when it has either. A GraphQL query that
@@ -170,9 +174,10 @@ function ghApiPosts(args, read, dir) {
170
174
  value = arg.slice(eq + 1);
171
175
  }
172
176
  else if (arg.startsWith("-") && arg.length > 1) {
173
- name = arg.slice(0, 2);
174
- if (arg.length > 2)
175
- value = arg.slice(arg[2] === "=" ? 3 : 2);
177
+ const flag = shortFlag(arg, (f) => GH_API_VALUE_FLAGS.has(f));
178
+ if (flag === undefined)
179
+ continue;
180
+ ({ name, value } = flag);
176
181
  }
177
182
  else {
178
183
  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,5 +1,5 @@
1
1
  import { accessSync, closeSync, constants, openSync, readSync, realpathSync, statSync } from "node:fs";
2
- import { delimiter, join, resolve } from "node:path";
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
5
  export const SHIM_MARKER = "# Added by second-read: the gh shim.";
@@ -22,26 +22,166 @@ const MERGE_FLAGS = {
22
22
  "--body-file": "bodyFile",
23
23
  "-F": "bodyFile",
24
24
  };
25
- const text = (kind) => ({ kind, flags: TEXT_FLAGS });
26
- const comment = { kind: "comment", flags: COMMENT_FLAGS };
25
+ const RELEASE_FLAGS = {
26
+ "--title": "title",
27
+ "-t": "title",
28
+ "--notes": "body",
29
+ "-n": "body",
30
+ "--notes-file": "bodyFile",
31
+ "-F": "bodyFile",
32
+ };
33
+ const DESCRIPTION_FLAGS = { "--description": "body", "-d": "body" };
34
+ const text = (kind, valued = []) => ({ kind, flags: TEXT_FLAGS, valued });
35
+ const comment = (valued = []) => ({ kind: "comment", flags: COMMENT_FLAGS, valued });
36
+ const ISSUE_CREATE = ["--assignee", "-a", "--label", "-l", "--milestone", "-m", "--project", "-p", "--recover",
37
+ "--template", "-T"];
38
+ const ISSUE_EDIT = ["--add-assignee", "--add-label", "--add-project", "--milestone", "-m", "--remove-assignee",
39
+ "--remove-label", "--remove-project"];
40
+ const PR_CREATE = [...ISSUE_CREATE, "--base", "-B", "--head", "-H", "--reviewer", "-r"];
41
+ const PR_EDIT = [...ISSUE_EDIT, "--add-reviewer", "--base", "-B", "--remove-reviewer"];
42
+ // A post's kind only picks how it's checked, and these check like a comment; each has its own noun.
43
+ const release = (posting) => ({
44
+ kind: "comment",
45
+ flags: RELEASE_FLAGS,
46
+ valued: ["--discussion-category", "--target"],
47
+ noun: "this release",
48
+ bodyName: "notes",
49
+ nameName: "tag",
50
+ ...posting,
51
+ });
52
+ // gh release create [<tag>] [<asset>...]
53
+ const releaseCreate = release({
54
+ valued: ["--discussion-category", "--notes-start-tag", "--target"],
55
+ args: (post, [tag]) => {
56
+ if (tag !== undefined)
57
+ post.names.push(tag);
58
+ },
59
+ });
60
+ // A repository's name, from gh repo create [<name>] or gh repo rename [<new name>].
61
+ const repository = (posting) => ({
62
+ kind: "comment",
63
+ flags: DESCRIPTION_FLAGS,
64
+ valued: [],
65
+ noun: "this repository",
66
+ bodyName: "description",
67
+ ...posting,
68
+ });
69
+ const repoName = (post, [name]) => {
70
+ if (name !== undefined)
71
+ post.names.push(name);
72
+ };
73
+ const repoCreate = repository({
74
+ valued: ["--gitignore", "-g", "--homepage", "-h", "--license", "-l", "--remote", "-r", "--source", "-s", "--team",
75
+ "-t", "--template", "-p"],
76
+ args: repoName,
77
+ });
78
+ // A gist's files are a new post of their own, not lines added to the repository's files, so exempt_files never
79
+ // applies to them.
80
+ const gist = (posting) => ({
81
+ kind: "comment",
82
+ flags: { "--desc": "body", "-d": "body" },
83
+ valued: [],
84
+ noun: "this gist",
85
+ bodyName: "description",
86
+ fileName: "contents",
87
+ nameName: "file name",
88
+ ...posting,
89
+ });
90
+ // Each argument is a file to upload, "-" being stdin. With none, it uploads stdin when it's piped in. A file is
91
+ // uploaded under its own name, and stdin under the name --filename gives.
92
+ const gistCreate = gist({
93
+ flags: { "--desc": "body", "-d": "body", "--filename": "name", "-f": "name" },
94
+ args: (post, files) => {
95
+ const named = post.names.at(-1);
96
+ post.names = [];
97
+ if (files.length === 0) {
98
+ files = ["-"];
99
+ post.stdinIfPiped = true;
100
+ }
101
+ post.bodyFiles.push(...files);
102
+ for (const file of files) {
103
+ if (file !== "-")
104
+ post.names.push(basename(file));
105
+ else if (named !== undefined)
106
+ post.names.push(named);
107
+ }
108
+ },
109
+ });
110
+ const label = (posting) => ({
111
+ kind: "comment",
112
+ flags: DESCRIPTION_FLAGS,
113
+ valued: ["--color", "-c"],
114
+ noun: "this label",
115
+ titleName: "name",
116
+ bodyName: "description",
117
+ ...posting,
118
+ });
27
119
  const POSTS = {
28
120
  issue: {
29
- create: text("issue"),
30
- new: text("issue"),
31
- edit: text("issue"),
121
+ create: text("issue", ISSUE_CREATE),
122
+ new: text("issue", ISSUE_CREATE),
123
+ edit: text("issue", ISSUE_EDIT),
32
124
  comment: text("comment"),
33
- close: comment,
34
- reopen: comment,
125
+ close: comment(["--duplicate-of", "--reason", "-r"]),
126
+ reopen: comment(),
35
127
  },
36
128
  pr: {
37
- create: text("pr"),
38
- new: text("pr"),
39
- edit: text("pr"),
129
+ create: text("pr", PR_CREATE),
130
+ new: text("pr", PR_CREATE),
131
+ edit: text("pr", PR_EDIT),
40
132
  comment: text("comment"),
41
133
  review: text("review"),
42
- close: comment,
43
- reopen: comment,
44
- merge: { kind: "commit", flags: MERGE_FLAGS, noun: "this merge commit", titleName: "subject" },
134
+ close: comment(),
135
+ reopen: comment(),
136
+ merge: {
137
+ kind: "commit",
138
+ flags: MERGE_FLAGS,
139
+ valued: ["--author-email", "-A", "--match-head-commit"],
140
+ noun: "this merge commit",
141
+ titleName: "subject",
142
+ },
143
+ },
144
+ release: {
145
+ create: releaseCreate,
146
+ new: releaseCreate,
147
+ // The tag it's given now is checked, not the one it had, so a release's tag can be renamed.
148
+ edit: release({ flags: { ...RELEASE_FLAGS, "--tag": "name" } }),
149
+ },
150
+ gist: {
151
+ create: gistCreate,
152
+ new: gistCreate,
153
+ // gh gist edit <gist> [<file>]: the file replaces the one --filename picks, or is uploaded under the name --add
154
+ // gives. Without it, --add uploads the file it names.
155
+ edit: gist({
156
+ flags: { "--desc": "body", "-d": "body", "--add": "bodyFile", "-a": "bodyFile" },
157
+ valued: ["--filename", "-f", "--remove", "-r"],
158
+ args: (post, [, file]) => {
159
+ const added = post.bodyFiles.at(-1);
160
+ if (added !== undefined)
161
+ post.names.push(basename(added));
162
+ if (file !== undefined)
163
+ post.bodyFiles = [file];
164
+ },
165
+ }),
166
+ },
167
+ label: {
168
+ create: label({
169
+ args: (post, [name]) => {
170
+ if (name !== undefined)
171
+ post.title.push(name);
172
+ },
173
+ }),
174
+ // The name it's given now is checked, not the one it had, so a label can be renamed.
175
+ edit: label({ flags: { ...DESCRIPTION_FLAGS, "--name": "title", "-n": "title" } }),
176
+ },
177
+ repo: {
178
+ create: repoCreate,
179
+ new: repoCreate,
180
+ edit: repository({
181
+ valued: ["--add-topic", "--default-branch", "--homepage", "-h", "--remove-topic",
182
+ "--squash-merge-commit-message", "--visibility"],
183
+ }),
184
+ rename: repository({ flags: {}, args: repoName }),
45
185
  },
46
186
  };
47
187
  // The text a posting gh command sends, or undefined for any other command, which passes straight through.
@@ -64,17 +204,22 @@ export function parseGh(args) {
64
204
  kind: posting.kind,
65
205
  noun: posting.noun ?? KIND_NAMES[posting.kind],
66
206
  titleName: posting.titleName ?? "title",
207
+ bodyName: posting.bodyName ?? "body",
208
+ fileName: posting.fileName ?? posting.bodyName ?? "body",
209
+ nameName: posting.nameName ?? "name",
67
210
  title: [],
68
211
  body: [],
69
212
  bodyFiles: [],
213
+ names: [],
70
214
  };
215
+ const takesValue = (flag) => flag in posting.flags || posting.valued.includes(flag) || flag === "-R" || flag === "--repo";
216
+ // The arguments, the command words first.
217
+ const positional = [];
71
218
  for (let i = 0; i < args.length; i++) {
72
219
  const arg = args[i];
73
- if (arg === "--")
220
+ if (arg === "--") {
221
+ positional.push(...args.slice(i + 1));
74
222
  break;
75
- if (arg === "-R" || arg === "--repo") {
76
- i++;
77
- continue;
78
223
  }
79
224
  let name;
80
225
  let value;
@@ -85,40 +230,66 @@ export function parseGh(args) {
85
230
  value = arg.slice(eq + 1);
86
231
  }
87
232
  else if (arg.startsWith("-") && arg.length > 1) {
88
- // A short flag takes its value from the next argument, or joined: "-tTitle" or "-t=Title".
89
- name = arg.slice(0, 2);
90
- if (arg.length > 2)
91
- value = arg.slice(arg[2] === "=" ? 3 : 2);
233
+ const flag = shortFlag(arg, takesValue);
234
+ if (flag === undefined)
235
+ continue;
236
+ ({ name, value } = flag);
92
237
  }
93
238
  else {
239
+ positional.push(arg);
94
240
  continue;
95
241
  }
96
242
  const field = posting.flags[name];
97
- if (field === undefined)
243
+ if (field === undefined) {
244
+ if (value === undefined && takesValue(name))
245
+ i++;
98
246
  continue;
247
+ }
99
248
  if (value === undefined)
100
249
  value = args[++i];
101
250
  if (value === undefined)
102
251
  continue;
103
252
  if (field === "bodyFile")
104
253
  post.bodyFiles.push(value);
254
+ else if (field === "name")
255
+ post.names.push(value);
105
256
  else
106
257
  post[field].push(value);
107
258
  }
259
+ posting.args?.(post, positional.slice(2));
108
260
  return post;
109
261
  }
262
+ // A group of short flags, read as gh reads them: the first that takes a value takes the rest of the argument, past
263
+ // an "=", or the next argument, which `value` is left undefined for. Those before it are switches, and a switch
264
+ // given "=" takes the rest as its own. Undefined when the group is all switches.
265
+ export function shortFlag(arg, takesValue) {
266
+ let j = 1;
267
+ for (; j < arg.length && !takesValue(`-${arg[j]}`); j++) {
268
+ if (arg[j + 1] === "=" && j + 2 < arg.length)
269
+ return undefined;
270
+ }
271
+ if (j === arg.length)
272
+ return undefined;
273
+ if (j + 1 === arg.length)
274
+ return { name: `-${arg[j]}`, value: undefined };
275
+ return { name: `-${arg[j]}`, value: arg.slice(arg[j + 1] === "=" && j + 2 < arg.length ? j + 2 : j + 1) };
276
+ }
110
277
  // Each text a posting gh command sends, with where it goes. `read` gives a body file's text, "-" being stdin.
111
278
  export function postTexts(post, read) {
112
279
  const noun = post.noun;
113
280
  return [
114
281
  ...post.title.map((t) => [`the ${post.titleName} of ${noun}`, t]),
115
- ...post.body.map((t) => [`the body of ${noun}`, t]),
282
+ ...post.body.map((t) => [`the ${post.bodyName} of ${noun}`, t]),
116
283
  ...post.bodyFiles.map((f) => [
117
- f === "-" ? `the body of ${noun}` : `the body of ${noun}, from ${f}`,
284
+ f === "-" ? `the ${post.fileName} of ${noun}` : `the ${post.fileName} of ${noun}, from ${f}`,
118
285
  read(f),
119
286
  ]),
120
287
  ];
121
288
  }
289
+ // Each name a posting gh command gives, with where it goes. A name is checked as a branch name is.
290
+ export function postNames(post) {
291
+ return post.names.map((n) => [`the ${post.nameName} of ${post.noun}`, n]);
292
+ }
122
293
  export function shimScript(command) {
123
294
  return `#!/bin/sh
124
295
  ${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, 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,14 @@ 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 = texts
344
- .map(([where, text]) => ({ where, findings: check(text, { identity, rules, kind: post.kind }) }))
345
- .filter((s) => s.findings.length > 0);
343
+ const sections = [
344
+ ...postNames(post).map(([where, name]) => ({ where, findings: checkName(name, identity, rules) })),
345
+ ...texts.map(([where, text]) => ({ where, findings: check(text, { identity, rules, kind: post.kind }) })),
346
+ ].filter((s) => s.findings.length > 0);
346
347
  if (sections.length > 0) {
347
348
  io.err(report(sections, FIX_TEXT));
348
349
  return EXIT.findings;
@@ -412,6 +413,8 @@ function claudeRefusal(io, call) {
412
413
  const { identity, rules: merged, exemptFiles } = rules.get(post.cwd);
413
414
  const exempt = post.file !== undefined && exemptMatcher(exemptFiles)(post.file);
414
415
  const options = { identity, rules: merged, kind: post.kind };
416
+ if (post.name)
417
+ return { where: post.where, findings: checkName(post.text, identity, merged) };
415
418
  return { where: post.where, findings: exempt ? checkTerms(post.text, options) : check(post.text, options) };
416
419
  })
417
420
  .filter((s) => s.findings.length > 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "second-read",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A privacy check on agent-written commits, issues, PRs and comments, for any coding agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",