second-read 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dean Verleger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,216 @@
1
- # Temporary Holding Version
1
+ # second-read
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ _A privacy check on agent-written commits, issues, PRs and comments, for any coding agent._
4
+
5
+ Coding agents write a lot of prose on your behalf: commit messages, issue and PR bodies, review comments. Some of it
6
+ mentions things about you that you'd never post yourself, such as a path on your machine, the name of a private
7
+ project, or a detail about your accounts. Nobody can read every line an agent posts, and a rule in a prompt is
8
+ followed most of the time, not every time. Once a slip is posted, it's hard to take back: edit histories keep old
9
+ revisions, and a commit is public until the history is rewritten.
10
+
11
+ second-read reads what's about to go out and stops it when it finds something private. It doesn't rewrite your text.
12
+ It refuses, names what it found, and leaves the fix to you or your agent.
13
+
14
+ > **Status:** early. The check, the git and `gh` guards and the Claude Code plugin run; the model check, other agent
15
+ > adapters and the sweep are planned.
16
+ > [PLAN.md](./PLAN.md) is the design.
17
+
18
+ ## What it checks
19
+
20
+ - **Paths on your machine:** anything under your home folder, temporary and scratch folders.
21
+ - **Who you are on this machine:** your account name, your machine's name and email addresses, apart from the
22
+ ones you allow (such as GitHub's `noreply` address). Your own address, git's `user.email`, is checked whatever a
23
+ repository allows.
24
+ - **Money and accounts:** plans, billing, payments, spending, usage limits, what you will or won't pay for, and
25
+ credentials.
26
+ - **Your own list of private words:** project, folder, character or client names, in a file on your machine that is
27
+ never checked in anywhere.
28
+ - **What you told the agent in private**, where an agent tool can run a model check. That means your reasons,
29
+ feelings, frustrations and preferences, and your life outside the work, quoted or paraphrased. A post should say
30
+ what changed, not why you personally decided it, unless that reason is about the work.
31
+ - **Other paraphrases**, by the same model check: "your disk is nearly full", a description of your accounts or
32
+ your setup.
33
+
34
+ It isn't a secrets scanner. Tools like gitleaks find keys and tokens, and second-read is meant to run beside one.
35
+
36
+ ## Where it runs
37
+
38
+ - **git hooks:** commit messages, and the lines a push adds.
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.
41
+ - **Agent adapters**, for posts that don't go through `git` or `gh`, such as a GitHub tool an agent calls directly.
42
+ Claude Code comes first. Other agents follow, each written from its tool's own hook docs.
43
+ - **A sweep** of what's already posted to a repository, edit histories included.
44
+
45
+ The check runs on your machine, and the text it reads goes nowhere else.
46
+
47
+ ## Install
48
+
49
+ second-read needs Node 22 or newer.
50
+
51
+ ```sh
52
+ npm install -g second-read
53
+ second-read init
54
+ ```
55
+
56
+ `init` writes your config file and says how to add your private terms. To update later, run
57
+ `npm install -g second-read` again.
58
+
59
+ ## Using the check
60
+
61
+ ```sh
62
+ second-read init # writes your config file, with no terms in it
63
+ echo "$MESSAGE" | second-read check --kind commit
64
+ ```
65
+
66
+ `check` exits `0` when the text is clean, `1` with findings and `2` when the check itself fails, so a guard can
67
+ refuse on anything but `0`. The report names each finding's category, line and text, and is printed only on your
68
+ machine.
69
+
70
+ Your config file is `~/.config/second-read/config.toml` (`$XDG_CONFIG_HOME`, or `%APPDATA%` on Windows):
71
+
72
+ ```toml
73
+ terms = ["example-private-project"] # never posted; see below
74
+ allowed_words = ["billing"] # money words that are fine to post
75
+ allowed_emails = ["*@example.com"]
76
+ allowed_paths = ["~/.config/some-tool"]
77
+ allowed_names = ["juniper"] # your account or machine name, when it's an ordinary word
78
+ ```
79
+
80
+ A term matches in any case, with or without accents, in fullwidth forms, with invisible characters inside it, and
81
+ with its words joined by spaces, hyphens or nothing. It doesn't catch misspellings.
82
+
83
+ The machine name isn't flagged when it's one a system gives by default, such as `MacBook-Pro`, `raspberrypi` or
84
+ `penguin`, or a word for the kind of computer, such as `laptop`. Any other name is, even an ordinary word, since a
85
+ name someone chose can say who they are. If yours is a word that's fine to post, put it in `allowed_names`, and
86
+ the same goes for an account name. Paths with your account name in them are still flagged.
87
+
88
+ A repository can check in a `.second-read.toml` with the same `allowed_*` keys, but for `allowed_names`: naming
89
+ your machine there would post it. It can't hold `terms`, and it's an error if it names one of your private terms.
90
+ Since anyone who can change the repository can change it, it also can't switch a whole check off: `allowed_paths`
91
+ can't take in all of `~` or a temp folder, and each `allowed_emails` pattern needs a fixed domain (`*@example.com`
92
+ or `*@*.example.com`, not `*@*` or `*@*.com`). Your own file has none of these limits.
93
+
94
+ A pattern with a fixed domain can still take in a lot, such as `*@*.co.uk`, or `*@gmail.com` and everyone there. So
95
+ your own addresses, each `user.email` git uses where the check runs, are checked whatever a repository allows,
96
+ unless one is a noreply address. Your own file allows one only by naming it exactly, as `git commit -s` needs: a
97
+ pattern such as `*@gmail.com` is for other people's addresses, and doesn't take in yours.
98
+
99
+ A repository whose code or tests hold paths, emails or money words on purpose can list those files in its
100
+ `.second-read.toml`:
101
+
102
+ ```toml
103
+ exempt_files = ["src/**/*.test.ts", "fixtures/"]
104
+ ```
105
+
106
+ `pre-push` checks the lines a push adds to them for your private terms only. Each glob is a path from the top of the
107
+ repository: `*` and `?` stay within one folder, `**` takes in any number of them, and a trailing `/` takes in a
108
+ whole folder. A glob made only of wildcards and dots, such as `**` or `**/*.*`, is an error, since it takes in nearly
109
+ every file; one for dotfiles, such as `**/.*`, is fine. Commit and tag messages, branch and tag names, and everything
110
+ the `gh` shim sends are still checked in full, a gist's files included: a gist is a post of its own.
111
+
112
+ ## The guards
113
+
114
+ ```sh
115
+ second-read hooks install # in a repository: adds the commit-msg and pre-push hooks
116
+ second-read shim install # writes a gh shim, and says which folder to put ahead of gh on PATH
117
+ ```
118
+
119
+ - **`commit-msg`** checks the message git is about to commit.
120
+ - **`pre-push`** checks the message and the added lines of every commit the remote doesn't have yet (for a merge, the
121
+ lines in none of its parents, such as a conflict resolution's), and the name each branch and tag gets on the remote,
122
+ with the same checks as a message: a branch called `fix-billing-page` or `paid-plan` is refused for the money words
123
+ unless the allowlist lets them through. An annotated tag's own name is checked too, since it goes with the tag
124
+ whatever the remote calls it. Deleting a branch or tag is never refused, since that's how a leaked name is taken
125
+ back. It also refuses a push that changes a `.second-read.toml`, and prints an id for each change: the new version's
126
+ blob, or the commit that deletes the file. Once you've read the change, push again with
127
+ `SECOND_READ_ALLOWLIST_REVIEWED="<id>"`. An id covers only that change, so a later one is refused again.
128
+ - **The `gh` shim** checks `--title`, `--body`, `--body-file` and a body read from stdin, for `gh issue` and
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` 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.
135
+
136
+ Each guard refuses on a finding, and also when the check itself fails. A hook that was already in place is set aside
137
+ and still runs after the check; `second-read hooks uninstall` removes only second-read's hooks and puts it back. The
138
+ repository's `.second-read.toml` is read from the repository the hook runs in, or from the folder the shim runs in.
139
+
140
+ When `core.hooksPath` points at a checked-in folder, the hooks go into a folder inside `.git` instead, and this
141
+ clone's `core.hooksPath` points there. The repository's hooks still run, including ones added later: each hook
142
+ githooks(5) lists, except `push-to-checkout`. git runs `reference-transaction` and `post-index-change` very often, so
143
+ they run only if the repository had them when you installed; install again after adding one. `hooks uninstall` puts
144
+ `core.hooksPath` back. Running the repository's own setup step again, if it sets `core.hooksPath`, turns the guards
145
+ off until you install them again.
146
+
147
+ ## The Claude Code plugin
148
+
149
+ The plugin checks what Claude Code is about to post, before the tool call runs. When it finds something, it denies
150
+ the call and gives Claude the report as the reason, so Claude can fix the text and try again. It needs Claude Code
151
+ 2.1.295 or newer and Node 22.18 or newer on `PATH`, and it reads the same config files as the guards.
152
+
153
+ 1. Add this repository as a plugin marketplace:
154
+
155
+ ```sh
156
+ claude plugin marketplace add deanbot/second-read
157
+ ```
158
+
159
+ 2. Install the plugin:
160
+
161
+ ```sh
162
+ claude plugin install second-read@second-read
163
+ ```
164
+
165
+ 3. Check that it's installed and enabled:
166
+
167
+ ```sh
168
+ claude plugin list
169
+ ```
170
+
171
+ It checks:
172
+
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
175
+ is checked when it's a mutation.
176
+ - **`git commit` and `git tag` messages**, from `-m`, `-F` and `--trailer`. What a push sends is the `pre-push`
177
+ hook's to check.
178
+ - **GitHub tools from MCP servers:** any tool with `github` in its name, in any case, apart from the ones that only
179
+ read (`get_`, `list_`, `search_`, `_read`). Every string it's given is checked, except `owner` and `repo`. A
180
+ file's contents are checked like the lines a push adds, so `exempt_files` applies to them.
181
+
182
+ A body can come from a heredoc (`--body "$(cat <<'EOF' … EOF)"`), a here-string, a file, or `cat`, `echo` or
183
+ `printf` piped in. A file the same command writes first, as in `cat > body.md <<'EOF'`, is read from the command,
184
+ since the hook runs before it does. Posts inside `$(…)` and backticks are checked too. A body the shell fills in
185
+ only when the command runs, such as `--body "$BODY"` or what `xargs` adds, can't be read beforehand, so the call is
186
+ denied. Put the text in the command, or in a file and pass its path.
187
+
188
+ The plugin never approves a call. A clean post goes on to Claude Code's usual permission check. Its deny holds in
189
+ `bypassPermissions` mode too: a session run with `--permission-mode bypassPermissions` on Claude Code 2.1.302 had
190
+ its leaking post refused.
191
+
192
+ If the hook itself fails or times out, the call is denied. A hook that can't run at all, because `node` isn't on
193
+ `PATH` or is older than 22.18, denies every Bash call, not only posts. To get out of that:
194
+
195
+ 1. Turn the plugin off, from a terminal:
196
+
197
+ ```sh
198
+ claude plugin disable second-read@second-read
199
+ ```
200
+
201
+ 2. Install Node 22.18 or newer.
202
+
203
+ 3. Turn the plugin back on:
204
+
205
+ ```sh
206
+ claude plugin enable second-read@second-read
207
+ ```
208
+
209
+ Before Claude Code 2.1.295, the hook's `onFailure` setting is ignored, so a hook that crashes lets the call through.
210
+
211
+ A setup script can tell whether the plugin is in place with `claude plugin list --json`, which lists it as
212
+ `"id": "second-read@second-read"` with `"enabled": true`.
213
+
214
+ ## License
215
+
216
+ [MIT](./LICENSE)
package/dist/checks.js ADDED
@@ -0,0 +1,325 @@
1
+ import { escapeRegExp, fold, originalSpan, termPattern, wordPattern } from "./normalize.js";
2
+ export const KINDS = ["commit", "issue", "pr", "comment", "review"];
3
+ export const KIND_NAMES = {
4
+ commit: "this commit message",
5
+ issue: "this issue",
6
+ pr: "this pull request",
7
+ comment: "this comment",
8
+ review: "this review",
9
+ };
10
+ export const DEFAULT_ALLOWED_EMAILS = [
11
+ "*@users.noreply.github.com",
12
+ "noreply@github.com",
13
+ "git@github.com",
14
+ "noreply@*",
15
+ "no-reply@*",
16
+ "*@users.noreply.gitlab.com",
17
+ "*@example.com",
18
+ "*@example.org",
19
+ "*@example.net",
20
+ "*@*.example",
21
+ "*@*.test",
22
+ "*@*.invalid",
23
+ ];
24
+ // Generic places under the home folder that say nothing about the person.
25
+ export const DEFAULT_ALLOWED_PATHS = [
26
+ "~/.config",
27
+ "~/.local",
28
+ "~/.cache",
29
+ "~/.ssh",
30
+ "~/.gitconfig",
31
+ "~/.npmrc",
32
+ "~/.bashrc",
33
+ "~/.bash_profile",
34
+ "~/.zshrc",
35
+ "~/.profile",
36
+ ];
37
+ // Account and machine names too generic to flag.
38
+ const GENERIC_NAMES = new Set([
39
+ "admin", "app", "dev", "ec2-user", "guest", "localhost", "node", "nobody", "root", "runner", "ubuntu", "user",
40
+ "vagrant",
41
+ ].map(nameKey));
42
+ // Machine names a system or an installer sets, and words for the kind or model of computer. A word someone might pick
43
+ // as a name stays out, since a chosen name can say who they are; the user's own allowed_names takes in those. These
44
+ // never apply to the account name, which is often the person's own name, and some of these are given names.
45
+ const DEFAULT_MACHINE_NAMES = new Set([
46
+ "Mac", "MacBook", "MacBook-Air", "MacBook-Pro", "Mac-mini", "Mac-Pro", "Mac-Studio", "iMac", "iMac-Pro",
47
+ "computer", "desktop", "laptop", "notebook", "workstation", "server",
48
+ "raspberrypi", "penguin", "steamdeck", "debian", "fedora", "kali", "archiso", "manjaro", "pop-os", "nixos",
49
+ "docker-desktop",
50
+ ].map(nameKey));
51
+ // A name as it's compared with the lists above: folded, without separators, and without the number a system adds
52
+ // to tell two machines apart, as in "MacBook-Pro-2".
53
+ function nameKey(name) {
54
+ return fold(name.replace(/[-_ ]\d+$/, "")).text.replace(/[^\p{L}\p{N}]+/gu, "");
55
+ }
56
+ const TEMP_FOLDERS = ["/tmp", "/var/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
57
+ // Money and accounts, over folded text. Each entry is [word, pattern]; `allowed_words` switches off a word.
58
+ // A word that's also ordinary dev language (subscription, plan, tier, budget, quota) counts only beside a money word.
59
+ const MONEY = [
60
+ ["billing", "billing|billed"],
61
+ ["invoice", "invoices?|invoiced"],
62
+ ["payment", "payments?|(?:pay|paid|paying) for|pay as you go|pay-as-you-go"],
63
+ [
64
+ "subscription",
65
+ "(?:my|monthly|annual|yearly|weekly|free|paid|premium|pro|business|enterprise|trial) subscriptions?" +
66
+ "|subscriptions? (?:fees?|prices?|pricing|costs?|charges?|renewals?|payments?|billing|plans?|tiers?)" +
67
+ "|(?:(?:upgrad|downgrad)(?:e|es|ed|ing)|cancel(?:s|led|ling|ed|ing)?|renew(?:s|ed|ing)?)" +
68
+ " (?:my|our|the|your|a|their) subscriptions?",
69
+ ],
70
+ [
71
+ "spending",
72
+ "spend(?:ing)? (?:limits?|caps?|alerts?)|spending money" +
73
+ "|(?:my|our|their|monthly|annual|yearly|personal|household|cloud) spending",
74
+ ],
75
+ ["budget", "(?:my|our|monthly|annual|yearly|weekly|personal|household|money|cash|spending) budgets?"],
76
+ [
77
+ "plan",
78
+ "(?:free|paid|pro|max|plus|team|business|enterprise|premium|pricing|billing|monthly|annual|yearly) plans?" +
79
+ "|(?:(?:upgrad|downgrad|chang)(?:e|es|ed|ing)|cancel(?:s|led|ling|ed|ing)?) (?:my|our|the|your|a|their) plan",
80
+ ],
81
+ ["tier", "(?:free|paid|pro|premium) tiers?"],
82
+ ["pricing", "pricing"],
83
+ [
84
+ "usage limit",
85
+ "usage (?:limits?|caps?|quotas?)|overage (?:charges?|fees?|costs?|pricing)" +
86
+ "|(?:billing|plan|paid|subscription|account) quotas?",
87
+ ],
88
+ ["card", "credit cards?|debit cards?|bank accounts?|bank statements?"],
89
+ ["refund", "refunds?|refunded|chargebacks?"],
90
+ ["trial", "free trials?|trial period"],
91
+ ];
92
+ // Amounts of money. A "$" before a single digit is left alone, since "$1" is usually a shell argument.
93
+ const AMOUNT = /(?<![\p{L}\p{N}$])(?:\$\s?(?:\d{1,3}(?:,\d{3})+|\d{2,}|\d(?=[.,]\d|\s?(?:k|m|bn)(?![\p{L}])|\s?(?:\/|a |per )))|[€£¥₹]\s?\d)[\d,]*(?:\.\d+)?(?:\s?(?:k|m|bn)(?![\p{L}]))?|(?<![\p{L}\p{N}.])\d[\d,]*(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|euros?|bucks)(?![\p{L}])/gu;
94
+ const EMAIL = /(?<![\p{L}\p{N}._%+-])[\p{L}\p{N}._%+-]+@[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.\p{L}{2,}(?![\p{L}\p{N}-])/gu;
95
+ const PATH_TAIL = "[^\\s'\"`<>()\\[\\]{}|,;]*";
96
+ const TRAILING_PUNCTUATION = /[.,:;!?]+$/;
97
+ export function check(input, options) {
98
+ const { identity, rules } = options;
99
+ const folded = fold(input);
100
+ const allowedWords = new Set(rules.allowedWords.map((w) => collapse(fold(w).text)));
101
+ const allowedPaths = [...DEFAULT_ALLOWED_PATHS, ...rules.allowedPaths].map(canonicalPath);
102
+ const allowedEmails = [...DEFAULT_ALLOWED_EMAILS, ...rules.allowedEmails].map(globPattern);
103
+ const spans = [];
104
+ // Text that's fine to post: an allowed email or path. A name or word inside it isn't a finding either.
105
+ const allowed = [];
106
+ for (const prefix of homePrefixes(identity)) {
107
+ const re = new RegExp(`(?<![\\w.-])${escapeRegExp(prefix)}(?![\\w.-])${PATH_TAIL}`, "gi");
108
+ for (const m of input.matchAll(re))
109
+ spans.push(pathSpan("home path", m));
110
+ }
111
+ const tilde = new RegExp(`(?<![\\w~/.$-])(?:~|\\$HOME|\\$\\{HOME\\}|%USERPROFILE%)([\\\\/]${PATH_TAIL})`, "g");
112
+ for (const m of input.matchAll(tilde)) {
113
+ const span = pathSpan("home path", m);
114
+ const path = canonicalPath("~" + input.slice(m.index + m[0].length - m[1].length, span.end));
115
+ if (path === "~" || isAllowedPath(path, allowedPaths))
116
+ allowed.push([span.start, span.end]);
117
+ else
118
+ spans.push(span);
119
+ }
120
+ const temps = [...TEMP_FOLDERS, ...identity.tempDirs.map(trimSlash).filter((d) => d.length > 1)];
121
+ for (const prefix of new Set(temps)) {
122
+ const re = new RegExp(`(?<![\\w.~/$%-])${escapeRegExp(prefix)}(?![\\w.-])${PATH_TAIL}`, "g");
123
+ for (const m of input.matchAll(re)) {
124
+ const span = pathSpan("temp folder", m);
125
+ if (isAllowedPath(canonicalPath(input.slice(span.start, span.end)), allowedPaths)) {
126
+ allowed.push([span.start, span.end]);
127
+ }
128
+ else
129
+ spans.push(span);
130
+ }
131
+ }
132
+ for (const m of input.matchAll(EMAIL)) {
133
+ const email = m[0].toLowerCase();
134
+ const span = { category: "email", start: m.index, end: m.index + m[0].length };
135
+ if (allowedEmails.some((re) => re.test(email)))
136
+ allowed.push([span.start, span.end]);
137
+ else
138
+ spans.push(span);
139
+ }
140
+ const names = [
141
+ ["account name", identity.user],
142
+ ["machine name", identity.host.split(".")[0] ?? ""],
143
+ ];
144
+ // Only the user's own file can allow a name: naming it in a repository would post it.
145
+ const allowedNames = new Set(rules.allowedNames.map(nameKey));
146
+ for (const [category, name] of names) {
147
+ const key = nameKey(name);
148
+ if (name.length < 3 || GENERIC_NAMES.has(key) || allowedNames.has(key))
149
+ continue;
150
+ if (category === "machine name" && DEFAULT_MACHINE_NAMES.has(key))
151
+ continue;
152
+ const re = termPattern(name);
153
+ if (re)
154
+ spans.push(...foldedSpans(folded, re, category));
155
+ }
156
+ for (const [word, source] of MONEY) {
157
+ if (allowedWords.has(word))
158
+ continue;
159
+ spans.push(...foldedSpans(folded, wordPattern(source), "money", allowedWords, word));
160
+ }
161
+ if (!allowedWords.has("amounts"))
162
+ spans.push(...foldedSpans(folded, AMOUNT, "money", allowedWords, "amounts"));
163
+ // Private terms and the user's own addresses are never allowed by a repository, wherever they appear. They come
164
+ // first, so the user's address is reported as theirs rather than as an email outside the allowlist.
165
+ const shown = [...neverAllowed(folded, options), ...outsideAllowed(spans, allowed)];
166
+ return toFindings(input, outermost(shown));
167
+ }
168
+ // Only the private terms and the user's own addresses, for text whose other findings are the repository's own
169
+ // business, such as an exempt file's added lines. A term inside a path or an email is found here, where check
170
+ // reports the wider span.
171
+ export function checkTerms(input, options) {
172
+ return toFindings(input, outermost(neverAllowed(fold(input), options)));
173
+ }
174
+ function neverAllowed(folded, { identity, rules }) {
175
+ const terms = rules.terms.flatMap((term) => {
176
+ const re = termPattern(term);
177
+ return re ? foldedSpans(folded, re, "private term") : [];
178
+ });
179
+ return [...terms, ...ownEmailSpans(folded, identity.emails, rules.userAllowedEmails)];
180
+ }
181
+ // The user's own addresses, whatever a repository allows: a pattern there such as "*@*.co.uk" or "*@gmail.com"
182
+ // takes in theirs too. A noreply address is made to be posted. The user's own file allows one only by naming it
183
+ // exactly, since a pattern there is for other people's addresses.
184
+ function ownEmailSpans(folded, emails, userAllowed) {
185
+ const allowed = new Set(userAllowed.map((e) => fold(e).text));
186
+ const own = new Set(emails.map((e) => fold(e.trim()).text).filter((e) => !isNoreply(e) && !allowed.has(e)));
187
+ if (own.size === 0)
188
+ return [];
189
+ const spans = [];
190
+ for (const m of folded.text.matchAll(EMAIL)) {
191
+ if (!own.has(m[0]))
192
+ continue;
193
+ const [start, end] = originalSpan(folded, m.index, m.index + m[0].length);
194
+ spans.push({ category: "your email", start, end });
195
+ }
196
+ return spans;
197
+ }
198
+ const NOREPLY_DOMAINS = ["users.noreply.github.com", "users.noreply.gitlab.com"];
199
+ function isNoreply(email) {
200
+ const at = email.lastIndexOf("@");
201
+ const local = email.slice(0, at);
202
+ return local === "noreply" || local === "no-reply" || NOREPLY_DOMAINS.includes(email.slice(at + 1));
203
+ }
204
+ // An allowed path that takes in the whole home folder or a whole temp folder, which leaves that check nothing to do:
205
+ // "~" in any spelling, a temp folder's root, or a folder above one, such as "/var".
206
+ export function isBlanketPath(path) {
207
+ const allowed = [canonicalPath(path)];
208
+ return ["~", ...TEMP_FOLDERS].some((root) => isAllowedPath(root, allowed));
209
+ }
210
+ // An allowed email pattern with no fixed domain: "*@*", "someone@*", "*@*.com". The domain must be written out,
211
+ // or be "*." and then one with a dot in it, as in "*@*.example.org". A pattern with no "@" matches no email.
212
+ export function isBlanketEmail(glob) {
213
+ const at = glob.lastIndexOf("@");
214
+ if (at < 0)
215
+ return false;
216
+ const domain = glob.slice(at + 1);
217
+ return domain.includes("*") && !/^\*\.[^*.]+(?:\.[^*.]+)+$/.test(domain);
218
+ }
219
+ function homePrefixes(identity) {
220
+ const prefixes = new Set();
221
+ const home = trimSlash(identity.home);
222
+ if (home.length > 1) {
223
+ prefixes.add(home);
224
+ prefixes.add(home.replaceAll("\\", "/"));
225
+ }
226
+ if (identity.user) {
227
+ for (const p of ["/Users/", "/home/", "C:\\Users\\", "C:/Users/"])
228
+ prefixes.add(p + identity.user);
229
+ }
230
+ return prefixes;
231
+ }
232
+ function pathSpan(category, m) {
233
+ const text = m[0].replace(TRAILING_PUNCTUATION, "");
234
+ return { category, start: m.index, end: m.index + text.length };
235
+ }
236
+ function foldedSpans(folded, re, category, allowed, word) {
237
+ const spans = [];
238
+ for (const m of folded.text.matchAll(re)) {
239
+ if (m[0].length === 0)
240
+ continue;
241
+ if (allowed?.has(collapse(m[0])))
242
+ continue;
243
+ const [start, end] = originalSpan(folded, m.index, m.index + m[0].length);
244
+ spans.push(word === undefined ? { category, start, end } : { category, word, start, end });
245
+ }
246
+ return spans;
247
+ }
248
+ // Keeps a span only when no other span contains it, so a path isn't also reported for the account name in it.
249
+ function outermost(spans) {
250
+ const sorted = [...spans].sort((a, b) => a.start - b.start || b.end - a.end);
251
+ const kept = [];
252
+ let reach = -1;
253
+ for (const s of sorted) {
254
+ if (s.end <= reach)
255
+ continue;
256
+ kept.push(s);
257
+ reach = s.end;
258
+ }
259
+ return kept;
260
+ }
261
+ function outsideAllowed(spans, allowed) {
262
+ if (allowed.length === 0)
263
+ return spans;
264
+ const sortedAllowed = [...allowed].sort((a, b) => a[0] - b[0]);
265
+ const kept = [];
266
+ let next = 0;
267
+ let reach = -1;
268
+ for (const s of [...spans].sort((a, b) => a.start - b.start)) {
269
+ while (next < sortedAllowed.length && sortedAllowed[next][0] <= s.start) {
270
+ reach = Math.max(reach, sortedAllowed[next][1]);
271
+ next++;
272
+ }
273
+ if (s.end > reach)
274
+ kept.push(s);
275
+ }
276
+ return kept;
277
+ }
278
+ // Spans arrive sorted by start, so line and column are counted in one pass over the input.
279
+ function toFindings(input, spans) {
280
+ let i = 0;
281
+ let line = 1;
282
+ let column = 1;
283
+ return spans.map((s) => {
284
+ while (i < s.start) {
285
+ const code = input.codePointAt(i);
286
+ if (code === 0x0a) {
287
+ line++;
288
+ column = 1;
289
+ }
290
+ else
291
+ column++;
292
+ i += code > 0xffff ? 2 : 1;
293
+ }
294
+ const finding = {
295
+ category: s.category,
296
+ line,
297
+ column,
298
+ text: input.slice(s.start, s.end),
299
+ start: s.start,
300
+ end: s.end,
301
+ };
302
+ if (s.word !== undefined)
303
+ finding.word = s.word;
304
+ return finding;
305
+ });
306
+ }
307
+ function canonicalPath(path) {
308
+ const p = path
309
+ .replaceAll("\\", "/")
310
+ .replace(/^(?:\$HOME|\$\{HOME\}|%USERPROFILE%)(?=\/|$)/, "~");
311
+ return trimSlash(p);
312
+ }
313
+ function isAllowedPath(path, allowed) {
314
+ return allowed.some((a) => path === a || path.startsWith(a + "/"));
315
+ }
316
+ function globPattern(glob) {
317
+ const source = glob.toLowerCase().split("*").map(escapeRegExp).join("[^@\\s]*");
318
+ return new RegExp(`^${source}$`, "u");
319
+ }
320
+ function trimSlash(path) {
321
+ return path.length > 1 ? path.replace(/[\\/]+$/, "") : path;
322
+ }
323
+ function collapse(s) {
324
+ return s.trim().replace(/\s+/g, " ");
325
+ }