pm-github 2026.10.4 → 2026.10.9
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/CHANGELOG.md +16 -0
- package/README.md +94 -2
- package/dist/gate.d.ts +269 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +873 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +126 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +511 -180
- package/dist/index.js.map +1 -1
- package/dist/projects.d.ts +11 -0
- package/dist/projects.d.ts.map +1 -1
- package/dist/projects.js +17 -7
- package/dist/projects.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +25 -12
package/dist/gate.js
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
// pm-github — fail-closed privacy gate over a proposed tracker change.
|
|
2
|
+
//
|
|
3
|
+
// GitHub issue bodies are untrusted text: anyone who can open an issue can put a
|
|
4
|
+
// credential, a personal email, or a host path in it, and an automated import
|
|
5
|
+
// writes that text straight into the pm tracker. A sync workflow then pushes the
|
|
6
|
+
// tracker to a public branch — publishing the leak with it. This module is the
|
|
7
|
+
// gate that runs between the write and any push: it scans ONLY the proposed
|
|
8
|
+
// change (the added lines and their filenames in the staged/working/untracked diff under the
|
|
9
|
+
// resolved pm tracker path, or an explicit unified diff), so pre-existing
|
|
10
|
+
// reviewed content is not re-litigated on every run, and it FAILS CLOSED:
|
|
11
|
+
//
|
|
12
|
+
// * a credential signature, personal email, phone number, or host path in an
|
|
13
|
+
// added line fails the gate;
|
|
14
|
+
// * an unreadable input, a git failure, a malformed allowlist, or a scanner
|
|
15
|
+
// error fails the gate — a clean verdict can never be the result of the
|
|
16
|
+
// scanner not being able to read what it was asked to read;
|
|
17
|
+
// * findings name the rule, the item id, the item field, and a content hash —
|
|
18
|
+
// never the matched text — so gate logs cannot become the leak they exist
|
|
19
|
+
// to prevent;
|
|
20
|
+
// * reviewed false positives are suppressed through a content-addressed
|
|
21
|
+
// allowlist (sha256 of the matched content), not a pattern, so an entry can
|
|
22
|
+
// never accidentally un-suppress a different, genuinely dangerous value.
|
|
23
|
+
//
|
|
24
|
+
// The scanner rules deliberately mirror the high-confidence signature set the
|
|
25
|
+
// repository's own identity/privacy gate (scripts/privacy-gate.ts) enforces over
|
|
26
|
+
// Git objects, extended with the personal-data shapes a public branch push would
|
|
27
|
+
// leak from imported issue text.
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { spawnSync } from "node:child_process";
|
|
32
|
+
import { TextDecoder } from "node:util";
|
|
33
|
+
import { resolvePmRoot } from "@unbrained/pm-cli/sdk";
|
|
34
|
+
/**
|
|
35
|
+
* Input failure the gate treats as fail-closed: unreadable diff file, not a Git
|
|
36
|
+
* work tree, a Git command that failed, a malformed allowlist, or a scanner
|
|
37
|
+
* error. Callers translate this into a non-zero exit; it must never surface as a
|
|
38
|
+
* clean pass.
|
|
39
|
+
*/
|
|
40
|
+
export class GateInputError extends Error {
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "GateInputError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// pm tracker path resolution (shared with index.ts; lives here so the gate
|
|
48
|
+
// module never needs an import cycle with the command module)
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the pm data dir from the `pmRoot` a command handler receives.
|
|
52
|
+
*
|
|
53
|
+
* The host may hand either the workspace root (the dir containing `.agents/pm`)
|
|
54
|
+
* or the data dir itself — the pm CLI accepts both for `--path`. This returns
|
|
55
|
+
* the dir that actually holds `settings.json` and `locks/`, defaulting to
|
|
56
|
+
* `pmRoot` unchanged when no nested `.agents/pm` exists.
|
|
57
|
+
*
|
|
58
|
+
* @param pmRoot - The path supplied by the extension host.
|
|
59
|
+
* @returns The resolved pm data directory.
|
|
60
|
+
*/
|
|
61
|
+
export function resolvePmDataDir(pmRoot) {
|
|
62
|
+
return resolvePmRoot(process.cwd(), pmRoot);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* High-confidence credential signatures. Every pattern is shaped so ordinary
|
|
66
|
+
* prose, identifiers, or version numbers cannot match it; broad low-confidence
|
|
67
|
+
* patterns are deliberately excluded so reviewed imports are not buried in
|
|
68
|
+
* noise — the gate stays at a zero false-positive posture on well-formed
|
|
69
|
+
* trackers while every listed provider shape still fails closed.
|
|
70
|
+
*/
|
|
71
|
+
const CREDENTIAL_RULES = [
|
|
72
|
+
{ rule: "github-token-classic", pattern: /gh[ousrp]_[A-Za-z0-9]{36}/g },
|
|
73
|
+
{ rule: "github-token-fine-grained", pattern: /github_pat_[A-Za-z0-9_]{22,}/g },
|
|
74
|
+
{ rule: "npm-token", pattern: /npm_[A-Za-z0-9]{36}/g },
|
|
75
|
+
{ rule: "aws-access-key-id", pattern: /(?:AKIA|ASIA)[0-9A-Z]{16}/g },
|
|
76
|
+
{ rule: "slack-token", pattern: /(?:xox[a-z]|xapp)-[A-Za-z0-9-]{10,}/g },
|
|
77
|
+
{ rule: "slack-webhook", pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]{24,}/g },
|
|
78
|
+
{ rule: "openai-legacy-key", pattern: /sk-[A-Za-z0-9]{48}/g },
|
|
79
|
+
{ rule: "openai-api-key", pattern: /sk-[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,}/g },
|
|
80
|
+
{ rule: "openai-project-key", pattern: /sk-proj-[A-Za-z0-9_-]{20,}/g },
|
|
81
|
+
{ rule: "openai-service-account-key", pattern: /sk-svcacct-[A-Za-z0-9_-]{20,}/g },
|
|
82
|
+
{ rule: "anthropic-api-key", pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
83
|
+
{
|
|
84
|
+
rule: "bearer-token",
|
|
85
|
+
pattern: /(?:authorization\s*:\s*bearer\s+[A-Za-z0-9._~+/=-]{8,}|bearer\s+[A-Za-z0-9._~+/=-]{20,})/gi,
|
|
86
|
+
},
|
|
87
|
+
{ rule: "private-key-block", pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g },
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* Personal-data signatures: an email that is not a no-reply address, and phone
|
|
91
|
+
* numbers in their two common written shapes.
|
|
92
|
+
*/
|
|
93
|
+
const PERSONAL_DATA_RULES = [
|
|
94
|
+
{
|
|
95
|
+
rule: "email-address",
|
|
96
|
+
// An SCP-style SSH clone URL (`git@github.com:owner/repo.git`, the colon
|
|
97
|
+
// followed directly by a path) names the git service account, not a
|
|
98
|
+
// person, so the pattern skips exactly that shape; a bare `git@host`
|
|
99
|
+
// contact address, even before a colon and a space, is still matched.
|
|
100
|
+
// The lookbehind keeps a match from starting inside a local part.
|
|
101
|
+
pattern: /(?<![A-Za-z0-9._%+-])(?!git@[A-Za-z0-9.-]+\.[A-Za-z]{2,}:[A-Za-z0-9_~./-])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
|
|
102
|
+
// GitHub, GitLab and most mail systems use a "noreply" domain or local part
|
|
103
|
+
// for automated identities; anything else in imported issue text is a real
|
|
104
|
+
// person's address and fails closed.
|
|
105
|
+
confirm: (matched) => {
|
|
106
|
+
const [local, domain] = matched.toLowerCase().split("@");
|
|
107
|
+
return !/^(?:no-?reply)$/.test(local) && !/(?:^|\.)no-?reply(?:\.|$)/.test(domain);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
rule: "phone-number",
|
|
112
|
+
pattern: /\+\d{1,3}[\s.-]?(?:\d{2,4}[\s.-]?){2,4}\d{2,4}/g,
|
|
113
|
+
// International notation needs a real phone digit count; short sequences
|
|
114
|
+
// like "+1 2 3" are not phone numbers. The pattern guarantees at least one
|
|
115
|
+
// digit, so the match can never be null here.
|
|
116
|
+
confirm: (matched) => matched.match(/\d/g).length >= 8,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
rule: "phone-number",
|
|
120
|
+
pattern: /\(\d{3}\)\s*\d{3}[\s.-]\d{4}|\b\d{3}[\s.-]\d{3}[\s.-]\d{4}\b/g,
|
|
121
|
+
// The North-American grouping is 10 digits by construction; the digit check
|
|
122
|
+
// only guards the parenthesized variant against truncation by surrounding
|
|
123
|
+
// punctuation.
|
|
124
|
+
confirm: (matched) => matched.match(/\d/g).length === 10,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
rule: "phone-number",
|
|
128
|
+
pattern: /\b(?:phone|telephone|tel|mobile)\b["']?\s*[:=]\s*["']?\s*\+?\d[\d \t().-]{5,}\d/gi,
|
|
129
|
+
// Contact labels distinguish unformatted numbers from issue/comment ids.
|
|
130
|
+
confirm: (matched) => { const digits = matched.replace(/\D/g, "").length; return digits >= 7 && digits <= 15; },
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
/**
|
|
134
|
+
* Host-identifying path signatures: absolute personal/system filesystem paths
|
|
135
|
+
* and home-directory usernames (`~user`, but never the anonymous `~/`), which
|
|
136
|
+
* would publish the machine and account that produced an import. The list is
|
|
137
|
+
* the POSIX/Windows set a developer workstation actually leaks, mirroring the
|
|
138
|
+
* repository's own object-store privacy gate.
|
|
139
|
+
*/
|
|
140
|
+
const HOST_PATH_RULES = [
|
|
141
|
+
{
|
|
142
|
+
rule: "absolute-host-path",
|
|
143
|
+
// Anchored to roots that identify a host (home, system and mount roots),
|
|
144
|
+
// so GitHub slash commands (`/assign`), repository-relative links
|
|
145
|
+
// (`/docs/setup.md`) and API routes (`GET /api/v1`) in issue text are not
|
|
146
|
+
// flagged; the root must end at a separator or delimiter (`/homework` is
|
|
147
|
+
// not `/home`). The lead-in is a lookbehind so the match is the path
|
|
148
|
+
// itself; it also admits `file://` URLs and `label:` prefixes (`cwd:/home/…`),
|
|
149
|
+
// whose two-character minimum leaves drive letters to the Windows rule.
|
|
150
|
+
pattern: /(?<=^|[\s"'`([=,{]|[A-Za-z0-9_]{2}:|file:\/\/)\/(?:home|Users|root|tmp|var|etc|opt|srv|mnt|media|private|Volumes|usr|run|proc|data|nix|scratch|workspace|builds|Library|System)(?=\/|$|[\s"'`<>),;\]}])(?:\/[^\s"'`<>),;\]}]*)?/g,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
rule: "windows-host-path",
|
|
154
|
+
// A drive letter at a token start, or as a `file:///C:/…` URL path.
|
|
155
|
+
pattern: /(?:(?<![A-Za-z0-9_:/])|(?<=file:\/\/\/))[A-Za-z]:[\\/][^\s"'`<>),;\]}]+/g,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
rule: "home-username",
|
|
159
|
+
// A leading tilde followed by a name AND a path separator is a username
|
|
160
|
+
// leak (`~alice/report.txt`); `~/` (anonymous), `~~strikethrough~~`, and
|
|
161
|
+
// prose approximations (`~most users`) are excluded by the boundary and
|
|
162
|
+
// the required trailing slash.
|
|
163
|
+
pattern: /(?:^|[\s"'`([=,])~[A-Za-z][A-Za-z0-9._-]*\/[^\s"'`<>),;\]}]*/g,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
/** Identifier segments whose presence marks an assignment target as secret-bearing. */
|
|
167
|
+
const SECRET_IDENTIFIER_SEGMENTS = [
|
|
168
|
+
"token",
|
|
169
|
+
"secret",
|
|
170
|
+
"password",
|
|
171
|
+
"passwd",
|
|
172
|
+
"credential",
|
|
173
|
+
"apikey",
|
|
174
|
+
];
|
|
175
|
+
/**
|
|
176
|
+
* Identifier segment pairs (normalized on `[_-]`) that mark composite assignment
|
|
177
|
+
* targets like `api_key` or `private_key` without matching prose such as
|
|
178
|
+
* `sort_key` or `author`.
|
|
179
|
+
*/
|
|
180
|
+
const SECRET_IDENTIFIER_PAIRS = ["api_key", "access_key", "private_key", "secret_key"];
|
|
181
|
+
/** Assignment shape for the high-entropy rule: `NAME = "value"` / `name: value`. */
|
|
182
|
+
const HIGH_ENTROPY_ASSIGNMENT = /(?:^|[^A-Za-z0-9_])([A-Za-z][A-Za-z0-9_-]{0,48})(?:\\?["'`])?\s*[:=]\s*(?:\\?["'`])?([A-Za-z0-9+/_=-]{20,256})(?:\\?["'`])?/g;
|
|
183
|
+
/**
|
|
184
|
+
* Shannon entropy per character of a value.
|
|
185
|
+
*
|
|
186
|
+
* Uniform random base62 has an expected ~5.5 bits/char, random hex ~4.0; natural
|
|
187
|
+
* language and dates sit far lower (~3–3.4). The assignment rule uses the gap:
|
|
188
|
+
* a long value over a secret-bearing identifier is only a finding when its
|
|
189
|
+
* per-character entropy is indistinguishable from random.
|
|
190
|
+
*
|
|
191
|
+
* @param value - The string to measure.
|
|
192
|
+
* @returns Bits per character (0 for the empty string).
|
|
193
|
+
*/
|
|
194
|
+
export function shannonEntropyPerChar(value) {
|
|
195
|
+
if (value.length === 0)
|
|
196
|
+
return 0;
|
|
197
|
+
const counts = new Map();
|
|
198
|
+
for (const ch of value)
|
|
199
|
+
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
200
|
+
let entropy = 0;
|
|
201
|
+
for (const count of counts.values()) {
|
|
202
|
+
const p = count / value.length;
|
|
203
|
+
entropy -= p * Math.log2(p);
|
|
204
|
+
}
|
|
205
|
+
return entropy;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Whether an `identifier = "value"` assignment looks like a hardcoded secret.
|
|
209
|
+
*
|
|
210
|
+
* Fires only when the identifier names a credential (token/secret/password/
|
|
211
|
+
* credential/api_key/access_key/private_key segment) AND the value is long
|
|
212
|
+
* (≥20 chars), high-entropy (≥3.9 bits/char — above dates, timestamps, prose),
|
|
213
|
+
* and the identifier names a credential. Both halves are required: identifier-only would
|
|
214
|
+
* flag every `token: "the token above"` prose reference, entropy-only would
|
|
215
|
+
* flag every generated id.
|
|
216
|
+
*
|
|
217
|
+
* @param identifier - The assignment target, e.g. `AWS_SECRET_ACCESS_KEY`.
|
|
218
|
+
* @param value - The assigned value text.
|
|
219
|
+
* @returns Whether the pair reads as a hardcoded credential.
|
|
220
|
+
*/
|
|
221
|
+
export function isHighEntropySecretAssignment(identifier, value) {
|
|
222
|
+
if (value.length < 20)
|
|
223
|
+
return false;
|
|
224
|
+
const segments = identifier.replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
|
|
225
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[_-]+/).filter(Boolean);
|
|
226
|
+
const namesSecret = segments.some((segment) => SECRET_IDENTIFIER_SEGMENTS.includes(segment)) ||
|
|
227
|
+
SECRET_IDENTIFIER_PAIRS.includes(segments.join("_"));
|
|
228
|
+
if (!namesSecret)
|
|
229
|
+
return false;
|
|
230
|
+
return shannonEntropyPerChar(value) >= 3.9;
|
|
231
|
+
}
|
|
232
|
+
/** Sticky `\uXXXX` matcher: reads at one offset without copying the line suffix. */
|
|
233
|
+
const UNICODE_ESCAPE = /\\u([0-9a-f]{4})/iy;
|
|
234
|
+
/**
|
|
235
|
+
* Scan one added line with every content rule.
|
|
236
|
+
*
|
|
237
|
+
* Rules are evaluated independently: one line can produce several hits (a
|
|
238
|
+
* bearer header wrapping a GitHub token reports both shapes), because each is a
|
|
239
|
+
* distinct reviewed rule. The high-entropy assignment rule is regex-scan +
|
|
240
|
+
* predicate, so it is driven here rather than in the static table.
|
|
241
|
+
*
|
|
242
|
+
* @param line - The added line text (diff `+` already stripped).
|
|
243
|
+
* @returns Every rule hit on the line, in evaluation order.
|
|
244
|
+
*/
|
|
245
|
+
export function scanLineForRuleHits(line) {
|
|
246
|
+
// Serialized tracker text may escape separators or provider prefixes. Map
|
|
247
|
+
// decoded characters back to their original offsets for history attribution.
|
|
248
|
+
const offsets = [];
|
|
249
|
+
let decoded = "";
|
|
250
|
+
for (let index = 0; index < line.length; index++) {
|
|
251
|
+
// A sticky match at the current offset keeps the decoder linear in the line
|
|
252
|
+
// length; slicing the suffix per character is quadratic where slices copy.
|
|
253
|
+
UNICODE_ESCAPE.lastIndex = index;
|
|
254
|
+
const unicode = line[index] === "\\" ? UNICODE_ESCAPE.exec(line) : null;
|
|
255
|
+
if (unicode) {
|
|
256
|
+
decoded += String.fromCharCode(Number.parseInt(unicode[1], 16));
|
|
257
|
+
offsets.push(index);
|
|
258
|
+
index += 5;
|
|
259
|
+
}
|
|
260
|
+
else if (line[index] === "\\" && /[\\/nrt]/.test(line[index + 1] ?? "")) {
|
|
261
|
+
decoded += { n: "\n", r: "\r", t: "\t" }[line[index + 1]] ?? line[index + 1];
|
|
262
|
+
offsets.push(index);
|
|
263
|
+
index++;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
decoded += line[index];
|
|
267
|
+
offsets.push(index);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const hits = [];
|
|
271
|
+
for (const rule of [...CREDENTIAL_RULES, ...PERSONAL_DATA_RULES, ...HOST_PATH_RULES]) {
|
|
272
|
+
rule.pattern.lastIndex = 0;
|
|
273
|
+
for (let match = rule.pattern.exec(decoded); match; match = rule.pattern.exec(decoded)) {
|
|
274
|
+
if (!rule.confirm || rule.confirm(match[0])) {
|
|
275
|
+
const matched = rule.rule === "home-username" ? match[0].slice(match[0].indexOf("~")) : match[0];
|
|
276
|
+
hits.push({ rule: rule.rule, matched, index: offsets[match.index] });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
HIGH_ENTROPY_ASSIGNMENT.lastIndex = 0;
|
|
281
|
+
for (let match = HIGH_ENTROPY_ASSIGNMENT.exec(decoded); match; match = HIGH_ENTROPY_ASSIGNMENT.exec(decoded)) {
|
|
282
|
+
const [identifier, value] = [match[1], match[2]];
|
|
283
|
+
if (isHighEntropySecretAssignment(identifier, value)) {
|
|
284
|
+
hits.push({ rule: "high-entropy-assignment", matched: value, index: offsets[match.index] });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return hits;
|
|
288
|
+
}
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Item id + field attribution
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
/** Top-level field header of a `.toon` line, e.g. `body:`, `notes[7]{…}:`, `tags[5]:`. */
|
|
293
|
+
const TOON_FIELD_HEADER = /^([a-z][a-z0-9_]*)(?:\[\d+\])?(?:\{[^}]*\})?\s*:/;
|
|
294
|
+
/**
|
|
295
|
+
* Attribute a toon field to every line of a change walk.
|
|
296
|
+
*
|
|
297
|
+
* Walks the lines in file order maintaining the enclosing top-level field: a
|
|
298
|
+
* field header (added OR context) updates the tracker, so an added section row
|
|
299
|
+
* (`"ts",author,"text"`) attributes to its section (`notes`, `files`, …) even
|
|
300
|
+
* when the section header itself is unchanged context. Added lines that are
|
|
301
|
+
* their own field header attribute to that field.
|
|
302
|
+
*
|
|
303
|
+
* @param lines - Added and context lines in walk order.
|
|
304
|
+
* @returns A field name per input line (never `undefined`; `"unknown"` when no header was seen yet).
|
|
305
|
+
*/
|
|
306
|
+
export function attributeToonFields(lines) {
|
|
307
|
+
let current = "unknown";
|
|
308
|
+
return lines.map((line) => {
|
|
309
|
+
const match = TOON_FIELD_HEADER.exec(line.text);
|
|
310
|
+
if (match)
|
|
311
|
+
current = match[1];
|
|
312
|
+
return current;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/** Item id statement inside a pm history JSONL event. */
|
|
316
|
+
const JSONL_ITEM_ID = /"path":"\/metadata\/id"\s*,\s*"value":"([^"]+)"/;
|
|
317
|
+
/**
|
|
318
|
+
* Resolve the item id for a changed tracker file.
|
|
319
|
+
*
|
|
320
|
+
* `.toon` files are named `<id>.toon`; history JSONL carries the id inside the
|
|
321
|
+
* event (the file itself is also named `<id>.jsonl`, but reading the event makes
|
|
322
|
+
* a synthesized diff work identically); other paths have no item id.
|
|
323
|
+
*
|
|
324
|
+
* @param filePath - The changed file path.
|
|
325
|
+
* @param line - The added line being attributed (already needed for JSONL).
|
|
326
|
+
* @returns The item id, or `""`.
|
|
327
|
+
*/
|
|
328
|
+
function itemIdForFile(filePath, line) {
|
|
329
|
+
const base = path.basename(filePath);
|
|
330
|
+
if (base.endsWith(".toon"))
|
|
331
|
+
return base.slice(0, -".toon".length);
|
|
332
|
+
if (base.endsWith(".jsonl"))
|
|
333
|
+
return JSONL_ITEM_ID.exec(line)?.[1] ?? base.slice(0, -".jsonl".length);
|
|
334
|
+
return "";
|
|
335
|
+
}
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Change collection — unified diff parsing and Git working-tree collection
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
/**
|
|
340
|
+
* Parse a unified diff into per-file added lines with toon field attribution.
|
|
341
|
+
*
|
|
342
|
+
* Accepts `git diff` (with or without `a/` `b/` prefixes) and plain
|
|
343
|
+
* `diff -u` output. Removed lines are skipped — only the proposed ADDITIONS can
|
|
344
|
+
* publish new content; deletions cannot leak anything that was not already in
|
|
345
|
+
* the tracked state.
|
|
346
|
+
*
|
|
347
|
+
* @param diffText - The unified diff text.
|
|
348
|
+
* @returns One entry per changed file with added lines and attribution.
|
|
349
|
+
*/
|
|
350
|
+
export function parseUnifiedDiff(diffText) {
|
|
351
|
+
if (diffText.trim() !== "" && !/^\+\+\+ /m.test(diffText) && !/^diff --git /m.test(diffText)) {
|
|
352
|
+
throw new GateInputError("pm github gate: input is not a unified diff.");
|
|
353
|
+
}
|
|
354
|
+
const files = [];
|
|
355
|
+
let filePath = "";
|
|
356
|
+
let itemId = "";
|
|
357
|
+
let walk = [];
|
|
358
|
+
let oldRemaining = 0;
|
|
359
|
+
let newRemaining = 0;
|
|
360
|
+
let inHunk = false;
|
|
361
|
+
const completeHunk = () => {
|
|
362
|
+
if (oldRemaining !== 0 || newRemaining !== 0) {
|
|
363
|
+
throw new GateInputError("pm github gate: unified diff is truncated or malformed.");
|
|
364
|
+
}
|
|
365
|
+
inHunk = false;
|
|
366
|
+
};
|
|
367
|
+
const flushFile = () => {
|
|
368
|
+
if (filePath === "" || walk.length === 0) {
|
|
369
|
+
walk = [];
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const fields = attributeToonFields(walk);
|
|
373
|
+
const added = walk
|
|
374
|
+
.map((line, index) => ({ line, field: fields[index] }))
|
|
375
|
+
.filter((entry) => entry.line.added)
|
|
376
|
+
.map((entry) => ({ text: entry.line.text, field: entry.field }));
|
|
377
|
+
files.push({ filePath, itemId, addedLines: added });
|
|
378
|
+
walk = [];
|
|
379
|
+
};
|
|
380
|
+
const lines = diffText.split("\n");
|
|
381
|
+
for (const raw of lines) {
|
|
382
|
+
if (raw === "GIT binary patch" || raw.startsWith("Binary files ") || raw.includes("\0")) {
|
|
383
|
+
throw new GateInputError("pm github gate: binary input cannot be privacy-scanned.");
|
|
384
|
+
}
|
|
385
|
+
if (raw.startsWith("diff --git ") || (raw.startsWith("--- ") && !(inHunk && oldRemaining > 0))) {
|
|
386
|
+
completeHunk();
|
|
387
|
+
flushFile();
|
|
388
|
+
filePath = "";
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (raw.startsWith("+++ ") && !(inHunk && newRemaining > 0)) {
|
|
392
|
+
completeHunk();
|
|
393
|
+
flushFile();
|
|
394
|
+
filePath = raw.slice(4).split("\t")[0].trim();
|
|
395
|
+
if (filePath.startsWith('"')) {
|
|
396
|
+
try {
|
|
397
|
+
filePath = JSON.parse(filePath);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
throw new GateInputError("pm github gate: unreadable diff filename.");
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (filePath === "/dev/null")
|
|
404
|
+
filePath = "";
|
|
405
|
+
else
|
|
406
|
+
filePath = filePath.replace(/^[ab]\//, "");
|
|
407
|
+
itemId = "";
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
// Header and index lines before a target are metadata, but a hunk is parsed
|
|
411
|
+
// even without one: its added lines must never pass unscanned.
|
|
412
|
+
if (filePath === "" && !inHunk && !raw.startsWith("@@"))
|
|
413
|
+
continue;
|
|
414
|
+
if (raw.startsWith("@@")) {
|
|
415
|
+
completeHunk();
|
|
416
|
+
const hunk = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(raw);
|
|
417
|
+
if (!hunk)
|
|
418
|
+
throw new GateInputError("pm github gate: invalid unified diff hunk.");
|
|
419
|
+
oldRemaining = Number(hunk[1] ?? 1);
|
|
420
|
+
newRemaining = Number(hunk[2] ?? 1);
|
|
421
|
+
inHunk = true;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// Diff metadata (``) is checked before the marker
|
|
425
|
+
// checks: real added content is always `+`-prefixed, so a bare `\\` line is
|
|
426
|
+
// never content.
|
|
427
|
+
if (raw.startsWith("\\"))
|
|
428
|
+
continue;
|
|
429
|
+
if (raw === "" && !inHunk)
|
|
430
|
+
continue;
|
|
431
|
+
if (!inHunk)
|
|
432
|
+
throw new GateInputError("pm github gate: content outside a diff hunk.");
|
|
433
|
+
if (raw.startsWith("+")) {
|
|
434
|
+
// Only `+++ /dev/null` or a header without a ---/+++ pair leaves no
|
|
435
|
+
// target; Git never adds lines there, so an operator-supplied --diff that
|
|
436
|
+
// does is malformed and fails closed rather than passing vacuously.
|
|
437
|
+
if (filePath === "")
|
|
438
|
+
throw new GateInputError("pm github gate: added diff content has no target file.");
|
|
439
|
+
newRemaining--;
|
|
440
|
+
const text = raw.slice(1);
|
|
441
|
+
if (itemId === "")
|
|
442
|
+
itemId = itemIdForFile(filePath, text);
|
|
443
|
+
walk.push({ text, added: true });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (raw.startsWith("-")) {
|
|
447
|
+
oldRemaining--;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (raw.startsWith(" ")) {
|
|
451
|
+
oldRemaining--;
|
|
452
|
+
newRemaining--;
|
|
453
|
+
// The enclosing branch guarantees the leading space.
|
|
454
|
+
walk.push({ text: raw.slice(1), added: false });
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (raw === "" && oldRemaining === 0 && newRemaining === 0) {
|
|
458
|
+
completeHunk();
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
throw new GateInputError("pm github gate: invalid unified diff content.");
|
|
462
|
+
}
|
|
463
|
+
completeHunk();
|
|
464
|
+
flushFile();
|
|
465
|
+
return files;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Read one whole file as a proposed addition (an untracked tracker file).
|
|
469
|
+
*
|
|
470
|
+
* A file Git has never tracked has no diff, but its full content is exactly the
|
|
471
|
+
* change a commit would publish, so every line is treated as added.
|
|
472
|
+
*
|
|
473
|
+
* @param filePath - Absolute path of the file to read.
|
|
474
|
+
* @param repoRelative - Repo-relative path for attribution.
|
|
475
|
+
* @param readFileSync - File reader (injectable).
|
|
476
|
+
* @returns The change file with all lines added.
|
|
477
|
+
*/
|
|
478
|
+
function wholeFileChange(filePath, repoRelative, readFileSync) {
|
|
479
|
+
const content = readFileSync(filePath);
|
|
480
|
+
if (content.includes("\0"))
|
|
481
|
+
throw new GateInputError("pm github gate: binary input cannot be privacy-scanned.");
|
|
482
|
+
const lines = content.split("\n").map((text) => ({ text, added: true }));
|
|
483
|
+
const fields = attributeToonFields(lines);
|
|
484
|
+
let itemId = "";
|
|
485
|
+
const addedLines = lines.map((line, index) => {
|
|
486
|
+
if (itemId === "")
|
|
487
|
+
itemId = itemIdForFile(repoRelative, line.text);
|
|
488
|
+
return { text: line.text, field: fields[index] };
|
|
489
|
+
});
|
|
490
|
+
return { filePath: repoRelative, itemId, addedLines };
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Operational tracker subpaths the gate does not scan.
|
|
494
|
+
*
|
|
495
|
+
* `locks/`, `extensions/`, and `checkpoints/` hold transient process state,
|
|
496
|
+
* installed extension code, and resumable journals — none of it is pm item
|
|
497
|
+
* content, all of it is either gitignored or runtime-managed, and scanning
|
|
498
|
+
* installed extension code would re-litigate reviewed package text on every
|
|
499
|
+
* run. Item files, history, schema, and settings stay in scope.
|
|
500
|
+
*/
|
|
501
|
+
const UNTRACKED_SCAN_EXCLUDED = ["locks", "extensions", "checkpoints"];
|
|
502
|
+
/**
|
|
503
|
+
* Whether a repo-relative changed path is operational state, out of gate scope.
|
|
504
|
+
*
|
|
505
|
+
* @param repoRelative - Repo-relative path of the changed file.
|
|
506
|
+
* @returns Whether the path is excluded from scanning.
|
|
507
|
+
*/
|
|
508
|
+
function isOperationalTrackerPath(repoRelative) {
|
|
509
|
+
const normalized = repoRelative.replace(/\\/g, "/");
|
|
510
|
+
return UNTRACKED_SCAN_EXCLUDED.includes(normalized.split("/")[0]);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Run Git with the production subprocess runner.
|
|
514
|
+
*
|
|
515
|
+
* Trimmed stdout is returned as-is; a non-zero exit yields `ok: false` with the
|
|
516
|
+
* child's stderr so the fail-closed collector can report a diagnosable error.
|
|
517
|
+
*
|
|
518
|
+
* @param cwd - Directory to run in.
|
|
519
|
+
* @param args - Git argument vector.
|
|
520
|
+
* @returns The normalized subprocess result.
|
|
521
|
+
*/
|
|
522
|
+
export function runGitDefault(cwd, args) {
|
|
523
|
+
const result = spawnSync("git", ["-C", cwd, ...args], { maxBuffer: 64 * 1024 * 1024 });
|
|
524
|
+
return {
|
|
525
|
+
ok: result.status === 0,
|
|
526
|
+
// stdout/stderr are nullish only when the child never spawned, and spawnSync
|
|
527
|
+
// then always reports the failure through `result.error`, so no second
|
|
528
|
+
// "git failed" fallback can ever be needed beyond String(result.error).
|
|
529
|
+
stdout: new TextDecoder("utf-8", { fatal: true }).decode(result.stdout ?? Buffer.alloc(0)),
|
|
530
|
+
stderr: result.stderr ? new TextDecoder("utf-8", { fatal: true }).decode(result.stderr) : String(result.error),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Read tracker, diff, or allowlist text without silently replacing invalid bytes.
|
|
535
|
+
*
|
|
536
|
+
* @param filePath - Input file to decode as UTF-8.
|
|
537
|
+
* @returns Complete decoded text; malformed input throws and fails the gate.
|
|
538
|
+
*/
|
|
539
|
+
function readTrackerText(filePath) {
|
|
540
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(filePath));
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Collect the proposed tracker change from the Git working tree.
|
|
544
|
+
*
|
|
545
|
+
* The proposed change is everything a commit would publish: staged plus unstaged
|
|
546
|
+
* modifications of tracked files under the tracker path (their added diff lines)
|
|
547
|
+
* and the full content of untracked files under it. Unreachable/deleted paths
|
|
548
|
+
* contribute nothing; operational subpaths are excluded by
|
|
549
|
+
* {@link isOperationalTrackerPath}. Any Git or read failure throws
|
|
550
|
+
* {@link GateInputError} — fail closed, never pass vacuously.
|
|
551
|
+
*
|
|
552
|
+
* @param pmDataDir - The resolved pm data directory.
|
|
553
|
+
* @param dependencies - Injectable Git runner and file reader.
|
|
554
|
+
* @returns One change entry per scanned file (empty when the tree is clean).
|
|
555
|
+
*/
|
|
556
|
+
export function collectTrackerChange(pmDataDir, dependencies = {}) {
|
|
557
|
+
const runGit = dependencies.runGit ?? runGitDefault;
|
|
558
|
+
const readFileSync = dependencies.readFileSync ?? readTrackerText;
|
|
559
|
+
const toplevel = runGit(pmDataDir, ["rev-parse", "--show-toplevel"]);
|
|
560
|
+
if (!toplevel.ok || toplevel.stdout.trim() === "") {
|
|
561
|
+
throw new GateInputError("pm github gate: the pm root is not inside a Git work tree, so the proposed tracker change cannot be read. " +
|
|
562
|
+
"Run inside the repository, or pass --diff <file> to scan an explicit diff.");
|
|
563
|
+
}
|
|
564
|
+
const repoRoot = fs.realpathSync(toplevel.stdout.trim());
|
|
565
|
+
const trackerDir = fs.realpathSync(pmDataDir);
|
|
566
|
+
const trackerRel = path.relative(repoRoot, trackerDir);
|
|
567
|
+
if (trackerRel.startsWith("..")) {
|
|
568
|
+
throw new GateInputError("pm github gate: the resolved pm tracker path escapes the Git work tree; refusing to scan an unclear change.");
|
|
569
|
+
}
|
|
570
|
+
const trackerSpec = trackerRel === "" ? "." : trackerRel;
|
|
571
|
+
// Literal pathspecs: a tracker path must never be read as pathspec magic
|
|
572
|
+
// (":(...)", ":!") or a glob, or its diff could come back empty and unread.
|
|
573
|
+
const status = runGit(repoRoot, ["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", trackerSpec]);
|
|
574
|
+
if (!status.ok) {
|
|
575
|
+
throw new GateInputError("pm github gate: git status failed; refusing to scan incomplete input.");
|
|
576
|
+
}
|
|
577
|
+
const changes = [];
|
|
578
|
+
const tokens = status.stdout.split("\0").filter((token) => token !== "");
|
|
579
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
580
|
+
const entry = tokens[index];
|
|
581
|
+
const xy = entry.slice(0, 2);
|
|
582
|
+
const filePath = entry.slice(3);
|
|
583
|
+
// Rename/copy entries carry the original path as the next NUL token; it is
|
|
584
|
+
// provenance of the rename, not a second changed path.
|
|
585
|
+
if (xy[0] === "R" || xy[0] === "C" || xy[1] === "R" || xy[1] === "C")
|
|
586
|
+
index++;
|
|
587
|
+
if (xy === "??" && isOperationalTrackerPath(path.relative(trackerDir, path.join(repoRoot, filePath))))
|
|
588
|
+
continue;
|
|
589
|
+
if (xy === "??") {
|
|
590
|
+
const absolute = path.join(repoRoot, filePath);
|
|
591
|
+
let size = 0;
|
|
592
|
+
let symlink = false;
|
|
593
|
+
try {
|
|
594
|
+
const stat = fs.lstatSync(absolute);
|
|
595
|
+
size = stat.size;
|
|
596
|
+
symlink = stat.isSymbolicLink();
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
throw new GateInputError("pm github gate: a proposed file cannot be inspected.");
|
|
600
|
+
}
|
|
601
|
+
if (size > UNTRACKED_FILE_BYTE_CAP) {
|
|
602
|
+
throw new GateInputError(`pm github gate: an untracked tracker file exceeds the ${UNTRACKED_FILE_BYTE_CAP}-byte scan cap; refusing to guess.`);
|
|
603
|
+
}
|
|
604
|
+
changes.push(wholeFileChange(absolute, filePath, symlink ? (file) => fs.readlinkSync(file) : readFileSync));
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (xy.includes("D") && !/[AMU]/.test(xy))
|
|
608
|
+
continue; // pure deletion
|
|
609
|
+
const addedLines = [];
|
|
610
|
+
for (const revision of [["--cached"], []]) {
|
|
611
|
+
const diff = runGit(repoRoot, ["--literal-pathspecs", "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--unified=2147483647", ...revision, "--", filePath]);
|
|
612
|
+
if (!diff.ok) {
|
|
613
|
+
throw new GateInputError("pm github gate: git diff failed; refusing to scan incomplete input.");
|
|
614
|
+
}
|
|
615
|
+
for (const parsed of parseUnifiedDiff(diff.stdout))
|
|
616
|
+
addedLines.push(...parsed.addedLines);
|
|
617
|
+
}
|
|
618
|
+
if (addedLines.length > 0)
|
|
619
|
+
changes.push({ filePath, itemId: itemIdForFile(filePath, ""), addedLines });
|
|
620
|
+
}
|
|
621
|
+
return changes;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Parse a gate allowlist file.
|
|
625
|
+
*
|
|
626
|
+
* The file maps the sha256 of REVIEWED matched content to a written
|
|
627
|
+
* justification. Entries without a non-empty reason are rejected — an unreviewed
|
|
628
|
+
* exemption is how a gate rots. A missing file fails closed only when it was
|
|
629
|
+
* passed explicitly; the default location is optional so a clean repo needs no
|
|
630
|
+
* allowlist file.
|
|
631
|
+
*
|
|
632
|
+
* @param allowlistPath - Absolute path of the allowlist JSON.
|
|
633
|
+
* @param required - Whether an explicit caller named the file (missing → error).
|
|
634
|
+
* @param readFileSync - File reader (injectable).
|
|
635
|
+
* @returns Hash → justification entries.
|
|
636
|
+
*/
|
|
637
|
+
export function readGateAllowlist(allowlistPath, required, readFileSync = (filePath) => fs.readFileSync(filePath, "utf-8")) {
|
|
638
|
+
let raw;
|
|
639
|
+
try {
|
|
640
|
+
raw = readFileSync(allowlistPath);
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
if (!required && err.code === "ENOENT")
|
|
644
|
+
return new Map();
|
|
645
|
+
throw new GateInputError("pm github gate: allowlist file is unreadable.");
|
|
646
|
+
}
|
|
647
|
+
let parsed;
|
|
648
|
+
try {
|
|
649
|
+
parsed = JSON.parse(raw);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
throw new GateInputError("pm github gate: allowlist file is not valid JSON.");
|
|
653
|
+
}
|
|
654
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
655
|
+
throw new GateInputError(`pm github gate: allowlist file must be a JSON object keyed by content hash`);
|
|
656
|
+
}
|
|
657
|
+
const entries = new Map();
|
|
658
|
+
for (const [hash, value] of Object.entries(parsed)) {
|
|
659
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) {
|
|
660
|
+
throw new GateInputError(`pm github gate: allowlist key is not a sha256 content hash`);
|
|
661
|
+
}
|
|
662
|
+
const reason = value?.reason;
|
|
663
|
+
if (typeof reason !== "string" || reason.trim() === "") {
|
|
664
|
+
throw new GateInputError(`pm github gate: allowlist entry ${hash.slice(0, 12)}… has no review justification`);
|
|
665
|
+
}
|
|
666
|
+
entries.set(hash, { reason: reason.trim() });
|
|
667
|
+
}
|
|
668
|
+
return entries;
|
|
669
|
+
}
|
|
670
|
+
/** Default allowlist filename, resolved at the repository root when one exists. */
|
|
671
|
+
export const GATE_ALLOWLIST_FILENAME = ".pm-github-gate-allowlist.json";
|
|
672
|
+
/** Byte cap for reading one untracked tracker file whole (8 MiB). */
|
|
673
|
+
const UNTRACKED_FILE_BYTE_CAP = 8 * 1024 * 1024;
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
// Gate run
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
/**
|
|
678
|
+
* Scan one change file's added lines and proposed filename and return raw findings.
|
|
679
|
+
*
|
|
680
|
+
* Each hit is attributed to the changed file's item id; the field comes from the
|
|
681
|
+
* toon walk for `.toon` files and from the enclosing JSONL patch entry for
|
|
682
|
+
* history files. Duplicate hits of the same rule + content + item + field
|
|
683
|
+
* within one file collapse to one finding, so one pasted token repeated in a
|
|
684
|
+
* body reads as one finding, not as noise.
|
|
685
|
+
*
|
|
686
|
+
* @param change - The proposed change for one file.
|
|
687
|
+
* @returns Raw findings including matched content (used for hashing only).
|
|
688
|
+
*/
|
|
689
|
+
function scanChangeFile(change) {
|
|
690
|
+
const findings = [];
|
|
691
|
+
const seen = new Set();
|
|
692
|
+
const proposedLines = change.addedLines.length === 0 ? [] : [
|
|
693
|
+
{ text: change.filePath, field: "file_path" }, ...change.addedLines,
|
|
694
|
+
];
|
|
695
|
+
for (const line of proposedLines) {
|
|
696
|
+
const values = [];
|
|
697
|
+
if (change.filePath.endsWith(".jsonl") && line.field !== "file_path") {
|
|
698
|
+
if (line.text.trim() === "")
|
|
699
|
+
continue;
|
|
700
|
+
// Structural JSON patch paths are pointers, while their values are data.
|
|
701
|
+
// Decode data before scanning so escaped separators cannot bypass a rule.
|
|
702
|
+
let parsed;
|
|
703
|
+
try {
|
|
704
|
+
parsed = JSON.parse(line.text);
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
throw new GateInputError("pm github gate: malformed history event.");
|
|
708
|
+
}
|
|
709
|
+
const pending = [{ value: parsed, field: "unknown" }];
|
|
710
|
+
while (pending.length > 0) {
|
|
711
|
+
const entry = pending.pop();
|
|
712
|
+
if (typeof entry.value === "string")
|
|
713
|
+
values.push({ text: entry.value, field: entry.field });
|
|
714
|
+
else if (Array.isArray(entry.value)) {
|
|
715
|
+
for (const value of entry.value)
|
|
716
|
+
pending.push({ value, field: entry.field });
|
|
717
|
+
}
|
|
718
|
+
else if (entry.value !== null && typeof entry.value === "object") {
|
|
719
|
+
const record = entry.value;
|
|
720
|
+
const patch = typeof record.op === "string" && ["add", "replace", "remove", "test", "move", "copy"].includes(record.op) && typeof record.path === "string";
|
|
721
|
+
if (patch) {
|
|
722
|
+
for (const key of ["path", "from"]) {
|
|
723
|
+
// Pointers are scanned like data: the host-path rules are anchored
|
|
724
|
+
// to host roots, so `/metadata/...` and `/body` pointers never match,
|
|
725
|
+
// and a secret embedded in a field key is still caught.
|
|
726
|
+
if (typeof record[key] === "string")
|
|
727
|
+
values.push({ text: record[key], field: key });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (patch && Object.hasOwn(record, "value")) {
|
|
731
|
+
const parts = record.path.split("/").filter(Boolean);
|
|
732
|
+
pending.push({ value: record.value, field: parts[0] === "metadata" ? (parts[1] ?? "metadata") : (parts[0] ?? "unknown") });
|
|
733
|
+
}
|
|
734
|
+
for (const [key, value] of Object.entries(record)) {
|
|
735
|
+
if (patch && ["op", "path", "value", "from"].includes(key))
|
|
736
|
+
continue;
|
|
737
|
+
// Keys are data too: a credential used as an object key must not pass
|
|
738
|
+
// unscanned (a .toon line with the same content is scanned as text).
|
|
739
|
+
values.push({ text: key, field: entry.field });
|
|
740
|
+
pending.push({ value, field: key === "metadata" || key === "patch" ? entry.field : key });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
else
|
|
746
|
+
values.push({ text: line.text, field: line.field });
|
|
747
|
+
for (const value of values) {
|
|
748
|
+
for (const hit of scanLineForRuleHits(value.text)) {
|
|
749
|
+
const field = value.field;
|
|
750
|
+
const itemId = change.itemId || itemIdForFile(change.filePath, line.text);
|
|
751
|
+
const key = `${hit.rule}\0${itemId}\0${field}\0${hit.matched}`;
|
|
752
|
+
if (seen.has(key))
|
|
753
|
+
continue;
|
|
754
|
+
seen.add(key);
|
|
755
|
+
findings.push({
|
|
756
|
+
rule: hit.rule,
|
|
757
|
+
item_id: scanLineForRuleHits(itemId).length > 0 ? "" : itemId,
|
|
758
|
+
field: scanLineForRuleHits(field).length > 0 ? "unknown" : field,
|
|
759
|
+
hash: createHash("sha256").update(hit.matched).digest("hex"),
|
|
760
|
+
matched: hit.matched,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return findings;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Run the fail-closed privacy gate over a proposed tracker change.
|
|
769
|
+
*
|
|
770
|
+
* Resolves the change (explicit `--diff` file, or the staged/working/untracked
|
|
771
|
+
* diff under the resolved pm tracker path), scans every added line with the
|
|
772
|
+
* credential, personal-data, and host-path rules, and applies the reviewed
|
|
773
|
+
* content-hash allowlist. Any input, Git, allowlist, or scanner failure throws
|
|
774
|
+
* {@link GateInputError}; a returned report therefore always describes a
|
|
775
|
+
* completed scan.
|
|
776
|
+
*
|
|
777
|
+
* @param input - pm root, optional diff file, optional allowlist file, injectable deps.
|
|
778
|
+
* @returns The machine-readable gate report.
|
|
779
|
+
*/
|
|
780
|
+
export function runTrackerGate(input) {
|
|
781
|
+
const readFileSync = input.dependencies?.readFileSync ?? readTrackerText;
|
|
782
|
+
const runGit = input.dependencies?.runGit ?? runGitDefault;
|
|
783
|
+
const pmDataDir = resolvePmDataDir(input.pmRoot);
|
|
784
|
+
let source;
|
|
785
|
+
let files;
|
|
786
|
+
try {
|
|
787
|
+
if (input.diffFile) {
|
|
788
|
+
source = "diff";
|
|
789
|
+
const diffText = readFileSync(path.resolve(input.diffFile));
|
|
790
|
+
files = parseUnifiedDiff(diffText);
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
source = "git";
|
|
794
|
+
files = collectTrackerChange(pmDataDir, { runGit, readFileSync });
|
|
795
|
+
}
|
|
796
|
+
for (const item of input.plannedItems ?? []) {
|
|
797
|
+
files.push({ filePath: "planned.jsonl", itemId: item.itemId, addedLines: [{
|
|
798
|
+
text: JSON.stringify({ metadata: item.fields }), field: "unknown",
|
|
799
|
+
}] });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
if (err instanceof GateInputError)
|
|
804
|
+
throw err;
|
|
805
|
+
// A collaborator error that already carries the gate's own message prefix
|
|
806
|
+
// (an injected runner raising a gate-scoped diagnostic) must pass through
|
|
807
|
+
// unwrapped instead of being re-labelled as an unreadable input.
|
|
808
|
+
if (err instanceof Error && err.message.startsWith("pm github gate:"))
|
|
809
|
+
throw err;
|
|
810
|
+
throw new GateInputError("pm github gate: could not read the proposed tracker change; input is unavailable.");
|
|
811
|
+
}
|
|
812
|
+
let allowlist = new Map();
|
|
813
|
+
let allowlistPath = "";
|
|
814
|
+
if (input.allowlistFile) {
|
|
815
|
+
allowlistPath = input.allowlistFile;
|
|
816
|
+
allowlist = readGateAllowlist(path.resolve(input.allowlistFile), true, readFileSync);
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
const toplevel = runGit(pmDataDir, ["rev-parse", "--show-toplevel"]);
|
|
820
|
+
if (toplevel.ok) {
|
|
821
|
+
allowlistPath = path.join(toplevel.stdout.trim(), GATE_ALLOWLIST_FILENAME);
|
|
822
|
+
allowlist = readGateAllowlist(allowlistPath, false, readFileSync);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
// The scan phase below only ever throws GateInputError (malformed history
|
|
826
|
+
// events); every other operation is pure regex/Map/array work that cannot
|
|
827
|
+
// throw, so no defensive wrap is needed and a thrown GateInputError already
|
|
828
|
+
// carries the user-facing message.
|
|
829
|
+
const findings = [];
|
|
830
|
+
let allowlisted = 0;
|
|
831
|
+
let addedLines = 0;
|
|
832
|
+
for (const change of files) {
|
|
833
|
+
addedLines += change.addedLines.length;
|
|
834
|
+
for (const raw of scanChangeFile(change)) {
|
|
835
|
+
if (allowlist.has(raw.hash)) {
|
|
836
|
+
allowlisted++;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
findings.push({ rule: raw.rule, item_id: raw.item_id, field: raw.field, hash: raw.hash });
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return {
|
|
843
|
+
verdict: findings.length > 0 ? "fail" : "pass",
|
|
844
|
+
source,
|
|
845
|
+
scanned_files: files.length,
|
|
846
|
+
added_lines: addedLines,
|
|
847
|
+
findings,
|
|
848
|
+
allowlisted,
|
|
849
|
+
allowlist_path: allowlistPath ? (input.allowlistFile ? "(explicit allowlist)" : GATE_ALLOWLIST_FILENAME) : "",
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Render a gate report as human-readable lines.
|
|
854
|
+
*
|
|
855
|
+
* Every line names only the rule, item, field, and short hash prefix — the
|
|
856
|
+
* matched content never appears, so gate output is safe to paste anywhere. The
|
|
857
|
+
* same lines feed the command's stderr and the import failure message, so the
|
|
858
|
+
* two surfaces cannot drift apart.
|
|
859
|
+
*
|
|
860
|
+
* @param report - The gate report to render.
|
|
861
|
+
* @returns One summary line, then one line per finding.
|
|
862
|
+
*/
|
|
863
|
+
export function formatGateReport(report) {
|
|
864
|
+
const summary = `pm github gate: ${report.verdict.toUpperCase()} — ${report.findings.length} finding(s), ` +
|
|
865
|
+
`${report.allowlisted} allowlisted, ${report.scanned_files} file(s), ${report.added_lines} added line(s)`;
|
|
866
|
+
const lines = [summary];
|
|
867
|
+
for (const finding of report.findings) {
|
|
868
|
+
lines.push(` ${finding.item_id || "(no item)"} · ${finding.field || "(unknown field)"} · ` +
|
|
869
|
+
`${finding.rule} · sha256:${finding.hash.slice(0, 12)}…`);
|
|
870
|
+
}
|
|
871
|
+
return lines;
|
|
872
|
+
}
|
|
873
|
+
//# sourceMappingURL=gate.js.map
|