@open-tomato/gates 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,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,10 @@
1
+ @open-tomato/packages
2
+ Copyright 2026 Marcos Tomatti and the open-tomato contributors
3
+
4
+ Licensed under the Apache License, Version 2.0 (see LICENSE).
5
+
6
+ The configs, rules and gates in this repository began in other
7
+ open-tomato repositories and are developed here since: the ESLint and
8
+ TypeScript configs and the two ESLint rules in rafa, the control-byte
9
+ gate in rafa, the privacy gate in components, and the pack and publish
10
+ scripts in service.
package/README.md CHANGED
@@ -1,3 +1,115 @@
1
- # Temporary Holding Version
1
+ # @open-tomato/gates
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
+ Repository gates for the open-tomato projects. Each gate reads what git
4
+ tracks, reports by file and line, and exits with one of three codes:
5
+
6
+ | Exit | Meaning |
7
+ | --- | --- |
8
+ | `0` | clean |
9
+ | `1` | findings |
10
+ | `2` | the gate could not run |
11
+
12
+ A gate that cannot run never reports a pass. Both gates are TypeScript run
13
+ by bun, so they need bun 1.3 or newer.
14
+
15
+ ```bash
16
+ bun add -d @open-tomato/gates
17
+ ```
18
+
19
+ ## `ot-control-byte-gate`
20
+
21
+ Refuses raw control bytes and invisible characters in tracked text files.
22
+ A raw NUL makes git treat a file as binary, so its diffs stop rendering, and
23
+ makes grep miss text that is there. The gate also refuses the Trojan Source
24
+ BiDi overrides and the zero-width characters, the same set as
25
+ `@open-tomato/no-unsafe-unicode` in
26
+ [`@open-tomato/eslint-plugin`](../eslint-plugin/README.md). The gate covers
27
+ files ESLint never reads, such as YAML, shell scripts and lock files.
28
+
29
+ ```bash
30
+ bunx ot-control-byte-gate # every tracked file
31
+ bunx ot-control-byte-gate --staged # the index, for a pre-commit hook
32
+ bunx ot-control-byte-gate --include-untracked # also files not yet added
33
+ ```
34
+
35
+ ## `ot-privacy-gate`
36
+
37
+ Refuses personal and private data in what a public repository tracks:
38
+
39
+ - **Banned terms** from the project's own list, stored as sha256 hashes so
40
+ no file spells them: machine names, private domains, private
41
+ repositories, home paths.
42
+ - **Shapes** that need no list: a home directory with its account name, a
43
+ private or public IPv4 address, a LAN host name, a credential prefix, an
44
+ email outside the reserved example domains.
45
+ - **Sample data** in stories, fixtures, mocks and tests: a URL, a phone
46
+ number or a profile link must use a reserved example host or the
47
+ fictional 555-01xx range.
48
+
49
+ ```bash
50
+ bunx ot-privacy-gate # tracked files and untracked ones git would track
51
+ bunx ot-privacy-gate --staged # the index, for a pre-commit hook
52
+ bunx ot-privacy-gate --history # every blob and commit identity, before a first push
53
+ bunx ot-privacy-gate --root ../other-repo # a repository other than the current one
54
+ ```
55
+
56
+ Each mode reads what it judges from where a reviewer would see it:
57
+
58
+ - `--staged` reads the files and `.privacy-gate.json` from the index, so
59
+ an allowance edited locally but never staged cannot pass a commit.
60
+ - `--history` judges a blob under every path it was committed at,
61
+ because the sample rules and the allow list both depend on the path.
62
+ - An unknown flag, `--root` with no folder, or two modes at once stop the
63
+ gate with exit 2, so a typo never runs a different scan.
64
+
65
+ ### The project's list
66
+
67
+ The package ships no banned terms. Each project keeps its own in
68
+ `.privacy-gate.json` at the repository root:
69
+
70
+ ```json
71
+ {
72
+ "banned": [
73
+ { "sha256": "<sha256 of the lowercase term>", "label": "a private machine name" }
74
+ ],
75
+ "allow": [
76
+ { "path": "package.json", "rule": "email", "sha256": "<sha256 of the evidence>", "why": "The author field." }
77
+ ]
78
+ }
79
+ ```
80
+
81
+ A label names the term without spelling it. Print a term's hash with:
82
+
83
+ ```bash
84
+ bun -e 'console.log(new Bun.CryptoHasher("sha256").update(process.argv[1].toLowerCase()).digest("hex"))' '<term>'
85
+ ```
86
+
87
+ An `allow` entry is a reviewed exception: the path glob, the rule, the
88
+ sha256 of the exact text the gate reported, and why it is meant. Without a
89
+ config file, the gate runs the shape rules alone and says so in its report.
90
+ A malformed config stops the gate with exit 2, and so does a key other than
91
+ `banned`, `allow` and `$comment`, or an `allow` rule the gate does not have:
92
+ a typo there would otherwise empty a list without a word.
93
+
94
+ ## A pre-commit hook
95
+
96
+ ```bash
97
+ #!/usr/bin/env bash
98
+ set -euo pipefail
99
+ bunx ot-control-byte-gate --staged
100
+ bunx ot-privacy-gate --staged
101
+ ```
102
+
103
+ Save it as `.githooks/pre-commit`, make it executable, and turn it on once
104
+ per clone with `git config core.hooksPath .githooks`.
105
+
106
+ ## Exports
107
+
108
+ The gates are importable too, for a project that wraps them:
109
+
110
+ | Path | What it holds |
111
+ | --- | --- |
112
+ | `./control-bytes` | `scanText`, `scanBuffer`, `scanFiles`, `listFiles` |
113
+ | `./privacy` | `judge`, `readSources`, `readConfig`, `parseArgs`, `run` |
114
+ | `./privacy/rules` | `scan`, `bannedIn`, `isSamplePath`, `sha256`, `RULE_NAMES` |
115
+ | `./privacy/config` | `readPrivacyConfig`, `parsePrivacyConfig`, `CONFIG_FILE`, `NO_CONFIG` |
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@open-tomato/gates",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Repository gates of the open-tomato projects: a control-byte gate and a privacy gate, run in CI and as pre-commit hooks.",
5
+ "license": "Apache-2.0",
6
+ "author": "Marcos Tomatti",
7
+ "openTomato": {
8
+ "team": "@open-tomato/maintainers"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/open-tomato/packages.git",
13
+ "directory": "packages/gates"
14
+ },
15
+ "homepage": "https://github.com/open-tomato/packages/tree/main/packages/gates#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/open-tomato/packages/issues"
18
+ },
19
+ "keywords": [
20
+ "gate",
21
+ "privacy",
22
+ "pre-commit",
23
+ "trojan-source",
24
+ "control-characters"
25
+ ],
26
+ "type": "module",
27
+ "bin": {
28
+ "ot-control-byte-gate": "./src/control-byte-gate.ts",
29
+ "ot-privacy-gate": "./src/privacy-gate.ts"
30
+ },
31
+ "exports": {
32
+ "./control-bytes": "./src/control-byte-gate.ts",
33
+ "./privacy": "./src/privacy-gate.ts",
34
+ "./privacy/rules": "./src/privacy-rules.ts",
35
+ "./privacy/config": "./src/privacy-config.ts",
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "src",
40
+ "LICENSE",
41
+ "NOTICE"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org/"
46
+ },
47
+ "engines": {
48
+ "bun": ">=1.3.0"
49
+ },
50
+ "scripts": {
51
+ "lint": "eslint .",
52
+ "check-types": "tsc --noEmit",
53
+ "test": "bun test"
54
+ },
55
+ "devDependencies": {
56
+ "@open-tomato/eslint-config": "^0.1.1",
57
+ "@open-tomato/eslint-plugin": "^0.1.1",
58
+ "@open-tomato/typescript-config": "^0.1.1",
59
+ "@types/bun": "^1.3.0",
60
+ "eslint": "^9.39.5",
61
+ "typescript": "^5.9.3"
62
+ }
63
+ }
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Control-byte gate:
4
+ * A raw C0 control byte in a tracked text file breaks two tools at once,
5
+ * and both failures are silent false negatives:
6
+ *
7
+ * - git classifies the blob as binary, so every diff renders as
8
+ * `Bin N -> M bytes` and the file can never be reviewed again.
9
+ * - POSIX grep reports NO MATCH for strings that are present in the
10
+ * file, so any absence check built on grep passes vacuously.
11
+ *
12
+ * Source must spell control characters as language escapes (the
13
+ * six-character backslash-u form), never as the literal byte. Runtime
14
+ * behaviour is identical; tool visibility is not.
15
+ *
16
+ * Usage:
17
+ * ot-control-byte-gate # all tracked files
18
+ * ot-control-byte-gate --staged # staged files only
19
+ * ot-control-byte-gate --include-untracked # + files not yet git-added
20
+ * ot-control-byte-gate --root <repo-root>
21
+ *
22
+ * Exit codes: 0 clean, 1 findings, 2 the gate could not run.
23
+ * 2 is distinct from 0 on purpose — a gate that cannot run must never be
24
+ * mistaken for a gate that passed.
25
+ */
26
+ import { execFileSync } from 'node:child_process';
27
+ import { readFileSync, statSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+
30
+ /**
31
+ * The NUL character and the escape spelling this gate recommends, built
32
+ * from code points rather than written as escapes. A backslash-u escape
33
+ * with four hex digits is valid JSON, so content carrying one through a
34
+ * JSON-decoding write path (an agent tool call) arrives with the escape
35
+ * already collapsed to the real byte — the exact accident this gate
36
+ * exists to catch. Built this way, this file can never seed it.
37
+ */
38
+ const NUL = String.fromCharCode(0);
39
+ const BACKSLASH_U = String.fromCharCode(92) + 'u';
40
+
41
+ /**
42
+ * Bytes that must never appear raw in a tracked text file: every C0
43
+ * control character except TAB/LF/CR, plus DEL. NUL (0x00) is the one
44
+ * git and grep key their binary classification on; ESC (0x1b) does not
45
+ * trip the classifier but makes a file unreadable and unmatchable by
46
+ * edit tools.
47
+ */
48
+ export const FORBIDDEN_BYTES: ReadonlySet<number> = new Set([
49
+ ...Array.from({ length: 0x20 }, (_, b) => b).filter(
50
+ (b) => b !== 0x09 && b !== 0x0a && b !== 0x0d,
51
+ ),
52
+ 0x7f,
53
+ ]);
54
+
55
+ /**
56
+ * Codepoints that are invisible or misleading rather than merely control
57
+ * bytes. These encode as bytes >= 0x80, so the byte scan above cannot
58
+ * see them — a Trojan Source payload (CVE-2021-42574) is entirely in
59
+ * this set.
60
+ *
61
+ * Kept deliberately in sync with `@open-tomato/no-unsafe-unicode` in
62
+ * `@open-tomato/eslint-plugin`; the gate's test suite asserts the two
63
+ * agree. The duplication is on
64
+ * purpose: this gate imports nothing, so it runs on a bare checkout with
65
+ * no install.
66
+ */
67
+ export const FORBIDDEN_CODEPOINTS: ReadonlySet<number> = new Set([
68
+ // Bidirectional overrides and isolates — Trojan Source.
69
+ 0x202a, 0x202b, 0x202c, 0x202d, 0x202e,
70
+ 0x2066, 0x2067, 0x2068, 0x2069,
71
+ // Invisible / default-ignorable.
72
+ 0x00ad, 0x200b, 0x200e, 0x200f, 0x2060, 0xfeff,
73
+ // Parser-splitting separators.
74
+ 0x2028, 0x2029,
75
+ ]);
76
+
77
+ /** Legitimate between two emoji, suspicious anywhere else. */
78
+ export const EMOJI_JOINERS: ReadonlySet<number> = new Set([0x200c, 0x200d]);
79
+
80
+ const EMOJI_ADJACENT =
81
+ /[\p{Extended_Pictographic}\p{Emoji_Modifier}\u{FE0F}]/u;
82
+
83
+ /**
84
+ * Extensions whose files are legitimately binary. This is an ALLOWLIST,
85
+ * and it is the gate's only escape hatch: anything not listed here is
86
+ * scanned. The inverse design — skipping unknown extensions — is what
87
+ * lets a control byte slip through unnoticed, so do not invert it.
88
+ */
89
+ export const BINARY_EXTENSIONS: ReadonlySet<string> = new Set([
90
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'icns', 'bmp', 'tiff',
91
+ 'woff', 'woff2', 'ttf', 'otf', 'eot',
92
+ 'pdf', 'zip', 'gz', 'tgz', 'br', 'xz', '7z',
93
+ 'mp3', 'mp4', 'webm', 'wav', 'mov', 'ogg',
94
+ 'wasm', 'bin', 'lockb', 'node', 'dylib', 'so', 'class', 'jar',
95
+ 'p12', 'der', 'keystore', 'jks',
96
+ ]);
97
+
98
+ /** Explicit per-path exemptions. Keep empty unless a file truly cannot comply. */
99
+ export const ALLOWLISTED_PATHS: ReadonlySet<string> = new Set<string>([]);
100
+
101
+ /** Per-file reporting cap: a truly binary file would otherwise flood the log. */
102
+ export const MAX_REPORTED_PER_FILE = 10;
103
+
104
+ export interface Finding {
105
+ file: string;
106
+ line: number;
107
+ column: number;
108
+ byte: number;
109
+ snippet: string;
110
+ }
111
+
112
+ export function isScannable(relPath: string): boolean {
113
+ if (ALLOWLISTED_PATHS.has(relPath)) return false;
114
+ const base = relPath.slice(relPath.lastIndexOf('/') + 1);
115
+ const dot = base.lastIndexOf('.');
116
+ if (dot <= 0) return true; // extensionless and dotfiles are scanned
117
+ return !BINARY_EXTENSIONS.has(base.slice(dot + 1).toLowerCase());
118
+ }
119
+
120
+ /**
121
+ * Render a line for display with every forbidden byte replaced by
122
+ * `<0xNN>`, so the gate's own output can never carry the byte into a
123
+ * terminal or CI log. Segments between control bytes are decoded as
124
+ * UTF-8 so multi-byte characters survive intact.
125
+ */
126
+ export function renderSafe(buf: Buffer): string {
127
+ const parts: string[] = [];
128
+ let start = 0;
129
+ for (let i = 0; i < buf.length; i++) {
130
+ if (!FORBIDDEN_BYTES.has(buf[i]!)) continue;
131
+ parts.push(buf.subarray(start, i).toString('utf8'));
132
+ parts.push(`<0x${buf[i]!.toString(16).padStart(2, '0')}>`);
133
+ start = i + 1;
134
+ }
135
+ parts.push(buf.subarray(start).toString('utf8'));
136
+ return parts.join('').trim()
137
+ .slice(0, 160);
138
+ }
139
+
140
+ /**
141
+ * Every forbidden CODEPOINT in the decoded text, with 1-based
142
+ * line/column. Runs alongside the byte scan: bytes catch NUL/ESC,
143
+ * codepoints catch BiDi and the invisible set.
144
+ */
145
+ export function scanText(file: string, text: string): Finding[] {
146
+ const findings: Finding[] = [];
147
+ let line = 1;
148
+ let lineStart = 0;
149
+ for (let i = 0; i < text.length; i++) {
150
+ const ch = text[i]!;
151
+ if (ch === '\n') {
152
+ line += 1;
153
+ lineStart = i + 1;
154
+ continue;
155
+ }
156
+ const code = text.codePointAt(i)!;
157
+ if (code === 0xfeff && i === 0) continue; // leading BOM is an encoding artifact
158
+
159
+ if (EMOJI_JOINERS.has(code)) {
160
+ const prev = codePointBefore(text, i);
161
+ const next = i + 1 < text.length
162
+ ? text.codePointAt(i + 1)!
163
+ : -1;
164
+ const ok =
165
+ prev >= 0 && next >= 0 &&
166
+ EMOJI_ADJACENT.test(String.fromCodePoint(prev)) &&
167
+ EMOJI_ADJACENT.test(String.fromCodePoint(next));
168
+ if (ok) continue;
169
+ } else if (!FORBIDDEN_CODEPOINTS.has(code)) {
170
+ continue;
171
+ }
172
+
173
+ let lineEnd = text.indexOf('\n', i);
174
+ if (lineEnd === -1) lineEnd = text.length;
175
+ findings.push({
176
+ file,
177
+ line,
178
+ column: i - lineStart + 1,
179
+ byte: code,
180
+ snippet: renderSafeText(text.slice(lineStart, lineEnd)),
181
+ });
182
+ }
183
+ return findings;
184
+ }
185
+
186
+ /**
187
+ * Code point ending at `i`, walking back over a surrogate pair. Reading
188
+ * `text[i - 1]` yields a lone low surrogate for any astral emoji, which
189
+ * never matches a Unicode property escape — that reads every real emoji
190
+ * ZWJ sequence as a violation.
191
+ */
192
+ function codePointBefore(text: string, i: number): number {
193
+ if (i <= 0) return -1;
194
+ const lo = text.charCodeAt(i - 1);
195
+ if (lo >= 0xdc00 && lo <= 0xdfff && i >= 2) {
196
+ const hi = text.charCodeAt(i - 2);
197
+ if (hi >= 0xd800 && hi <= 0xdbff) {
198
+ return (hi - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
199
+ }
200
+ }
201
+ return lo;
202
+ }
203
+
204
+ /** Like `renderSafe`, for text: replaces flagged codepoints with `<U+XXXX>`. */
205
+ export function renderSafeText(text: string): string {
206
+ let out = '';
207
+ for (let i = 0; i < text.length; i++) {
208
+ const code = text.codePointAt(i)!;
209
+ const flagged =
210
+ FORBIDDEN_CODEPOINTS.has(code) ||
211
+ EMOJI_JOINERS.has(code) ||
212
+ FORBIDDEN_BYTES.has(code);
213
+ out += flagged
214
+ ? `<U+${code.toString(16).toUpperCase()
215
+ .padStart(4, '0')}>`
216
+ : text[i];
217
+ }
218
+ return out.trim().slice(0, 160);
219
+ }
220
+
221
+ /** Every forbidden byte in `buf`, with 1-based line/column. */
222
+ export function scanBuffer(file: string, buf: Buffer): Finding[] {
223
+ const findings: Finding[] = [];
224
+ let line = 1;
225
+ let lineStart = 0;
226
+ for (let i = 0; i < buf.length; i++) {
227
+ const byte = buf[i]!;
228
+ if (byte === 0x0a) {
229
+ line += 1;
230
+ lineStart = i + 1;
231
+ continue;
232
+ }
233
+ if (!FORBIDDEN_BYTES.has(byte)) continue;
234
+ let lineEnd = buf.indexOf(0x0a, i);
235
+ if (lineEnd === -1) lineEnd = buf.length;
236
+ findings.push({
237
+ file,
238
+ line,
239
+ column: i - lineStart + 1,
240
+ byte,
241
+ snippet: renderSafe(buf.subarray(lineStart, lineEnd)),
242
+ });
243
+ }
244
+ return findings;
245
+ }
246
+
247
+ /**
248
+ * Paths git knows about, NUL-delimited so filenames with newlines cannot
249
+ * split a record. Throws when git cannot answer — callers must exit 2,
250
+ * not 0.
251
+ */
252
+ export function listFiles(
253
+ root: string,
254
+ staged: boolean,
255
+ includeUntracked = false,
256
+ ): string[] {
257
+ const run = (args: string[]): string[] => execFileSync('git', args, { cwd: root, maxBuffer: 64 * 1024 * 1024 })
258
+ .toString('utf8')
259
+ .split(NUL)
260
+ .filter(Boolean);
261
+
262
+ if (staged) {
263
+ return run(['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR']);
264
+ }
265
+
266
+ const tracked = run(['ls-files', '-z']);
267
+ if (!includeUntracked) return tracked;
268
+
269
+ // New skill/agent/context files are usually written before they are
270
+ // added. `--exclude-standard` keeps .gitignore honoured, so
271
+ // node_modules and build output stay out.
272
+ const untracked = run(['ls-files', '--others', '--exclude-standard', '-z']);
273
+ return [...new Set([...tracked, ...untracked])];
274
+ }
275
+
276
+ /** Worktree content, or null when the path is not a readable regular file. */
277
+ export function readWorktree(root: string, rel: string): Buffer | null {
278
+ const full = join(root, rel);
279
+ try {
280
+ if (!statSync(full).isFile()) return null; // submodule / gone
281
+ } catch {
282
+ return null; // staged deletion, or a path removed from the worktree
283
+ }
284
+ return readFileSync(full);
285
+ }
286
+
287
+ /**
288
+ * Content of the STAGED (index) version. The pre-commit hook must judge
289
+ * what the commit will actually contain: a byte staged and then cleaned
290
+ * up in the worktree would otherwise sail through.
291
+ */
292
+ export function readStaged(root: string, rel: string): Buffer | null {
293
+ try {
294
+ return execFileSync('git', ['cat-file', 'blob', `:${rel}`], {
295
+ cwd: root,
296
+ maxBuffer: 64 * 1024 * 1024,
297
+ });
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ export function scanFiles(
304
+ root: string,
305
+ relPaths: string[],
306
+ read: (rel: string) => Buffer | null = (rel) => readWorktree(root, rel),
307
+ ): Finding[] {
308
+ const findings: Finding[] = [];
309
+ for (const rel of relPaths) {
310
+ if (!isScannable(rel)) continue;
311
+ const content = read(rel);
312
+ if (content === null) continue;
313
+ findings.push(...scanBuffer(rel, content));
314
+ // Byte scan first (NUL/ESC), then the decoded scan (BiDi/invisible).
315
+ // A file that is not valid UTF-8 still gets the byte scan, which is
316
+ // the one that matters for git's binary classification.
317
+ findings.push(...scanText(rel, content.toString('utf8')));
318
+ }
319
+ return findings;
320
+ }
321
+
322
+ if (import.meta.main) {
323
+ const argv = process.argv.slice(2);
324
+ const staged = argv.includes('--staged');
325
+ const includeUntracked = argv.includes('--include-untracked');
326
+ const rootFlag = argv.indexOf('--root');
327
+ const root = rootFlag > -1
328
+ ? argv[rootFlag + 1]!
329
+ : process.cwd();
330
+
331
+ let files: string[] = [];
332
+ try {
333
+ files = listFiles(root, staged, includeUntracked);
334
+ } catch (err) {
335
+ const message = err instanceof Error
336
+ ? err.message
337
+ : String(err);
338
+ console.error(`[control-byte-gate] FAIL — git could not list files: ${message}`);
339
+ process.exit(2);
340
+ }
341
+
342
+ const scannable = files.filter(isScannable);
343
+
344
+ // Vacuity guard: in full-repo mode an empty file list means the gate
345
+ // was pointed somewhere wrong, not that the repo is clean. Reporting
346
+ // OK here would be exactly the false all-clear this gate exists to
347
+ // prevent.
348
+ if (!staged && scannable.length === 0) {
349
+ console.error(
350
+ '[control-byte-gate] FAIL — 0 scannable files found. Refusing to report' +
351
+ ' a pass; check --root points at the repository.',
352
+ );
353
+ process.exit(2);
354
+ }
355
+
356
+ // Say plainly when there was nothing to look at. "OK - 0 files
357
+ // scanned" reads like a verification that happened; it is the absence
358
+ // of one.
359
+ if (staged && scannable.length === 0) {
360
+ console.log('[control-byte-gate] nothing staged to scan.');
361
+ process.exit(0);
362
+ }
363
+
364
+ const findings = scanFiles(
365
+ root,
366
+ scannable,
367
+ staged
368
+ ? (rel) => readStaged(root, rel)
369
+ : undefined,
370
+ );
371
+
372
+ if (findings.length === 0) {
373
+ console.log(
374
+ `[control-byte-gate] OK — ${scannable.length} file(s) scanned, no unsafe characters.`,
375
+ );
376
+ process.exit(0);
377
+ }
378
+
379
+ console.error(
380
+ `[control-byte-gate] FAIL — ${findings.length} unsafe character(s) in ` +
381
+ `${new Set(findings.map((f) => f.file)).size} file(s):`,
382
+ );
383
+ const byFile = new Map<string, Finding[]>();
384
+ for (const f of findings) {
385
+ const list = byFile.get(f.file) ?? [];
386
+ list.push(f);
387
+ byFile.set(f.file, list);
388
+ }
389
+ for (const [file, list] of byFile) {
390
+ for (const f of list.slice(0, MAX_REPORTED_PER_FILE)) {
391
+ const hexByte = `0x${f.byte.toString(16).padStart(2, '0')}`;
392
+ console.error(` ${file}:${f.line}:${f.column} ${hexByte}`);
393
+ console.error(` ${f.snippet}`);
394
+ }
395
+ if (list.length > MAX_REPORTED_PER_FILE) {
396
+ console.error(
397
+ ` ${file}: ... and ${list.length - MAX_REPORTED_PER_FILE} more in this file`,
398
+ );
399
+ }
400
+ }
401
+ console.error(
402
+ `\n Write the character as a language escape instead (e.g. ${BACKSLASH_U}0000).`,
403
+ );
404
+ console.error(
405
+ ' Runtime behaviour is unchanged; a literal byte makes git render the',
406
+ );
407
+ console.error(
408
+ ' file as `Bin` and makes grep report no match for text that is present.',
409
+ );
410
+ process.exit(1);
411
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The project's privacy-gate config: `.privacy-gate.json` at the
3
+ * repository root. It holds what only the project knows:
4
+ *
5
+ * {
6
+ * "banned": [{ "sha256": "<hash of the lowercase term>", "label": "a private machine name" }],
7
+ * "allow": [{ "path": "package.json", "rule": "email", "sha256": "<hash of the evidence>", "why": "..." }]
8
+ * }
9
+ *
10
+ * Both lists store hashes, never values, so the config can sit in a
11
+ * public repository. A project with no file gets the shape rules alone,
12
+ * and the gate says so in its report.
13
+ *
14
+ * A malformed file throws: a gate that silently dropped a broken list
15
+ * would pass the very terms the list exists to stop.
16
+ */
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ import { type BannedTerms, RULE_NAMES, type RuleName } from './privacy-rules';
22
+
23
+ /** The config file name, at the repository root. */
24
+ export const CONFIG_FILE = '.privacy-gate.json';
25
+
26
+ /** The top-level keys a config may hold; any other is a typo that would empty a list. */
27
+ const KNOWN_KEYS: ReadonlySet<string> = new Set(['$comment', 'banned', 'allow']);
28
+
29
+ /** A sha256 in lowercase hex. */
30
+ const SHA256 = /^[0-9a-f]{64}$/;
31
+
32
+ /** One reviewed exception: a path glob, a rule, and the sha256 of the exact evidence. */
33
+ export interface Allowance {
34
+ readonly path: string;
35
+ readonly rule: RuleName;
36
+ readonly sha256: string;
37
+ readonly why: string;
38
+ }
39
+
40
+ /** The parsed config. */
41
+ export interface PrivacyConfig {
42
+ /** Whether the project has a config file at all. */
43
+ readonly found: boolean;
44
+ readonly banned: BannedTerms;
45
+ readonly allow: readonly Allowance[];
46
+ }
47
+
48
+ /** Whether `value` is a non-empty string. */
49
+ function isText(value: unknown): value is string {
50
+ return typeof value === 'string' && value.trim() !== '';
51
+ }
52
+
53
+ /** Whether `value` names one of the gate's rules. */
54
+ function isRuleName(value: unknown): value is RuleName {
55
+ return (RULE_NAMES as readonly unknown[]).includes(value);
56
+ }
57
+
58
+ /** One banned entry, checked, as a map pair. */
59
+ function parseBanned(entry: unknown, index: number): [string, string] {
60
+ const { sha256, label } = (entry ?? {}) as Record<string, unknown>;
61
+ if (!isText(sha256) || !SHA256.test(sha256)) throw new Error(`${CONFIG_FILE}: banned[${index}].sha256 must be a lowercase sha256`);
62
+ if (!isText(label)) throw new Error(`${CONFIG_FILE}: banned[${index}].label must name the term without spelling it`);
63
+ return [sha256, label];
64
+ }
65
+
66
+ /** One allow entry, checked. */
67
+ function parseAllowance(entry: unknown, index: number): Allowance {
68
+ const { path, rule, sha256, why } = (entry ?? {}) as Record<string, unknown>;
69
+ if (!isText(path)) throw new Error(`${CONFIG_FILE}: allow[${index}].path must be a glob`);
70
+ if (!isRuleName(rule)) throw new Error(`${CONFIG_FILE}: allow[${index}].rule must be one of ${RULE_NAMES.join(', ')}`);
71
+ if (!isText(sha256) || !SHA256.test(sha256)) throw new Error(`${CONFIG_FILE}: allow[${index}].sha256 must be a lowercase sha256`);
72
+ if (!isText(why)) throw new Error(`${CONFIG_FILE}: allow[${index}].why must say why the value is meant`);
73
+ return { path, rule, sha256, why };
74
+ }
75
+
76
+ /** A list field of the config, or an empty list when it is absent. */
77
+ function listField(config: Record<string, unknown>, field: string): unknown[] {
78
+ const value = config[field];
79
+ if (value === undefined) return [];
80
+ if (!Array.isArray(value)) throw new Error(`${CONFIG_FILE}: ${field} must be a list`);
81
+ return value;
82
+ }
83
+
84
+ /** Parses the text of a config file. */
85
+ export function parsePrivacyConfig(text: string): PrivacyConfig {
86
+ const parsed = JSON.parse(text) as unknown;
87
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${CONFIG_FILE}: must hold a JSON object`);
88
+ const config = parsed as Record<string, unknown>;
89
+ const unknown = Object.keys(config).filter((key) => !KNOWN_KEYS.has(key));
90
+ if (unknown.length > 0) throw new Error(`${CONFIG_FILE}: unknown key ${unknown.join(', ')}; it holds only banned, allow and $comment`);
91
+ return {
92
+ found: true,
93
+ banned: new Map(listField(config, 'banned').map(parseBanned)),
94
+ allow: listField(config, 'allow').map(parseAllowance),
95
+ };
96
+ }
97
+
98
+ /** The config of a project with no config file. */
99
+ export const NO_CONFIG: PrivacyConfig = { found: false, banned: new Map(), allow: [] };
100
+
101
+ /** Reads `.privacy-gate.json` from `root`, or an empty config when there is none. */
102
+ export function readPrivacyConfig(root: string): PrivacyConfig {
103
+ const path = join(root, CONFIG_FILE);
104
+ if (!existsSync(path)) return NO_CONFIG;
105
+ return parsePrivacyConfig(readFileSync(path, 'utf8'));
106
+ }
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The privacy gate: refuses personal and private data in what a public
4
+ * repository tracks. The rules live in `./privacy-rules.ts`, the
5
+ * project's banned terms and reviewed exceptions in its
6
+ * `.privacy-gate.json` (`./privacy-config.ts`); this file picks what to
7
+ * read and reports.
8
+ *
9
+ * ot-privacy-gate tracked and untracked-not-ignored files
10
+ * ot-privacy-gate --staged the index blobs (the pre-commit hook)
11
+ * ot-privacy-gate --history every blob and commit identity in every ref
12
+ * ot-privacy-gate --root <dir> a repository other than the current one
13
+ *
14
+ * `--staged` judges the index, not the worktree, so a value staged and
15
+ * then cleaned up locally still blocks; it reads the config from the
16
+ * index too, so an unstaged allowance cannot pass a commit. `--history`
17
+ * is the check before a first push: a value removed by a later commit is
18
+ * still published by the earlier one, and a blob is judged under every
19
+ * path it was committed at.
20
+ *
21
+ * A finding that is meant (the author's address in `package.json`, say)
22
+ * goes in the config's `allow` list with its path glob, rule and the
23
+ * sha256 of its evidence, so every exception is written down and reviewed
24
+ * in a diff without the config spelling the value.
25
+ *
26
+ * Exit codes: 0 clean, 1 findings, 2 the gate could not run. A gate that
27
+ * cannot run must never read as a gate that passed.
28
+ */
29
+ import { join } from 'node:path';
30
+
31
+ import { type Allowance, CONFIG_FILE, NO_CONFIG, parsePrivacyConfig, type PrivacyConfig, readPrivacyConfig } from './privacy-config';
32
+ import { type Finding, scan, sha256 } from './privacy-rules';
33
+
34
+ /** A file to judge: where it is named from, and its text. */
35
+ export interface Source {
36
+ readonly name: string;
37
+ readonly path: string;
38
+ readonly text: string;
39
+ }
40
+
41
+ /** The modes the gate reads sources in. */
42
+ export type Mode = 'tree' | 'staged' | 'history';
43
+
44
+ /** What the command line asks for. */
45
+ export interface GateArgs {
46
+ readonly mode: Mode;
47
+ /** The folder to start from; the current one when absent. */
48
+ readonly root: string | undefined;
49
+ }
50
+
51
+ /** A NUL in the first bytes marks a binary file, which is skipped. */
52
+ const BINARY_PROBE = 8000;
53
+
54
+ /** The flags that pick a mode other than the tree. */
55
+ const MODE_FLAGS: Readonly<Record<string, Mode>> = { '--staged': 'staged', '--history': 'history' };
56
+
57
+ /** Run git and answer its stdout, failing loudly on a nonzero exit. */
58
+ function git(args: string[], cwd: string): Buffer {
59
+ const result = Bun.spawnSync(['git', ...args], { cwd, maxBuffer: 512 * 1024 * 1024 });
60
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(' ')} failed: ${result.stderr.toString()}`);
61
+ return result.stdout;
62
+ }
63
+
64
+ /** The whole text of a blob, or null for a binary one. */
65
+ function textOf(bytes: Uint8Array): string | null {
66
+ if (bytes.subarray(0, BINARY_PROBE).includes(0)) return null;
67
+ return new TextDecoder().decode(bytes);
68
+ }
69
+
70
+ /** Split NUL-separated git output into its entries. */
71
+ function entries(output: Buffer): string[] {
72
+ return output.toString().split('\0')
73
+ .filter((entry) => entry !== '');
74
+ }
75
+
76
+ /** The tracked files and the untracked ones git would track. */
77
+ async function treeSources(root: string): Promise<Source[]> {
78
+ const sources: Source[] = [];
79
+ for (const path of entries(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard'], root))) {
80
+ const file = Bun.file(join(root, path));
81
+ if (!(await file.exists())) continue;
82
+ const text = textOf(await file.bytes());
83
+ if (text !== null) sources.push({ name: path, path, text });
84
+ }
85
+ return sources;
86
+ }
87
+
88
+ /** The index blobs of the files a commit would add or change. */
89
+ function stagedSources(root: string): Source[] {
90
+ const sources: Source[] = [];
91
+ for (const path of entries(git(['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'], root))) {
92
+ const text = textOf(git(['show', `:${path}`], root));
93
+ if (text !== null) sources.push({ name: `${path} (staged)`, path, text });
94
+ }
95
+ return sources;
96
+ }
97
+
98
+ /** The text of the blob `sha`, read once and kept in `cache`. */
99
+ function blobText(sha: string, root: string, cache: Map<string, string | null>): string | null {
100
+ const known = cache.get(sha);
101
+ if (known !== undefined) return known;
102
+ const text = textOf(git(['cat-file', 'blob', sha], root));
103
+ cache.set(sha, text);
104
+ return text;
105
+ }
106
+
107
+ /**
108
+ * Every blob in the tree of every commit reachable from any ref, once per
109
+ * path it was committed at, plus commit identities and messages. The
110
+ * rules and the allow list both depend on the path, so a blob copied to a
111
+ * second path is judged again there.
112
+ */
113
+ function historySources(root: string): Source[] {
114
+ const sources: Source[] = [];
115
+ const seen = new Set<string>();
116
+ const cache = new Map<string, string | null>();
117
+ for (const commit of git(['rev-list', '--all'], root).toString()
118
+ .split('\n')
119
+ .filter((line) => line !== '')) {
120
+ for (const entry of entries(git(['ls-tree', '-r', '-z', commit], root))) {
121
+ const [meta = '', path = ''] = entry.split('\t');
122
+ const [, type, sha] = meta.split(' ');
123
+ if (type !== 'blob' || sha === undefined || path === '' || seen.has(`${sha} ${path}`)) continue;
124
+ seen.add(`${sha} ${path}`);
125
+ const text = blobText(sha, root, cache);
126
+ if (text !== null) sources.push({ name: `${path} @ ${sha.slice(0, 8)}`, path, text });
127
+ }
128
+ }
129
+ const log = git(['log', '--all', '--format=%h%x00%an <%ae>%x00%cn <%ce>%x00%B%x00%x01'], root).toString();
130
+ for (const record of log.split('\x01')) {
131
+ const [hash, author, committer, body] = record.replace(/^\n/, '').split('\0');
132
+ if (hash === undefined || hash === '') continue;
133
+ sources.push({ name: `commit ${hash}`, path: '.git/COMMIT', text: [author, committer, body].join('\n') });
134
+ }
135
+ return sources;
136
+ }
137
+
138
+ /** Whether a finding in `path` is one of the reviewed exceptions. */
139
+ function isAllowed(path: string, finding: Finding, allowed: readonly Allowance[]): boolean {
140
+ return allowed.some((allowance) => allowance.rule === finding.rule
141
+ && allowance.sha256 === sha256(finding.evidence)
142
+ && new Bun.Glob(allowance.path).match(path));
143
+ }
144
+
145
+ /** Judges the sources under `config` and answers the report lines. */
146
+ export function judge(sources: readonly Source[], config: PrivacyConfig): string[] {
147
+ const report: string[] = [];
148
+ for (const source of sources) {
149
+ for (const finding of scan(source.path, source.text, config.banned)) {
150
+ if (isAllowed(source.path, finding, config.allow)) continue;
151
+ report.push(`${source.name}:${finding.line} ${finding.rule} ${finding.evidence}`);
152
+ }
153
+ }
154
+ return report;
155
+ }
156
+
157
+ /** The sources one mode reads from the repository at `root`. */
158
+ export async function readSources(root: string, mode: Mode): Promise<Source[]> {
159
+ if (mode === 'staged') return stagedSources(root);
160
+ if (mode === 'history') return historySources(root);
161
+ return treeSources(root);
162
+ }
163
+
164
+ /** The config a mode judges by: the index copy for `--staged`, the worktree copy otherwise. */
165
+ export function readConfig(root: string, mode: Mode): PrivacyConfig {
166
+ if (mode !== 'staged') return readPrivacyConfig(root);
167
+ if (entries(git(['ls-files', '-z', '--cached', '--', CONFIG_FILE], root)).length === 0) return NO_CONFIG;
168
+ return parsePrivacyConfig(git(['show', `:${CONFIG_FILE}`], root).toString());
169
+ }
170
+
171
+ /**
172
+ * Parses the command line strictly: an unknown argument, `--root` with
173
+ * no folder, or two mode flags throw, so a typo never runs a different
174
+ * scan than the one asked for.
175
+ */
176
+ export function parseArgs(argv: readonly string[]): GateArgs {
177
+ let mode: Mode | undefined;
178
+ let root: string | undefined;
179
+ for (let index = 0; index < argv.length; index += 1) {
180
+ const arg = argv[index] ?? '';
181
+ if (arg === '--root') {
182
+ const value = argv[index + 1];
183
+ if (value === undefined || value.startsWith('--')) throw new Error('--root needs a folder after it');
184
+ if (root !== undefined) throw new Error('--root is given twice');
185
+ root = value;
186
+ index += 1;
187
+ continue;
188
+ }
189
+ const flagMode = MODE_FLAGS[arg];
190
+ if (flagMode === undefined) throw new Error(`unknown argument ${arg}; the gate takes --staged, --history and --root <dir>`);
191
+ if (mode !== undefined) throw new Error(`--${mode} and ${arg} cannot run together`);
192
+ mode = flagMode;
193
+ }
194
+ return { mode: mode ?? 'tree', root };
195
+ }
196
+
197
+ /** The exit code for "the gate could not run". */
198
+ const EXIT_FAILED = 2;
199
+
200
+ /** Runs the gate over the repository the arguments name and answers its exit code. */
201
+ export async function run(argv: readonly string[]): Promise<number> {
202
+ const { mode, root: start = process.cwd() } = parseArgs(argv);
203
+ const root = git(['rev-parse', '--show-toplevel'], start).toString()
204
+ .trim();
205
+ const config = readConfig(root, mode);
206
+ const sources = await readSources(root, mode);
207
+ if (mode !== 'staged' && sources.length === 0) {
208
+ console.error(`privacy-gate: FAIL, 0 ${mode} sources under ${root}. Refusing to report a pass.`);
209
+ return EXIT_FAILED;
210
+ }
211
+ const listNote = config.found
212
+ ? `${config.banned.size} banned terms`
213
+ : `no ${CONFIG_FILE}, so no banned terms`;
214
+ const report = judge(sources, config);
215
+ if (report.length === 0) {
216
+ console.log(`privacy-gate: ${sources.length} ${mode} sources, ${listNote}, no findings`);
217
+ return 0;
218
+ }
219
+ console.error(`privacy-gate: ${report.length} findings in ${mode} sources (${listNote})\n${report.join('\n')}`);
220
+ console.error(`\nRemove each value, replace it with a reserved example, or add a reviewed entry to the allow list of ${CONFIG_FILE}\n(its evidence as sha256: bun -e 'console.log(new Bun.CryptoHasher("sha256").update(process.argv[1]).digest("hex"))' '<evidence>').`);
221
+ return 1;
222
+ }
223
+
224
+ if (import.meta.main) {
225
+ try {
226
+ process.exit(await run(process.argv.slice(2)));
227
+ } catch (error) {
228
+ const message = error instanceof Error
229
+ ? error.message
230
+ : String(error);
231
+ console.error(`privacy-gate: FAIL, the gate could not run: ${message}`);
232
+ process.exit(EXIT_FAILED);
233
+ }
234
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The privacy rules: what a tracked file may not carry in a public
3
+ * repository. Each rule reads one string and answers findings with a
4
+ * line number, so the same rules judge a staged blob, a tracked file and
5
+ * every blob in the history.
6
+ *
7
+ * Three families:
8
+ *
9
+ * - **Banned terms**, stored as sha256 hashes of their lowercase form so
10
+ * no file spells them: machine names, private domains, private
11
+ * repositories, home paths and mail handles. This package ships no
12
+ * list; each project keeps its own in `.privacy-gate.json`
13
+ * (`./privacy-config.ts`).
14
+ * - **Shapes**, which need no list: a home directory path, a private or
15
+ * public IPv4 address, a LAN host name, a credential prefix.
16
+ * - **Sample data**, the gap other projects found: mock people in
17
+ * stories, fixtures and tests. There an email, a URL, a phone number
18
+ * or a profile link must use a reserved example domain or number, so a
19
+ * real person's address can never pass as a sample.
20
+ *
21
+ * To ban a term, print its hash and add an entry to the project's
22
+ * `.privacy-gate.json` with a label that does not spell it:
23
+ *
24
+ * bun -e 'console.log(new Bun.CryptoHasher("sha256").update(process.argv[1].toLowerCase()).digest("hex"))' '<term>'
25
+ */
26
+
27
+ /** A rule's answer: the line it fired on and what it found. */
28
+ export interface Finding {
29
+ readonly line: number;
30
+ readonly rule: RuleName;
31
+ /** What matched. A banned term is shown by its label only. */
32
+ readonly evidence: string;
33
+ }
34
+
35
+ /** The rule names, as the report and the allowlist spell them. */
36
+ export const RULE_NAMES = [
37
+ 'banned-term',
38
+ 'home-path',
39
+ 'ip-address',
40
+ 'lan-host',
41
+ 'credential',
42
+ 'email',
43
+ 'sample-url',
44
+ 'sample-phone',
45
+ 'profile-link',
46
+ ] as const;
47
+
48
+ /** One rule name. */
49
+ export type RuleName = typeof RULE_NAMES[number];
50
+
51
+ /** sha256 of a banned lowercase term, mapped to a label that does not spell it. */
52
+ export type BannedTerms = ReadonlyMap<string, string>;
53
+
54
+ /** No banned terms: the default when a project lists none. */
55
+ export const NO_BANNED_TERMS: BannedTerms = new Map();
56
+
57
+ /** Characters a token is made of, for the banned-term rule. */
58
+ const TOKEN = /[a-z0-9._/~@-]+/g;
59
+
60
+ /** The separators that split a token into words. */
61
+ const SEPARATOR = /[._/~@-]/;
62
+
63
+ /** Tokens longer than this are cut into words from their first part only. */
64
+ const MAX_WORDS = 24;
65
+
66
+ /** Mail and web domains reserved for examples (RFC 2606, RFC 6761). */
67
+ const RESERVED_DOMAIN = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost)$/;
68
+
69
+ /** IPv4 addresses kept for documentation, loopback and wildcards. */
70
+ const ALLOWED_IP = /^(127\.0\.0\.1|0\.0\.0\.0|255\.255\.255\.255|192\.0\.2\.\d+|198\.51\.100\.\d+|203\.0\.113\.\d+)$/;
71
+
72
+ /** Hosts a sample may link to without naming anyone. */
73
+ const ALLOWED_SAMPLE_HOST = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost|www\.w3\.org|w3\.org)$/;
74
+
75
+ /** Credential prefixes a vendor issues keys under, followed by a key-like body. */
76
+ const CREDENTIAL = /\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|(AKIA|ASIA)[A-Z0-9]{16}|sk-(ant-)?[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AIza[A-Za-z0-9_-]{30,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)/;
77
+
78
+ /** A home directory with its account segment, on macOS, Linux or Windows. */
79
+ const HOME_PATH = /(^|[^\w.-])(\/Users\/[A-Za-z0-9._-]+|\/home\/[A-Za-z0-9._-]+|[A-Za-z]:\\Users\\[A-Za-z0-9._-]+)/;
80
+
81
+ /** A dotted quad, checked for range after the match. */
82
+ const IPV4 = /(?<![\w.])(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?![\w.]*\d)/g;
83
+
84
+ /** An SVG path or points attribute, whose numbers read like dotted quads. */
85
+ const SVG_PATH = /(^|\s)(d|points)=["{]/;
86
+
87
+ /** A host name on a LAN-only suffix, not a property chain like `node.local.name`. */
88
+ const LAN_HOST = /\b[a-z0-9-]+\.(local|lan|internal|home\.arpa|localdomain)\b(?![.(\w])/i;
89
+
90
+ /** An email address; the domain is checked after the match. */
91
+ const EMAIL = /(?<![\w/@.-])[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})(?![\w-])/g;
92
+
93
+ /** An http(s) URL's host. */
94
+ const URL_HOST = /https?:\/\/([A-Za-z0-9.-]+)/g;
95
+
96
+ /** A phone number shape: optional country code, then 9 to 13 digits in groups. */
97
+ const PHONE = /(?<![\w.])\+?\d{1,3}?[ (.-]*\d{2,4}[ ).-]+\d{3,4}[ .-]+\d{3,4}(?![\w.])/g;
98
+
99
+ /** The fictional range: 555-0100 to 555-0199. */
100
+ const FICTIONAL_PHONE = /555[ .-]?01\d\d/;
101
+
102
+ /** A link to a person's profile on a public network. */
103
+ const PROFILE = /\b(github\.com|gitlab\.com|twitter\.com|x\.com|linkedin\.com\/in|instagram\.com|facebook\.com)\/([A-Za-z0-9_.-]+)/gi;
104
+
105
+ /** Paths that hold sample data: stories, fixtures, mocks, tests. */
106
+ const SAMPLE_PATH = /(\.stories\.[jt]sx?$|\.test\.[jt]sx?$|\.spec\.[jt]sx?$|(^|\/)(stories|fixtures?|mocks?|__mocks__|testdata|samples?)\/|fixtures?\.[jt]sx?$|mocks?\.[jt]sx?$)/;
107
+
108
+ /** Whether `path` holds sample data. */
109
+ export function isSamplePath(path: string): boolean {
110
+ return SAMPLE_PATH.test(path);
111
+ }
112
+
113
+ /** The sha256 of `text`, as hex. */
114
+ export function sha256(text: string): string {
115
+ return new Bun.CryptoHasher('sha256').update(text)
116
+ .digest('hex');
117
+ }
118
+
119
+ /** Every run of whole words in `token`, each with and without the separator before it. */
120
+ function candidates(token: string): string[] {
121
+ const words: { start: number; end: number }[] = [];
122
+ let start = -1;
123
+ for (let index = 0; index <= token.length; index += 1) {
124
+ const isWord = index < token.length && !SEPARATOR.test(token.charAt(index));
125
+ if (isWord && start === -1) start = index;
126
+ if (!isWord && start !== -1) {
127
+ words.push({ start, end: index });
128
+ start = -1;
129
+ }
130
+ }
131
+ const runs: string[] = [];
132
+ const kept = words.slice(0, MAX_WORDS);
133
+ for (let first = 0; first < kept.length; first += 1) {
134
+ const from = kept[first]?.start ?? 0;
135
+ for (let last = first; last < kept.length; last += 1) {
136
+ const to = kept[last]?.end ?? 0;
137
+ runs.push(token.slice(from, to));
138
+ if (from > 0) runs.push(token.slice(from - 1, to));
139
+ }
140
+ }
141
+ return runs;
142
+ }
143
+
144
+ /** The labels of the banned terms one line holds. */
145
+ export function bannedIn(text: string, banned: BannedTerms = NO_BANNED_TERMS): string[] {
146
+ const found = new Set<string>();
147
+ for (const token of text.toLowerCase().match(TOKEN) ?? []) {
148
+ for (const candidate of candidates(token)) {
149
+ const label = banned.get(sha256(candidate));
150
+ if (label !== undefined) found.add(label);
151
+ }
152
+ }
153
+ return [...found];
154
+ }
155
+
156
+ /** Whether an IPv4 match is a real address outside the allowed set. */
157
+ function isReportableIp(parts: string[], text: string): boolean {
158
+ if (parts.some((part) => Number(part) > 255)) return false;
159
+ return !ALLOWED_IP.test(text);
160
+ }
161
+
162
+ /** The findings of the rules that apply to every file, for one line. */
163
+ function lineFindings(text: string, line: number, banned: BannedTerms): Finding[] {
164
+ const findings: Finding[] = [];
165
+ for (const label of bannedIn(text, banned)) findings.push({ line, rule: 'banned-term', evidence: label });
166
+ const home = HOME_PATH.exec(text);
167
+ if (home?.[2] !== undefined) findings.push({ line, rule: 'home-path', evidence: home[2] });
168
+ const svgPath = SVG_PATH.test(text);
169
+ for (const match of svgPath
170
+ ? []
171
+ : text.matchAll(IPV4)) {
172
+ if (isReportableIp(match.slice(1, 5), match[0])) findings.push({ line, rule: 'ip-address', evidence: match[0] });
173
+ }
174
+ const lan = LAN_HOST.exec(text);
175
+ if (lan !== null) findings.push({ line, rule: 'lan-host', evidence: lan[0] });
176
+ const credential = CREDENTIAL.exec(text);
177
+ if (credential?.[1] !== undefined) findings.push({ line, rule: 'credential', evidence: `${credential[1].slice(0, 6)}…` });
178
+ for (const match of text.matchAll(EMAIL)) {
179
+ const domain = (match[1] ?? '').toLowerCase();
180
+ if (!RESERVED_DOMAIN.test(domain)) findings.push({ line, rule: 'email', evidence: match[0] });
181
+ }
182
+ return findings;
183
+ }
184
+
185
+ /** The findings of the sample-data rules, for one line of a sample file. */
186
+ function sampleFindings(text: string, line: number): Finding[] {
187
+ const findings: Finding[] = [];
188
+ for (const match of text.matchAll(URL_HOST)) {
189
+ const host = (match[1] ?? '').toLowerCase();
190
+ if (!ALLOWED_SAMPLE_HOST.test(host)) findings.push({ line, rule: 'sample-url', evidence: match[0] });
191
+ }
192
+ for (const match of text.matchAll(PHONE)) {
193
+ const digits = match[0].replace(/\D/g, '');
194
+ if (digits.length >= 9 && !FICTIONAL_PHONE.test(match[0])) findings.push({ line, rule: 'sample-phone', evidence: match[0].trim() });
195
+ }
196
+ for (const match of text.matchAll(PROFILE)) findings.push({ line, rule: 'profile-link', evidence: match[0] });
197
+ return findings;
198
+ }
199
+
200
+ /** Every finding in one file's text. Line numbers count from one. */
201
+ export function scan(path: string, text: string, banned: BannedTerms = NO_BANNED_TERMS): Finding[] {
202
+ const sample = isSamplePath(path);
203
+ const findings: Finding[] = [];
204
+ text.split('\n').forEach((content, index) => {
205
+ findings.push(...lineFindings(content, index + 1, banned));
206
+ if (sample) findings.push(...sampleFindings(content, index + 1));
207
+ });
208
+ return findings;
209
+ }