session-orchestrator 5.8.0 → 5.10.0

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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +52 -2
  6. package/README.md +14 -10
  7. package/docs/USER-GUIDE.md +2 -0
  8. package/docs/ci-setup.md +46 -1
  9. package/docs/events-schema.md +71 -1
  10. package/docs/owner-config-schema.md +18 -1
  11. package/docs/owner-privacy-patterns.md +85 -0
  12. package/docs/scope-collision-guard.md +12 -2
  13. package/hooks/enforce-commands.mjs +9 -19
  14. package/hooks/enforce-scope.mjs +23 -42
  15. package/hooks/hooks-codex.json +1 -1
  16. package/hooks/hooks.json +1 -1
  17. package/hooks/post-bash-write-verify.mjs +38 -21
  18. package/package.json +2 -2
  19. package/scripts/emit-session.mjs +15 -0
  20. package/scripts/lib/ci-status-banner.mjs +38 -5
  21. package/scripts/lib/events-schema.mjs +8 -0
  22. package/scripts/lib/gates/gate-full.mjs +6 -0
  23. package/scripts/lib/gates/gate-helpers.mjs +2 -0
  24. package/scripts/lib/named-vault-resolver.mjs +1 -1
  25. package/scripts/lib/owner-yaml.mjs +33 -3
  26. package/scripts/lib/platform.mjs +70 -1
  27. package/scripts/lib/pre-push-remote-gate.mjs +107 -0
  28. package/scripts/lib/quality-gate-wave.mjs +40 -0
  29. package/scripts/lib/quality-gate.mjs +32 -26
  30. package/scripts/lib/redact-spans.mjs +8 -17
  31. package/scripts/lib/scope-gate.mjs +68 -5
  32. package/scripts/lib/session-record-repair.mjs +19 -8
  33. package/scripts/lib/session-schema/validator.mjs +12 -0
  34. package/scripts/lib/session-start-probes.mjs +20 -5
  35. package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
  36. package/scripts/lib/vault-backfill/template.mjs +1 -1
  37. package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
  38. package/scripts/lib/vault-status/board-writer.mjs +15 -7
  39. package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
  40. package/scripts/lib/wave-resource-gate.mjs +2 -1
  41. package/scripts/migrate-vault-paths.mjs +57 -60
  42. package/scripts/release.mjs +107 -37
  43. package/scripts/run-quality-gate.mjs +9 -39
  44. package/scripts/site-numbers.mjs +4 -0
  45. package/scripts/vault-consolidate.mjs +19 -4
  46. package/skills/_shared/fleet-protocol.md +2 -0
  47. package/skills/claude-md-drift-check/SKILL.md +1 -1
  48. package/skills/claude-md-drift-check/checker.mjs +14 -3
  49. package/skills/remote-offload/SKILL.md +1 -1
  50. package/skills/session-end/metrics-collection.md +39 -9
  51. package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
  52. package/skills/wave-executor/references/wave-loop-review.md +37 -7
@@ -1,121 +1,13 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * check-owner-leakage.mjs — Scan tracked files for owner-privacy leakage patterns.
4
- * Add --include-untracked to also scan new files that Git does not ignore.
5
- *
6
- * Implements the #462 audit trail durable CI guard (#471).
7
- * Canonicalization-before-matching refactor (issue #661): the historical
8
- * form-by-form treadmill (a fresh regex per encoding — P1 bare-no-slash #631,
9
- * P9 dash-encoded #634, …) is replaced by a single CANONICALIZATION step.
10
- * `canonicalizeLine()` collapses every known textual encoding of a path
11
- * (URL-percent, dash-as-separator, backslash, double-slash, `\uXXXX`/`%uXXXX`
12
- * escapes, HTML/numeric entities, unicode homoglyph slashes/dashes, case)
13
- * into ONE canonical slash-form. CP1 is matched against that canonical form;
14
- * the four DOT-anchored host/IP rules (CP2, CP3, CP7, CP8) are matched against
15
- * the raw line AND the canonical form (#1080). The remaining rules stay RAW by
16
- * measurement, not oversight — see the `canon` comment in the line loop for why
17
- * the slash-anchored (CP4, CP10) and slug-anchored (CP6) rules must not be
18
- * canonicalized. A novel encoding of the same path normalizes to the same
19
- * canonical string, so it is caught structurally — no new regex required. Err
20
- * toward over-matching: this is a security guard, a false-positive is cheap, a
21
- * false-negative ships a leak to the public mirror.
22
- *
23
- * Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]
24
- *
25
- * Forbidden patterns (canonical rules CP1–CP10):
26
- * CP1 personal home path `/Users/bernhardg…` — matched on the CANONICAL form,
27
- * so the slash-form (P1, #631), the dash-encoded projects-dir form
28
- * (legacy P9, #634), URL-percent-encoded, backslash, and any future
29
- * separator-encoding all collapse to one rule.
30
- * CP2 private GitLab host `gitlab.gotzendorfer.at`
31
- * CP3 private events domain `events.gotzendorfer.at`
32
- * CP4 private package scope `@goetzendorfer/…`
33
- * CP5 DEFAULT_GITLAB_HOST on line with 'gotzendorfer' OR as exported const
34
- * CP6 private project slugs (see PRIVATE_SLUGS constant below). Two scopes:
35
- * the tracked-file scan (runScan) uses the FULL 7-slug PRIVATE_SLUGS list;
36
- * the in-process vault-namespace guard (isOwnerLeakySegment) uses the
37
- * narrower CP6_INPROCESS_PATTERNS — PRIVATE_SLUGS minus VAULT_CLEAR_SLUGS,
38
- * leaving only 2 retained slugs. See the VAULT_CLEAR_SLUGS carve-out
39
- * (issue #59 owner decision 2026-07-18).
40
- * CP7 catch-all `gotzendorfer.at` not matching an allowlisted exclusion
41
- * CP8 full RFC1918 private dotted-quad (10.x.x.x / 192.168.x.x / 172.16-31.x.x)
42
- * — internal IP leak. Placeholder `.x` forms and CIDR/range notation are NOT
43
- * matched (only literal 4-octet IPs), so SSRF-range docs stay clean.
44
- * CP10 `~/Projects/<PersonalName>/` — personal-name segment in a Projects path
45
- * (name-denylist driven; see PERSONAL_NAMES constant below; issue #653)
46
- * CP11 host-local confidential customer/repo names — matched from a NEVER-COMMITTED
47
- * list referenced by owner.yaml `paths.confidential-names-file`
48
- * (env SO_CONFIDENTIAL_NAMES_FILE; issue #728a). UNIQUE among CP-rules: a CP11
49
- * violation REDACTS the matched name span from its reported lineContent, because
50
- * this scanner runs in a PUBLIC GitHub-Actions mirror — printing the confidential
51
- * name verbatim to the public CI log would be a worse leak than the one guarded.
52
- *
53
- * CP11 is INACTIVE unless a host-local names file is configured (the ~99% default),
54
- * so public CI and unconfigured hosts see no behaviour change.
55
- *
56
- * Legacy P-numbering note: P1+P9 are now ONE canonical rule (CP1). The FAIL
57
- * label still names the offending class for the audit trail.
58
- *
59
- * Exclusions (line-scoped, never whole-file):
60
- * 1. Lines with office@gotzendorfer.at or security@gotzendorfer.at
61
- * and no other gotzendorfer.at token
62
- * 2. https://gotzendorfer.at[...] URLs in README.md and the 3 manifest files
63
- * 3. Manifest author/email/url/websiteURL/privacyPolicyURL/termsOfServiceURL keys
64
- * in .claude-plugin/plugin.json, .claude-plugin/marketplace.json, .codex-plugin/plugin.json
65
- * (covered by exclusions 1 + 2 above; listed explicitly for audit trail)
66
- * 4. .orchestrator/audits/** — never scanned (excluded in file enumeration)
67
- * 5. tests/lib/events-default-url.test.mjs — ONLY the exact JSDoc contract line:
68
- * " * - No literal `events.gotzendorfer.at` URL appears anywhere in scripts/ or hooks/."
69
- * (a real string-literal events.gotzendorfer.at elsewhere in that file still FAILs)
70
- * 6. tests/scripts/export-hw-learnings.test.mjs — exempt from P8 ONLY: the RFC1918
71
- * IPs there are the redaction subject of the anonymizeString suite, not leaks.
72
- * 7. The public-site URL in the three PUBLISHED site pages (site/index.html,
73
- * site/impressum/index.html, site/datenschutz/index.html) — the domain is a
74
- * legally mandated publication (Impressum, §5 ECG) plus the JSON-LD
75
- * publisher/author identity and the rel="author" footer link. Like every
76
- * exclusion above it is LINE-SCOPED and reached only via isAllowlisted(), which
77
- * only CP3 and CP7 consult — CP1/CP2/CP4/CP5/CP6/CP8/CP10/CP11 still fail on
78
- * these files, on the very same line. (#1076)
79
- *
80
- * Exit codes:
81
- * 0 — all checks passed
82
- * 1 — at least one failure (or usage error / unreadable root)
83
- */
84
2
 
85
- import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
86
- import { join, extname, relative, basename, sep, resolve } from 'node:path';
3
+ import { readFileSync, readdirSync, statSync, existsSync, realpathSync, lstatSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
4
+ import { join, extname, relative, basename, sep, resolve, dirname, isAbsolute } from 'node:path';
87
5
  import { execFileSync } from 'node:child_process';
88
6
  import { argv } from 'node:process';
7
+ import { homedir } from 'node:os';
89
8
  import { fileURLToPath } from 'node:url';
90
9
  import { createRequire } from 'node:module';
91
- // NOTE: the two host-local helper modules (../config/host-paths.mjs and
92
- // ./confidential-names.mjs) are imported DYNAMICALLY inside
93
- // getConfidentialNamePatterns(), NOT statically here. This scanner is a
94
- // documented standalone single-file vendoring target (security.md § Owner-Privacy:
95
- // "Reuse the same scanner") — the .husky/pre-commit E2E and any consumer that
96
- // copies ONLY this file into a fresh tree would otherwise crash at module load
97
- // with ERR_MODULE_NOT_FOUND, blocking clean commits. Dynamic import lets CP11 go
98
- // inert (one WARN line, exit unchanged) when the helpers are absent while CP1–CP10
99
- // run unchanged. That degrade is scoped to ERR_MODULE_NOT_FOUND ALONE (#1244) —
100
- // every other CP11 load failure fails CLOSED; see getConfidentialNamePatterns().
101
10
 
102
- // ---------------------------------------------------------------------------
103
- // CLI / import-mode detection (#661)
104
- // ---------------------------------------------------------------------------
105
- //
106
- // This module is BOTH a CLI script (run by validate-plugin + .husky/pre-commit)
107
- // AND an importable library (the canonicalization helpers are unit-tested in
108
- // isolation). When imported, the top-level scan + process.exit() must NOT run.
109
- // `isMain` is true only when this file is the node entry point.
110
- // REALPATH BOTH SIDES (#1244 / same class as the #1153 argv[1] main-guard finding).
111
- // `fileURLToPath(import.meta.url)` is already canonicalized by Node's ESM loader,
112
- // so a plain `resolve(argv[1])` comparison silently fails whenever the invocation
113
- // path traverses a symlink — on macOS every `/tmp/...` path does (`/tmp` →
114
- // `/private/tmp`). The failure mode is the worst one a security guard has: isMain
115
- // stays false, runScan() never runs, and the process prints NOTHING and exits 0,
116
- // i.e. a clean-looking pass that never scanned a byte. realpathSync both sides so
117
- // the two spellings of the same file compare equal; if realpathSync throws (path
118
- // gone, permission denied) fall back to the historical comparison.
119
11
  function canonicalPath(p) {
120
12
  try {
121
13
  return realpathSync(p);
@@ -127,92 +19,147 @@ const isMain =
127
19
  argv[1] !== undefined &&
128
20
  canonicalPath(resolve(argv[1])) === canonicalPath(fileURLToPath(import.meta.url));
129
21
 
130
- // CLI: single positional arg required (only enforced when run directly).
131
- const pluginRoot = argv[2];
22
+ const pluginRoot = argv[2] ? canonicalPath(resolve(argv[2])) : argv[2];
132
23
  const includeUntracked = argv.slice(3).includes('--include-untracked');
24
+ const requireOwnerPatterns = argv.slice(3).includes('--require-owner-patterns');
25
+ const packedIndex = argv.indexOf('--packed-files');
26
+ const packedInventoryPath = packedIndex >= 0 ? argv[packedIndex + 1] : null;
27
+ if (isMain && packedIndex >= 0 && (!packedInventoryPath || packedInventoryPath.startsWith('--'))) {
28
+ console.error('packed-files requires an inventory path'); process.exit(1);
29
+ }
133
30
  if (isMain && !pluginRoot) {
134
31
  console.error('Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]');
135
32
  process.exit(1);
136
33
  }
137
34
 
138
- // ---------------------------------------------------------------------------
139
- // Private slugs constant — #462 audit trail (list is CLOSED: add only after audit review)
140
- // ---------------------------------------------------------------------------
141
- const PRIVATE_SLUGS = [
142
- 'launchpad-ai-factory',
143
- 'Codex-Hackathon',
144
- 'buchhaltgenie',
145
- 'AngebotsChecker',
146
- 'wien-forschungsfragen-klima',
147
- 'aiat-pmo-module',
148
- 'mail-assistant',
149
- ];
150
-
151
- // ---------------------------------------------------------------------------
152
- // VAULT_CLEAR_SLUGS carve-out (issue #59, owner decision 2026-07-18)
153
- // ---------------------------------------------------------------------------
154
- //
155
- // Slugs that must STILL be blocked from leaking into TRACKED public-mirror files
156
- // (so they remain in PRIVATE_SLUGS / CP6_PATTERNS, which runScan uses) but are
157
- // CLEARED for use as an IN-PROCESS vault-namespace segment — the vault is a
158
- // host-local, gitignored corpus, so collapsing these to the shared 'redacted-repo'
159
- // bucket needlessly de-isolates per-repo notes without any public-leak benefit.
160
- // isOwnerLeakySegment iterates the narrower CP6_INPROCESS_PATTERNS (PRIVATE_SLUGS
161
- // minus these), so a carved-out slug resolves to its own namespace while the
162
- // tracked-file scanner is UNCHANGED. Comparison is case-insensitive: values are
163
- // lowercased here and every CP6_INPROCESS_PATTERNS filter lowercases before
164
- // membership test.
165
- export const VAULT_CLEAR_SLUGS = new Set([
166
- 'buchhaltgenie',
167
- 'mail-assistant',
168
- 'wien-forschungsfragen-klima',
169
- 'launchpad-ai-factory',
170
- 'angebotschecker',
171
- ]);
35
+ // Host policy stays outside source and package; this file remains standalone.
36
+ export const VAULT_CLEAR_SLUGS = new Set();
37
+
38
+ // Mirrors the zero-import private-config-dir resolver because vendored copies carry this file alone.
39
+ const POLICY_KEYS = ['usernamePrefixes', 'privateHosts', 'eventsHosts', 'privateDomains', 'packageScopes', 'privateSlugs', 'vaultClearSlugs', 'personalNames', 'publicEmails', 'publicUrls'];
40
+ const POLICY_LIMIT_BYTES = 65536; // At most 256 literals per class; revisit only with measured larger policies.
41
+ let ownerCache;
42
+ let compiledOwnerState;
43
+ let compiledOwnerRules;
44
+ function policySource(env = process.env) {
45
+ const own = (env.SO_CONFIG_HOME || '').trim();
46
+ const xdg = (env.XDG_CONFIG_HOME || '').trim();
47
+ const dir = own || (xdg ? join(xdg, 'session-orchestrator') : join(homedir(), '.config', 'session-orchestrator'));
48
+ return (env.SO_OWNER_PATTERNS_FILE || '').trim() || join(dir, 'owner-patterns.json');
49
+ }
50
+ function checkedHostFile(filename) {
51
+ if (!isAbsolute(filename)) throw new Error('unsafe');
52
+ // Canonical OS aliases (/tmp on macOS) are allowed above the private source parent.
53
+ let cursor = dirname(filename);
54
+ const parent = lstatSync(cursor);
55
+ if (!parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 0o022)) throw new Error('unsafe');
56
+ const canonicalParent = realpathSync(cursor);
57
+ // Reject repo-local data even if ignored; walk every ancestor for a .git marker.
58
+ cursor = canonicalParent;
59
+ while (true) {
60
+ if (existsSync(join(cursor, '.git'))) throw new Error('unsafe');
61
+ const next = dirname(cursor); if (next === cursor) break; cursor = next;
62
+ }
63
+ // Reject user-controlled intermediate symlinks; OS-owned /var and /tmp aliases are trusted.
64
+ cursor = dirname(filename);
65
+ while (cursor !== dirname(cursor)) {
66
+ const st = lstatSync(cursor);
67
+ if ((st.isSymbolicLink() && st.uid !== 0) || (st.uid !== 0 && (st.mode & 0o022))) throw new Error('unsafe');
68
+ cursor = dirname(cursor);
69
+ }
70
+ const st = lstatSync(filename);
71
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o7777) !== 0o600 || st.nlink !== 1 || (typeof process.getuid === 'function' && st.uid !== process.getuid())) throw new Error('unsafe');
72
+ if (st.size > POLICY_LIMIT_BYTES) throw new Error('invalid');
73
+ const fd = openSync(filename, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
74
+ try {
75
+ const opened = fstatSync(fd);
76
+ if (!opened.isFile() || opened.ino !== st.ino || opened.dev !== st.dev || (opened.mode & 0o7777) !== 0o600 || opened.size > POLICY_LIMIT_BYTES) throw new Error('unsafe');
77
+ const raw = readFileSync(fd, 'utf8');
78
+ if (Buffer.byteLength(raw) > POLICY_LIMIT_BYTES) throw new Error('invalid');
79
+ return raw;
80
+ } finally { closeSync(fd); }
81
+ }
82
+ export function inspectOwnerPatterns({ filePath, env = process.env, refresh = false } = {}) {
83
+ const filename = filePath ?? policySource(env);
84
+ if (!refresh && ownerCache?.filename === filename) return ownerCache.result;
85
+ let result;
86
+ try {
87
+ const policy = JSON.parse(checkedHostFile(filename));
88
+ if (!policy || Array.isArray(policy) || policy.version !== 1 || Object.keys(policy).some((key) => key !== 'version' && !POLICY_KEYS.includes(key))) throw new Error('invalid');
89
+ for (const key of POLICY_KEYS) {
90
+ if (!Object.hasOwn(policy, key)) policy[key] = [];
91
+ if (!Array.isArray(policy[key]) || policy[key].length > 256 || policy[key].some((v) => typeof v !== 'string' || !v.trim() || v.length > 256 || v !== v.trim() || [...v].some((c) => c.charCodeAt(0) < 32))) throw new Error('invalid');
92
+ }
93
+ if (policy.vaultClearSlugs.some((slug) => !policy.privateSlugs.some((s) => s.toLowerCase() === slug.toLowerCase()))) throw new Error('invalid');
94
+ if (!POLICY_KEYS.slice(0, 6).concat('personalNames').some((key) => policy[key].length)) throw new Error('invalid');
95
+ result = { status: 'ok', policy };
96
+ } catch (err) {
97
+ result = { status: err.code === 'ENOENT' ? 'missing' : err.message === 'unsafe' || err.code === 'ELOOP' ? 'unsafe' : 'invalid', reason: err.code ?? (err.message === 'unsafe' ? 'unsafe-source' : 'invalid-source') };
98
+ }
99
+ ownerCache = {filename, result};
100
+ VAULT_CLEAR_SLUGS.clear();
101
+ for (const slug of result.policy?.vaultClearSlugs ?? []) VAULT_CLEAR_SLUGS.add(slug.toLowerCase());
102
+ return result;
103
+ }
104
+ function countMatches(re, value) { return (value.match(re) || []).length; }
172
105
 
173
- // ---------------------------------------------------------------------------
174
- // Canonicalization (issue #661)
175
- // ---------------------------------------------------------------------------
176
- //
177
- // The historical evasion class (`encoded-path-forms-evade-slash-form-scanners`,
178
- // #95) is structural: the SAME personal path is re-spelled with a different
179
- // SEPARATOR or escape so a slash-anchored regex misses it. Rather than add a
180
- // fresh regex per encoding, we DECODE every known encoding back to a single
181
- // canonical slash-form ONCE, then match path patterns against that form.
182
- //
183
- // Decodings applied, in order (each is idempotent / safe to over-apply):
184
- // 1. URL-percent escapes (%2F → /, %2E → ., %2D → -, %5C → \, %20 → space …)
185
- // 2. `\uXXXX` / `%uXXXX` JS/JSON unicode escapes for /, ., -, \
186
- // 3. HTML numeric + named entities for /, ., - (&#47; &#x2F; &sol; …)
187
- // 4. Unicode homoglyph separators → ASCII (fullwidth / division slash → '/',
188
- // various dashes/hyphens → '-')
189
- // 5. Backslash → forward slash, and any run of separators collapsed
190
- // 6. Dash-as-separator → slash (Claude Code projects-dir encoding, #634)
191
- //
192
- // After decoding, ALL of `/Users/bernhardg.`, `-Users-bernhardg--`,
193
- // `%2FUsers%2Fbernhardg`, `\Users\bernhardg`, `/Users/bernhardg`, and a
194
- // novel future spelling collapse to a canonical `/Users/bernhardg…` string the
195
- // CP1 rule matches. CASE IS PRESERVED through every decode step so the
196
- // `/Users` (capital-U) case-sensitivity contract and the uppercase-username
197
- // near-miss guard both survive. Over-matching is intentional (security guard).
106
+ function literalRegex(values, flags = '', prefix = '', suffix = '') {
107
+ return new RegExp(values.length ? prefix + '(?:' + values.map(escapeRegex).join('|') + ')' + suffix : '(?!)', flags);
108
+ }
109
+ function currentOwnerRules() {
110
+ const state = inspectOwnerPatterns();
111
+ if (compiledOwnerState === state) return compiledOwnerRules;
112
+ const policy = state.policy ?? Object.fromEntries(POLICY_KEYS.map((key) => [key, []]));
113
+ const insensitiveToken = (token) => [...token].map((c) => /[a-z]/i.test(c) ? '[' + c.toLowerCase() + c.toUpperCase() + ']' : escapeRegex(c)).join('');
114
+ const prefix = policy.usernamePrefixes.map(insensitiveToken).join('|') || '(?!)';
115
+ compiledOwnerState = state;
116
+ compiledOwnerRules = {
117
+ ...state, policy,
118
+ CP1_CANON: new RegExp('/Users/(?:' + prefix + ')[a-z.]*(/|\\b)'),
119
+ CP1_BARE: new RegExp('^(?:' + prefix + ')[a-z]*$'),
120
+ SCANNER_REGEX_QUOTE_BLANK_G: new RegExp('(Users.)(?:' + prefix + ')(\\[)', 'g'),
121
+ CP2: literalRegex(policy.privateHosts, '', '\\b', '\\b'),
122
+ CP3: literalRegex(policy.eventsHosts, '', '\\b', '\\b'),
123
+ CP3_G: literalRegex(policy.eventsHosts, 'g', '\\b', '\\b'),
124
+ CP4: literalRegex(policy.packageScopes, '', '@', '/[A-Za-z0-9*_-]+'),
125
+ CP5_WITH_GOTZ: /DEFAULT_GITLAB_HOST/,
126
+ CP5_EXPORT: /\bexport\b.*\bconst\b.*\bDEFAULT_GITLAB_HOST\b/,
127
+ CP6_PATTERNS: policy.privateSlugs.map((slug) => literalRegex([slug], 'i', '\\b', '\\b')),
128
+ CP6_INPROCESS_PATTERNS: policy.privateSlugs.filter((slug) => !VAULT_CLEAR_SLUGS.has(slug.toLowerCase())).map((slug) => literalRegex([slug], 'i', '\\b', '\\b')),
129
+ CP7: literalRegex(policy.privateDomains), CP7_G: literalRegex(policy.privateDomains, 'g'),
130
+ CP10_PATTERNS: policy.personalNames.map((name) => literalRegex([name], '', '(?:~|/Users/[^/]+|/home/[^/]+)/Projects/', '(\\/|\\b)')),
131
+ redactionPatterns: POLICY_KEYS.filter((key) => !key.startsWith('public')).flatMap((key) => policy[key].map((value) => literalRegex([value], 'i'))),
132
+ };
133
+ return compiledOwnerRules;
134
+ }
135
+ function getPackedFiles() {
136
+ // npm's JSON inventory is bounded; filenames alone never constitute a content scan.
137
+ const inventoryFd = openSync(packedInventoryPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
138
+ let raw;
139
+ try {
140
+ const stat = fstatSync(inventoryFd);
141
+ if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new Error('invalid-inventory');
142
+ raw = readFileSync(inventoryFd, 'utf8');
143
+ if (Buffer.byteLength(raw) > 16 * 1024 * 1024) throw new Error('inventory-too-large');
144
+ } finally { closeSync(inventoryFd); }
145
+ const inventory = JSON.parse(raw);
146
+ const files = Array.isArray(inventory) && inventory.length === 1 ? inventory[0]?.files : null;
147
+ if (!Array.isArray(files) || !files.length || files.length > 20000) throw new Error('invalid-inventory');
148
+ const root = pluginRoot;
149
+ return [...new Set(files.map((entry) => {
150
+ const name = entry?.path;
151
+ if (typeof name !== 'string' || !name || name.length > 4096 || isAbsolute(name) || name.split('/').some((part) => part === '..' || part === '.' || !part) || (name.includes('\\') || [...name].some((c) => c.charCodeAt(0) < 32))) throw new Error('unsafe-inventory');
152
+ let current = root;
153
+ for (const part of name.split('/')) { current = join(current, part); if (lstatSync(current).isSymbolicLink()) throw new Error('unsafe-inventory'); }
154
+ if (!lstatSync(current).isFile()) throw new Error('invalid-inventory-entry');
155
+ return current;
156
+ }))];
157
+ }
198
158
 
199
- /** Unicode homoglyph / variant SLASH code points that should canonicalize to '/'. */
200
159
  const HOMOGLYPH_SLASHES = /[⁄∕/⧸╱]/g; // ⁄ ∕ / ⧸ ╱
201
- /** Unicode homoglyph / variant DASH code points that should canonicalize to '-'. */
202
160
  const HOMOGLYPH_DASHES =
203
161
  /[‐‑‒–—―−﹘﹣-]/g; // ‐ ‑ ‒ – — ― − ﹘ ﹣ -
204
162
 
205
- /**
206
- * Zero-width / format / invisible code points that a leak can splice INTO the
207
- * username (`/Users/bern<U+200B>hardg`) to evade a contiguous-literal match.
208
- * Stripped from the canonical form before matching (Finding 3). Over-stripping
209
- * is safe for a security guard — these glyphs never legitimately appear inside a
210
- * filesystem path segment. Code points (escaped to keep the source ASCII-clean
211
- * and lint-quiet): tab U+0009, soft-hyphen U+00AD, ZWSP U+200B, ZWNJ U+200C,
212
- * ZWJ U+200D, word-joiner U+2060, BOM/ZWNBSP U+FEFF. Built from an explicit
213
- * code-point list via a non-character-class alternation so the combining ZWJ
214
- * does not trip no-misleading-character-class.
215
- */
216
163
  const ZERO_WIDTH_FORMAT = new RegExp(
217
164
  '(?:' +
218
165
  ['\\u0009', '\\u00ad', '\\u200b', '\\u200c', '\\u200d', '\\u2060', '\\ufeff'].join('|') +
@@ -220,41 +167,16 @@ const ZERO_WIDTH_FORMAT = new RegExp(
220
167
  'gu',
221
168
  );
222
169
 
223
- /**
224
- * Decode the printable-ASCII range of a hex code point, or null when out of
225
- * range. Used by the percent / unicode-escape decoders so the LETTERS of
226
- * `Users`/`bernhardg` (e.g. `%55` → `U`, `%62` → `b`) are decoded, not only the
227
- * separators (Finding 4). Over-decoding into the full printable-ASCII range is
228
- * intentional and safe for a security guard. CASE IS PRESERVED because the hex
229
- * value itself encodes the case (`%55`→`U`, `%75`→`u`).
230
- * @param {number} cp
231
- * @returns {string|null}
232
- */
233
170
  function printableAsciiFromCodePoint(cp) {
234
171
  return cp >= 0x20 && cp <= 0x7e ? String.fromCharCode(cp) : null;
235
172
  }
236
173
 
237
- /**
238
- * Decode URL-percent escapes back to their ASCII characters, WITHOUT touching
239
- * unrelated literal `%`s (decodeURIComponent would throw on a stray `%`). The
240
- * decode targets the WHOLE printable-ASCII range — both separators (`%2F` → /)
241
- * AND the alphanumerics of `Users`/`bernhardg` (`%55` → U, #Finding 4) — and the
242
- * `%uXXXX` JS/JSON-style percent-unicode escape. Double-encoding (`%252F`) is
243
- * handled by the fixpoint loop in canonicalizeLine (a single decode pass turns
244
- * `%252F` → `%2F`; the next pass turns `%2F` → `/`). Only the small set of
245
- * always-relevant separators is hard-coded; everything else in printable-ASCII
246
- * is decoded generically.
247
- * @param {string} s
248
- * @returns {string}
249
- */
250
174
  function decodePercent(s) {
251
- // `%uXXXX` (JS/JSON-style percent-unicode) — BMP code point in printable ASCII.
252
175
  let out = s.replace(/%u([0-9a-fA-F]{4})/g, (_m, hex) => {
253
176
  const cp = parseInt(hex, 16);
254
177
  const ch = printableAsciiFromCodePoint(cp);
255
178
  return ch === null ? _m : ch;
256
179
  });
257
- // `%XX` — any printable-ASCII byte (separators AND letters/digits).
258
180
  out = out.replace(/%([0-9a-fA-F]{2})/g, (_m, hex) => {
259
181
  const cp = parseInt(hex, 16);
260
182
  const ch = printableAsciiFromCodePoint(cp);
@@ -263,13 +185,6 @@ function decodePercent(s) {
263
185
  return out;
264
186
  }
265
187
 
266
- /**
267
- * Decode JS/JSON `\uXXXX` escapes back to their ASCII characters — separators
268
- * AND the alphanumerics of `Users`/`bernhardg` (Finding 4). Out-of-range code
269
- * points are left intact.
270
- * @param {string} s
271
- * @returns {string}
272
- */
273
188
  function decodeUnicodeEscapes(s) {
274
189
  return s.replace(/\\u([0-9a-fA-F]{4})/g, (_m, hex) => {
275
190
  const cp = parseInt(hex, 16);
@@ -278,292 +193,49 @@ function decodeUnicodeEscapes(s) {
278
193
  });
279
194
  }
280
195
 
281
- /**
282
- * Decode HTML named + numeric character references. Named entities cover the
283
- * path separators; numeric entities (`&#85;`, `&#x55;`) decode the WHOLE
284
- * printable-ASCII range so the LETTERS of `Users`/`bernhardg` are decoded, not
285
- * only the separators (Finding 4). Out-of-range code points are left intact.
286
- * @param {string} s
287
- * @returns {string}
288
- */
289
196
  function decodeHtmlEntities(s) {
290
197
  return s
291
198
  .replace(/&sol;/gi, '/')
292
199
  .replace(/&period;/gi, '.')
293
200
  .replace(/&(?:dash|hyphen);/gi, '-')
294
- // numeric hex entity — any printable-ASCII code point
295
201
  .replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => {
296
202
  const ch = printableAsciiFromCodePoint(parseInt(hex, 16));
297
203
  return ch === null ? _m : ch;
298
204
  })
299
- // numeric decimal entity — any printable-ASCII code point
300
205
  .replace(/&#(\d+);/g, (_m, dec) => {
301
206
  const ch = printableAsciiFromCodePoint(parseInt(dec, 10));
302
207
  return ch === null ? _m : ch;
303
208
  });
304
209
  }
305
210
 
306
- /**
307
- * Canonicalize one line of text into a single slash-form string suitable for
308
- * separator-agnostic path matching. CASE IS PRESERVED — the owner-path rule
309
- * (CP1) is anchored on the case-sensitive `/Users/bernhardg` literal, so a
310
- * deliberately-lowercased `/users/...` source literal and an uppercase-letter
311
- * username near-miss (`/Users/bernhardgXfoo`) both stay distinguishable after
312
- * canonicalization. Only SEPARATORS / escapes are normalized, never letter case.
313
- * Public for unit-testing the decode steps.
314
- * @param {string} line
315
- * @returns {string} canonical form (case preserved)
316
- */
317
- export function canonicalizeLine(line) {
211
+ export function canonicalizeLine(line, { preserveDashes = false } = {}) {
318
212
  let s = String(line);
319
- // 0. strip zero-width / format / invisible chars (and tab) spliced into a
320
- // path segment to break a contiguous-literal match (Finding 3). Done first
321
- // AND inside the fixpoint loop so a glyph wedged between two ENCODED chars
322
- // (e.g. `%2<U+200B>F`) also vanishes once its neighbours decode.
323
213
  s = s.replace(ZERO_WIDTH_FORMAT, '');
324
- // 1–3. decode every textual encoding to a FIXPOINT (Finding 5). A single pass
325
- // only peels one encoding layer; a nested encoding (one decoder's output
326
- // feeding another — `%252F` → `%2F` → `/`, or an entity whose decoded form
327
- // is itself a percent-escape) needs the loop. The decoders are monotone
328
- // (each only ever shrinks/normalizes), so the loop converges; the bound is
329
- // a safety valve against any pathological non-converging input.
330
214
  for (let i = 0; i < 12; i++) {
331
215
  const before = s;
332
- // strip format chars exposed by the previous round's decoding
333
216
  s = s.replace(ZERO_WIDTH_FORMAT, '');
334
- // percent escapes (separators AND letters, plus %uXXXX)
335
217
  s = decodePercent(s);
336
- // JS/JSON unicode escapes
337
218
  s = decodeUnicodeEscapes(s);
338
- // HTML entities (named + numeric, separators AND letters)
339
219
  s = decodeHtmlEntities(s);
340
220
  if (s === before) break; // fixpoint reached
341
221
  }
342
- // 4. unicode homoglyph separators → ASCII
343
222
  s = s.replace(HOMOGLYPH_SLASHES, '/').replace(HOMOGLYPH_DASHES, '-');
344
- // 5. backslash → forward slash
345
223
  s = s.replace(/\\/g, '/');
346
- // 6. dash-as-separator → slash. The Claude Code projects-dir encoding maps
347
- // BOTH '/' and '.' to '-', so a leading/embedded dash run reconstructs the
348
- // original separators. Converting every '-' run to '/' is over-broad but
349
- // safe for matching: CP1 anchors on `/Users/bernhardg` which only appears
350
- // when the real path is present. Collapse runs of '-'.
351
- s = s.replace(/-+/g, '/');
352
- // collapse repeated slashes produced by the decodings
224
+ if (!preserveDashes) s = s.replace(/-+/g, '/');
353
225
  s = s.replace(/\/{2,}/g, '/');
354
226
  return s;
355
227
  }
356
228
 
357
- // ---------------------------------------------------------------------------
358
- // Forbidden patterns (matched against canonical and/or raw form)
359
- // ---------------------------------------------------------------------------
360
-
361
- /**
362
- * CP1: personal home path — matched on the CANONICAL (lowercased, slash-form)
363
- * line. Because canonicalization collapses the slash-form (#631), the
364
- * dash-encoded projects-dir form (#634), percent/unicode/backslash/homoglyph
365
- * spellings into one string, a SINGLE rule replaces the old P1+P9 pair and
366
- * catches future encodings structurally.
367
- *
368
- * Mirrors the ORIGINAL P1 regex EXACTLY, applied to the canonical (separator-
369
- * normalized, case-PRESERVED) form: `/Users/bernhardg` + `[a-z.]*` (lowercase-
370
- * letter / dot continuation — catches the full-username form
371
- * `/Users/bernhardgoetzendorfer/`) + a trailing slash OR a word boundary.
372
- *
373
- * Because canonicalization is case-preserving, this single rule simultaneously:
374
- * - is case-SENSITIVE on the host segment (`/Users`, not `/users`) — a
375
- * deliberately-lowercased `/users/bernhardg.` literal does NOT match;
376
- * - keeps the #631 fix (bare `/Users/bernhardg.` at EOL matches via `\b`);
377
- * - keeps the old false-positive profile (the `[a-z.]*`-then-boundary stops
378
- * at an uppercase / digit / underscore continuation):
379
- * `/Users/bernhardgXfoo` → `X` (uppercase) stops `[a-z.]*`, `g`→`X` no `\b` → reject
380
- * `/Users/bernhardg9` → `9` word-char, no `\b` after `g` → reject
381
- * `/Users/bernhardg_home` → `_` word-char, no `\b` after `g` → reject
382
- * `/Users/bernhardo` → diverges before `g` → reject
383
- * `/Users/bernhardgoetzendorfer/` → `[a-z.]*` eats `oetzendorfer`, `/` → MATCH
384
- *
385
- * The win over the old P1+P9 pair: every ENCODED separator form (dash #634,
386
- * percent, backslash, homoglyph, html-entity, unicode-escape, and any future
387
- * spelling) collapses to `/Users/bernhardg…` in the canonical string, so ONE
388
- * rule catches them all instead of a fresh regex per encoding.
389
- *
390
- * Finding 1 (HIGH): the USERNAME segment is matched CASE-INSENSITIVELY via an
391
- * explicit per-letter token (`[bB][eE]…[gG]`) — not the `/i` flag, which would
392
- * also loosen the host anchor, and not an inline `(?i:…)` group, which Node 20
393
- * does not reliably support. On case-insensitive APFS, `/Users/Bernhardg.` is a
394
- * real path identifying the operator, so a capitalized username must still be
395
- * caught. Two case contracts are DELIBERATELY kept narrow:
396
- * - the HOST segment stays case-SENSITIVE (`\/Users\/`, capital-U literal) so a
397
- * lowercased `/users/bernhardg/x` source literal does NOT match;
398
- * - the CONTINUATION class stays lowercase-only (`[a-z.]*`) so an UPPERCASE
399
- * letter continuing the segment marks a DIFFERENT user and stops the match:
400
- * `/Users/bernhardgXfoo` → `[a-z.]*` matches 0 chars, `g`→`X` (both word
401
- * chars) gives no `\b` → reject (continuation-boundary survives).
402
- */
403
- const CP1_CANON = /\/Users\/[bB][eE][rR][nN][hH][aA][rR][dD][gG][a-z.]*(\/|\b)/;
404
-
405
- /**
406
- * Self-documentation guard: a line that QUOTES the scanner's own pattern regex
407
- * (e.g. CHANGELOG / migration-doc prose `…added P9 /-Users-bernhardg[a-z.]*-/…`)
408
- * contains the username token but is NOT a path — a real macOS home path never
409
- * embeds a regex character-class `[…]` immediately after the username.
410
- * This is the same self-reference class that SELF_EXCLUSIONS handles for the
411
- * scanner's source + test files; here it is line-scoped so a REAL leak elsewhere
412
- * in the same doc still fails. Without it, the #661 canonical rule (which is now
413
- * as strict on the dash-form as P1 always was on the slash-form) would newly
414
- * flag the pre-existing CHANGELOG entries that document the legacy P1/P9 regexes.
415
- *
416
- * NOTE: tested against the CANONICAL form, where the dash-run normalization has
417
- * already turned the regex range `[a-z` into `[a/z`. We therefore anchor on the
418
- * literal `[` right after `bernhardg` (`bernhardg\[`), which uniquely marks a
419
- * quoted character-class and never appears in a real path segment. Username is
420
- * matched case-insensitively to mirror the case-insensitive CP1 username token.
421
- *
422
- * Finding 2 (MED): the guard now BLANKS only the matched regex-quote TOKEN
423
- * (replacing the `bernhardg` username with a sentinel) rather than suppressing
424
- * the WHOLE line. A real leak that merely SHARES a line with a quoted regex
425
- * (`Real: /Users/bernhardg/Projects/secret (see regex /Users/bernhardg[a-z.]*)`)
426
- * is still caught: only the `…bernhardg[` token is neutralized, the real
427
- * `/Users/bernhardg/…` path on the same line survives the residue re-scan.
428
- * The `Users.bernhardg\[` anchor (with `.` matching the canonicalized slash)
429
- * leaves the real-path form `Users/bernhardg/` — username followed by a SLASH,
430
- * not a `[` — untouched.
431
- */
432
- const SCANNER_REGEX_QUOTE_BLANK_G = /(Users.)[bB][eE][rR][nN][hH][aA][rR][dD][gG](\[)/g;
433
-
434
- /** CP2: private GitLab host */
435
- const CP2 = /\bgitlab\.gotzendorfer\.at\b/;
436
-
437
- /** CP3: private events domain */
438
- const CP3 = /\bevents\.gotzendorfer\.at\b/;
439
-
440
- /** CP4: private package scope */
441
- const CP4 = /@goetzendorfer\/[A-Za-z0-9*_-]+/;
442
-
443
- /** CP5: DEFAULT_GITLAB_HOST on a line with 'gotzendorfer' OR as an exported const */
444
- const CP5_WITH_GOTZ = /DEFAULT_GITLAB_HOST/;
445
- const CP5_EXPORT = /\bexport\b.*\bconst\b.*\bDEFAULT_GITLAB_HOST\b/;
446
-
447
- /**
448
- * CP6 (tracked-file scan): private project slugs (word-boundary anchored,
449
- * case-insensitive — #483 W4-Q6 caught "Buchhaltgenie" capitalized).
450
- * The FULL 7-slug list — runScan uses this so the public-mirror guard is UNCHANGED.
451
- */
452
- const CP6_PATTERNS = PRIVATE_SLUGS.map((slug) => new RegExp(`\\b${escapeRegex(slug)}\\b`, 'i'));
453
-
454
- /**
455
- * CP6 (in-process vault-namespace guard): PRIVATE_SLUGS minus VAULT_CLEAR_SLUGS
456
- * (issue #59 owner decision 2026-07-18). isOwnerLeakySegment iterates THIS list,
457
- * so carved-out slugs resolve to their own vault namespace instead of collapsing
458
- * to 'redacted-repo', while the tracked-file scanner (CP6_PATTERNS) still blocks
459
- * all 7 from leaking into the public mirror. Effectively only the 2 retained
460
- * slugs (Codex-Hackathon, aiat-pmo-module). Case-insensitive: filter lowercases
461
- * before the VAULT_CLEAR_SLUGS membership test, matching the 'i'-flag regex.
462
- */
463
- const CP6_INPROCESS_PATTERNS = PRIVATE_SLUGS
464
- .filter((slug) => !VAULT_CLEAR_SLUGS.has(slug.toLowerCase()))
465
- .map((slug) => new RegExp(`\\b${escapeRegex(slug)}\\b`, 'i'));
466
-
467
- /** CP7: catch-all gotzendorfer.at (must not match allowlist) */
468
- const CP7 = /gotzendorfer\.at/;
469
-
470
- /**
471
- * GLOBAL twins of the two allowlist-consulting domain rules, used to count
472
- * OCCURRENCES in the raw vs the canonical form of a line (#1080 Finding A).
473
- *
474
- * Why an occurrence COUNT and not simply a second `.test()` on the canonical
475
- * form: CP3 and CP7 are the only rules whose verdict is filtered by
476
- * isAllowlisted(), and that allowlist matches a LINE FORM — the sanctioned
477
- * Impressum/author URL, and the exact events doc-comment contract line.
478
- * Canonicalization folds every `-` run to `/`, which mangles precisely that
479
- * prose: ` * - No literal ...` canonicalizes to ` * / No literal ...`. So
480
- * re-running isAllowlisted() against the canonical form would fail to recognise
481
- * its own sanctioned line and turn this gate — and with it .husky/pre-commit —
482
- * permanently red on a clean tree. Measured, not reasoned: that exact exclusion
483
- * regex returns true on the raw line and false on its canonical form.
484
- *
485
- * The occurrence count separates the two cases without touching the allowlist:
486
- * when the canonical form carries MORE domain tokens than the raw line, the
487
- * surplus was produced by DECODING an escape, and a decoded token can never be
488
- * the sanctioned publication the allowlist covers — so it bypasses the allowlist.
489
- * When the counts are equal, the rule behaves exactly as it always did and
490
- * consults the allowlist on the raw line.
491
- */
492
- const CP3_G = /\bevents\.gotzendorfer\.at\b/g;
493
- const CP7_G = /gotzendorfer\.at/g;
494
-
495
- /**
496
- * Count matches of a GLOBAL regex in `s` (0 when none).
497
- * @param {RegExp} re a regex carrying the `g` flag
498
- * @param {string} s
499
- * @returns {number}
500
- */
501
- function countMatches(re, s) {
502
- return (s.match(re) || []).length;
503
- }
504
-
505
- /**
506
- * CP8: full RFC1918 private dotted-quad — internal-IP leak.
507
- * Matches only literal 4-octet private IPs (10.x.x.x, 192.168.x.x, 172.16-31.x.x).
508
- * Deliberately does NOT match placeholder `.x` forms (10.x, 192.168.x) or CIDR/range
509
- * notation used in SSRF-range documentation, nor TEST-NET (192.0.2.x, RFC 5737).
510
- */
511
229
  const CP8 = /(?:\b10(?:\.\d{1,3}){3}|\b192\.168(?:\.\d{1,3}){2}|\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})/;
512
230
 
513
- /** CP8 allowlist: files where RFC1918 IPs are a legitimate test subject (IP-redaction fixtures). */
514
231
  const CP8_ALLOWLIST = new Set(['tests/scripts/export-hw-learnings.test.mjs']);
515
232
 
516
- /**
517
- * Match an owner personal-home-path leak in a line, using canonicalization.
518
- * Returns the offending class label, or null when clean.
519
- *
520
- * Strategy: canonicalize separators/escapes (case preserved), then run the
521
- * single CP1 rule once. Because canonicalization collapses every known
522
- * encoding of `/Users/bernhardg…` to one string while preserving letter case,
523
- * this ONE structural rule replaces the P1-bare-slash + P9-dash regex
524
- * treadmill AND catches future encodings (percent / backslash / homoglyph /
525
- * html-entity / unicode-escape / dash) without a new regex per form.
526
- * @param {string} line
527
- * @returns {string|null}
528
- */
529
233
  export function matchOwnerPath(line) {
530
- const canon = canonicalizeLine(line);
531
- // Self-documentation guard (line-scoped, Finding 2): BLANK only the quoted
532
- // regex TOKEN (`…bernhardg[`) — replacing the username with a sentinel — then
533
- // re-scan the RESIDUE. This neutralizes prose that quotes the scanner's own
534
- // pattern (`…bernhardg[a-z.]*…` — not a path) WITHOUT suppressing a real leak
535
- // that happens to share the line. Tested against the CANONICAL form because
536
- // the documented regex source spells separators with `\/` (escaped slash)
537
- // which only normalizes to `/` after canonicalization. See
538
- // SCANNER_REGEX_QUOTE_BLANK_G.
539
- const residue = canon.replace(SCANNER_REGEX_QUOTE_BLANK_G, '$1__REGEXQUOTE__$2');
540
- if (CP1_CANON.test(residue)) return 'CP1 (personal home path — canonicalized)';
541
- return null;
234
+ const { CP1_CANON, SCANNER_REGEX_QUOTE_BLANK_G } = currentOwnerRules();
235
+ const residue = canonicalizeLine(line).replace(SCANNER_REGEX_QUOTE_BLANK_G, '$1__REGEXQUOTE__$2');
236
+ return CP1_CANON.test(residue) ? 'CP1 (personal home path — canonicalized)' : null;
542
237
  }
543
238
 
544
- /** CP10: personal-name owner segment in a ~/Projects/<Name>/ path (#653).
545
- * Name-denylist driven (CLOSED list, audit-reviewed) — mirrors PRIVATE_SLUGS (CP6).
546
- * A generic ~/Projects/[A-Z][a-z]+/ would false-positive on legit capitalized
547
- * project dirs (~/Projects/MyApp/), so we match only known personal names.
548
- *
549
- * Matches the tilde form (`~/Projects/Bernhard`) OR an absolute home form
550
- * (`/Users/<user>/Projects/Bernhard`, `/home/<user>/Projects/Bernhard`), then a
551
- * trailing-slash OR word boundary. The slash-OR-word-boundary alternation is the
552
- * Finding-1 fix for the original `…/<Name>/` form, which REQUIRED a slash after the
553
- * name and so let a bare `~/Projects/Bernhard` (end-of-line, before `&&`) pass
554
- * undetected — the same blindspot CP1 was patched for (see CP1 doc above). The
555
- * absolute-home alternation is the Finding-3 defense-in-depth: a leak in a
556
- * non-owner home (`/Users/alice/Projects/Bernhard/vault`) slipped both CP1 and the
557
- * old tilde-only CP10. The trailing word boundary keeps the false-positive profile
558
- * tight: `~/Projects/Bernhardt/` (denylisted name + a continuation letter) does
559
- * NOT match, so names that merely START with a denylisted name are not flagged. */
560
- const PERSONAL_NAMES = ['Bernhard'];
561
- const CP10_PATTERNS = PERSONAL_NAMES.map(
562
- (name) => new RegExp(`(?:~|/Users/[^/]+|/home/[^/]+)/Projects/${escapeRegex(name)}(\\/|\\b)`),
563
- );
564
-
565
- /** CP10 allowlist: files where ~/Projects/Bernhard is a deliberate test subject
566
- * (migration/drift fixtures) or a legitimate one-shot-migration source/target. */
567
239
  const CP10_ALLOWLIST = new Set([
568
240
  'tests/scripts/vault-consolidate.test.mjs',
569
241
  'tests/scripts/migrate-vault-paths.test.mjs',
@@ -579,149 +251,16 @@ function escapeRegex(s) {
579
251
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
580
252
  }
581
253
 
582
- // DELIBERATE DUPLICATE — do not replace the function below with an import from
583
- // `scripts/lib/redact-spans.mjs`. This scanner is a documented STANDALONE
584
- // SINGLE-FILE vendoring target (`.claude/rules/security.md` § "Owner-Privacy
585
- // Pre-Commit Hook": consumer repos copy exactly this ONE file into their tree as
586
- // a pre-commit stage). A static import resolves in-tree but throws
587
- // ERR_MODULE_NOT_FOUND in every vendored copy — under the husky stage that means
588
- // empty stdout + exit 1 on a CLEAN tree, i.e. every commit blocked. Not
589
- // hypothetical: `tests/husky/pre-commit-owner-leakage.test.mjs` cpSync()s this
590
- // single file into a tmp repo, and the static-import variant turned all three of
591
- // its cases red (#974).
592
- //
593
- // The dynamic-import degrade used by getConfidentialNamePatterns() is NOT
594
- // available here. That helper degrades to `[]` for the standalone-copy case alone
595
- // — CP11 goes inert, CP1–CP10 keep running, nothing leaks (every OTHER load
596
- // failure there is now a counted FAIL, #1244). A failed REDACTION has the opposite
597
- // failure direction:
598
- // it prints confidential names verbatim into a PUBLIC GitHub-Actions log, which
599
- // is precisely the exposure this function exists to prevent (Fix 1 below). The
600
- // redaction sink must be unconditionally present, so it lives inline.
601
- //
602
- // `scripts/lib/redact-spans.mjs` is the shared primitive for IN-TREE consumers
603
- // (e.g. `scripts/lib/secret-masker.mjs`); this copy serves the vendored path.
604
- // The two are pinned byte-for-byte against each other by the drift guard in
605
- // `tests/lib/redact-spans.test.mjs` — change one and that suite goes red until
606
- // both agree again.
607
- /**
608
- * Redact every confidential-name span from `line`, ORDER-INDEPENDENTLY (Fix 1 + Fix 2).
609
- *
610
- * This is the single redaction sink for the confidential-names privacy invariant.
611
- * It is applied at the print choke-point over EVERY violation's lineContent — not
612
- * only CP11 hits — because this scanner runs in a PUBLIC GitHub-Actions mirror: a
613
- * confidential customer/repo name that co-occurs with a CP1–CP10 hit on the same
614
- * line (e.g. a name beside an RFC1918 IP that fails CP8) would otherwise be echoed
615
- * verbatim to the public CI log (Fix 1).
616
- *
617
- * ORDER-INDEPENDENCE (Fix 2): a naïve chain of `.replace()` calls is order-dependent
618
- * — when one configured name is a PREFIX of another (`['acme','acme-corp-secret']`),
619
- * redacting the shorter first destroys the longer's match and leaks a suffix residue
620
- * (`[REDACTED]-corp-secret`). Instead we compute ALL match spans against the ORIGINAL
621
- * (unmutated) string across every pattern, merge overlapping/adjacent intervals, and
622
- * splice `[REDACTED]` per merged interval. No pattern ever sees a string another
623
- * pattern already rewrote, so prefix/suffix overlap cannot leak regardless of list
624
- * order.
625
- *
626
- * @param {string} line — the raw (already-trimmed) violation lineContent.
627
- * @param {RegExp[]} patterns — confidential-name regexes (word-boundary, case-insensitive).
628
- * @returns {string} the line with every configured name span replaced by [REDACTED].
629
- */
630
- function redactSpans(line, patterns) {
631
- if (!Array.isArray(patterns) || patterns.length === 0) return line;
632
-
633
- // 1. Collect [start, end) spans of every match of every pattern against the
634
- // ORIGINAL line (never a partially-mutated one). Global clone so exec() walks
635
- // all matches; zero-width guard prevents an infinite loop on a degenerate regex.
636
- const spans = [];
637
- for (const re of patterns) {
638
- const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
639
- let m;
640
- while ((m = g.exec(line)) !== null) {
641
- if (m[0].length === 0) {
642
- g.lastIndex += 1;
643
- continue;
644
- }
645
- spans.push([m.index, m.index + m[0].length]);
646
- }
647
- }
648
- if (spans.length === 0) return line;
649
-
650
- // 2. Merge overlapping / adjacent intervals (sorted by start).
651
- spans.sort((a, b) => a[0] - b[0]);
652
- const merged = [];
653
- for (const [s, e] of spans) {
654
- const last = merged[merged.length - 1];
655
- if (last && s <= last[1]) {
656
- last[1] = Math.max(last[1], e);
657
- } else {
658
- merged.push([s, e]);
659
- }
660
- }
661
-
662
- // 3. Splice [REDACTED] per merged interval, left-to-right over the ORIGINAL line.
663
- let out = '';
664
- let cursor = 0;
665
- for (const [s, e] of merged) {
666
- out += line.slice(cursor, s) + '[REDACTED]';
667
- cursor = e;
668
- }
669
- out += line.slice(cursor);
670
- return out;
671
- }
672
-
673
- /**
674
- * The THREE modules getConfidentialNamePatterns() imports directly, as absolute
675
- * URLs resolved against THIS file. Used only to classify an ERR_MODULE_NOT_FOUND:
676
- * `err.url` carries the URL of the module that could not be found (measured on
677
- * Node 24 — a relative specifier yields `url`, a bare package specifier yields
678
- * none), so a miss on one of these three is the standalone single-file copy,
679
- * while a miss anywhere DEEPER (a transitive of an in-tree helper, or a bare
680
- * package) is a broken install that must fail CLOSED rather than go inert.
681
- */
682
254
  const CP11_DIRECT_SIBLING_URLS = new Set(
683
255
  ['../config/host-paths.mjs', './confidential-names.mjs', '../owner-yaml.mjs'].map(
684
256
  (spec) => new URL(spec, import.meta.url).href,
685
257
  ),
686
258
  );
687
259
 
688
- /**
689
- * True when an ERR_MODULE_NOT_FOUND names one of this scanner's own three DIRECT
690
- * helper imports — i.e. the documented standalone-vendoring shape. False for a
691
- * transitive relative module or a bare package (no `err.url` at all), which is a
692
- * broken in-tree install: CP11 then fails closed instead of silently returning
693
- * zero patterns while names ARE configured.
694
- *
695
- * @param {{ url?: string }} err
696
- * @returns {boolean}
697
- */
698
260
  function isMissingDirectSibling(err) {
699
261
  return typeof err?.url === 'string' && CP11_DIRECT_SIBLING_URLS.has(err.url);
700
262
  }
701
263
 
702
- /**
703
- * Was `paths.confidential-names-file` actually written into owner.yaml?
704
- *
705
- * WHY THIS RE-READS THE FILE. `loadOwnerConfig()` reports an invalid OPTIONAL
706
- * section only as `droppedSections: [{ section: 'paths', errors }]` and replaces
707
- * `config.paths` with the DEFAULTS — the raw keys of the dropped section are not
708
- * recoverable from its return value, and its `errors[]` name a key only for the
709
- * per-key `paths.<key> must be a string` case (a `paths: 42` shape names none).
710
- * So the one question CP11's verdict turns on — did the operator configure a
711
- * names file AT ALL — has no answer in the loader's contract today. Re-parsing
712
- * the file for that single key is the minimal derivation; widening the loader's
713
- * return shape would touch every one of its callers.
714
- *
715
- * Only reached when the YAML already parsed once inside loadOwnerConfig (the
716
- * dropped-section branch implies that), so `js-yaml` is resolvable here; 'unknown'
717
- * is the defensive residue and makes the caller fail closed.
718
- *
719
- * Returns a CLASS, never the value: the configured path is host-local and this
720
- * scanner's output is captured by a PUBLIC CI mirror (see the no-path rule above).
721
- *
722
- * @param {string} ownerYamlPath
723
- * @returns {'configured'|'absent'|'unknown'}
724
- */
725
264
  function rawConfidentialNamesKeyState(ownerYamlPath) {
726
265
  let parsed;
727
266
  try {
@@ -732,92 +271,23 @@ function rawConfidentialNamesKeyState(ownerYamlPath) {
732
271
  }
733
272
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return 'unknown';
734
273
  const paths = parsed.paths;
735
- // `paths:` absent, null, or not a mapping at all → the key cannot be in there.
736
274
  if (paths === null || typeof paths !== 'object' || Array.isArray(paths)) return 'absent';
737
275
  const value = paths['confidential-names-file'];
738
276
  if (value === undefined || value === null) return 'absent';
739
- // A non-string (or empty-string) value is still an ATTEMPT to configure CP11 —
740
- // except '' , which is the schema's documented "no override" spelling.
741
277
  if (typeof value === 'string' && value.trim() === '') return 'absent';
742
278
  return 'configured';
743
279
  }
744
280
 
745
- /**
746
- * CP11: build word-boundary, case-insensitive regexes from the host-local
747
- * confidential-names list (#728a). Mirrors CP6_PATTERNS (private slugs), but the
748
- * name source is LOADED from a never-committed host-local file instead of a
749
- * closed in-source array — the confidential customer/repo names must never live
750
- * in a committed file.
751
- *
752
- * LAZY BY CONTRACT: this reads the host-local file (owner.yaml + env), so it MUST
753
- * be called only from runScan() — NOT at module top-level. This module is
754
- * dual-mode (CLI entry point AND importable library, `isMain` guard); a top-level
755
- * fs read would fire on every library import (e.g. pseudonym-map.mjs importing
756
- * isOwnerLeakySegment). inspectConfidentialNames caches per path, so calling this
757
- * once per scan reads the file at most once.
758
- *
759
- * STANDALONE-SAFE: the two host-local helper modules are DYNAMICALLY imported
760
- * here (not statically at the top of the file) so a single-file copy of this
761
- * scanner — the documented vendoring pattern (security.md § Owner-Privacy: "Reuse
762
- * the same scanner"; exercised by tests/husky/pre-commit-owner-leakage.test.mjs,
763
- * which copies ONLY this file into a tmp repo) — does not crash at module load
764
- * with ERR_MODULE_NOT_FOUND. When the helpers are unresolvable (or throw), CP11
765
- * degrades to inert ([] patterns) and CP1–CP10 run unchanged.
766
- *
767
- * FAIL CLOSED WHEN CP11 WAS EXPECTED (GitLab #1244). The single bare
768
- * `try { … } catch { return [] }` this function used to be conflated three
769
- * outcomes that must not share a verdict, and printed `PASS` for all three:
770
- * (a) the helpers are unresolvable — the STANDALONE single-file copy. The
771
- * intended degrade, and the ONLY one: inert + one WARN line, exit unchanged.
772
- * (b) CP11 is not configured at all (no names file, no env) — the ~99% default:
773
- * inactive, silent, PASS. Unchanged. This INCLUDES an owner.yaml whose
774
- * `paths:` section was dropped as invalid for some OTHER key (the #1244
775
- * fix over-reached here and failed every commit on such a host): the raw
776
- * key is re-read (rawConfidentialNamesKeyState) and, when absent, CP11 is
777
- * INACTIVE — one WARN naming the dropped section, no FAIL, exit unchanged.
778
- * (c) CP11 IS configured — env set, a names file resolved, or the raw
779
- * `paths.confidential-names-file` key present in a `paths:` section that
780
- * was dropped as invalid — or its configuration is unknowable because
781
- * owner.yaml exists but cannot be parsed (e.g. `js-yaml` missing) — and
782
- * could not be loaded. Previously indistinguishable from (b): the scanner matched
783
- * nothing and printed `PASS: no owner-privacy leakage found`. A guard that
784
- * cannot run must say so and FAIL, never report the clean verdict it did
785
- * not earn — so this returns a `disabledReason` that runScan turns into a
786
- * `CP11 DISABLED` stderr line plus a counted FAIL (exit 1).
787
- *
788
- * The reason strings deliberately carry NO PATH. This scanner's stdout+stderr are
789
- * captured by a PUBLIC GitHub-Actions mirror, and the confidential-names path is
790
- * host-local — echoing it there would leak the very `/Users/<name>/…` shape CP1
791
- * exists to block. The operator knows their own path; the CLASS of failure is what
792
- * this line has to convey.
793
- *
794
- * @param {{ loadHostPaths?: typeof import('../config/host-paths.mjs').loadHostPaths }} [opts]
795
- * @returns {Promise<{ patterns: RegExp[], disabledReason?: string, inertWarn?: string }>}
796
- */
797
281
  export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
798
282
  let helpers;
799
283
  try {
800
284
  helpers = {
801
285
  hostPaths: await import('../config/host-paths.mjs'),
802
286
  confidentialNames: await import('./confidential-names.mjs'),
803
- // Already a transitive dependency (host-paths.mjs imports it statically), so
804
- // this adds no file to the standalone-copy chain the husky E2E mirrors.
805
287
  ownerYaml: await import('../owner-yaml.mjs'),
806
288
  };
807
289
  } catch (err) {
808
290
  if (err?.code === 'ERR_MODULE_NOT_FOUND' && isMissingDirectSibling(err)) {
809
- // (a) standalone single-file vendoring. The degrade is documented — but it
810
- // must not swallow a CP11 that the operator DID configure (GitLab #1262).
811
- // No helper resolved here, so the only configuration signal still readable
812
- // is the raw env var: a literal mirror of ENV_KEYS['confidential-names-file']
813
- // in scripts/lib/config/host-paths.mjs, which cannot be imported on this
814
- // branch by construction.
815
- //
816
- // LIMIT (named, not hidden): an owner.yaml-configured names file is
817
- // STRUCTURALLY undetectable in a standalone copy — resolveOwnerYamlPath()
818
- // lives in the very module chain that failed to resolve, so there is no way
819
- // to learn that owner.yaml even exists, let alone what it configures. Only
820
- // the env route can fail closed here; the owner.yaml route degrades inert.
821
291
  const envNamesPath = process.env.SO_CONFIDENTIAL_NAMES_FILE;
822
292
  if (typeof envNamesPath === 'string' && envNamesPath.trim() !== '') {
823
293
  return {
@@ -831,7 +301,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
831
301
  inertWarn: 'CP11 inert — confidential-names helpers not resolvable (standalone copy)',
832
302
  };
833
303
  }
834
- // Any OTHER import failure is a broken in-tree install, not the vendoring case.
835
304
  return { patterns: [], disabledReason: `confidential-names helpers failed to load (${err?.name ?? 'Error'})` };
836
305
  }
837
306
 
@@ -840,23 +309,8 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
840
309
  const { inspectConfidentialNames } = helpers.confidentialNames;
841
310
  const { resolveOwnerYamlPath } = helpers.ownerYaml;
842
311
 
843
- // ONE owner.yaml load. Since #1251 loadHostPaths() passes the loader's own
844
- // health (source / reason / droppedSections) straight through, so the
845
- // env>owner.yaml>default precedence and the load's diagnosis come from the
846
- // same read — no second loadOwnerConfig() call to recover what was discarded.
847
312
  const hostCtx = (loadHostPaths ?? helpers.hostPaths.loadHostPaths)();
848
313
 
849
- // FAIL-CLOSED on an unknowable load (LOW-2). `loadHostPaths` has its own
850
- // defensive catch that swallows a THROWING owner loader and returns
851
- // `{ ownerConfig: undefined, env, source: undefined, reason: undefined }`.
852
- // Without this branch that shape resolves `namesPath` to '' , fires no
853
- // reason branch, and returns `{ patterns: [] }` — a silent PASS.
854
- // Only the throw produces all-undefined: `loadOwnerConfig` returns
855
- // `{ config: getDefaults(), source: 'defaults', errors: [] }` for a
856
- // genuinely ABSENT owner.yaml (owner-yaml.mjs:587-590) and `source:
857
- // 'defaults'` with a `reason` for every read/parse failure — so a missing
858
- // file keeps taking the normal path below. Latent today (the loader
859
- // returns on every error path), load-bearing if that ever changes.
860
314
  if (
861
315
  hostCtx.ownerConfig === undefined &&
862
316
  hostCtx.source === undefined &&
@@ -872,9 +326,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
872
326
  const namesPath = resolveHostPath('confidential-names-file', '', hostCtx);
873
327
 
874
328
  if (typeof namesPath !== 'string' || namesPath.trim() === '') {
875
- // Nothing resolves a names file. That is either (b) — genuinely
876
- // unconfigured — or a state in which the answer is UNKNOWABLE because the
877
- // owner.yaml that would carry it could not be read. Unknowable is (c).
878
329
  if (existsSync(resolveOwnerYamlPath())) {
879
330
  if (hostCtx.reason === 'yaml-parser-missing') {
880
331
  return {
@@ -891,10 +342,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
891
342
  };
892
343
  }
893
344
  if (hostCtx.droppedSections?.some((d) => d.section === 'paths')) {
894
- // The paths: section was replaced by its default because SOME key in it
895
- // is invalid — which says nothing yet about whether CP11 was configured.
896
- // Re-read the RAW file for that one key (the loader does not expose it on
897
- // this branch; see rawConfidentialNamesKeyState) and only then decide.
898
345
  const rawKey = rawConfidentialNamesKeyState(resolveOwnerYamlPath());
899
346
  if (rawKey === 'configured') {
900
347
  return {
@@ -910,8 +357,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
910
357
  'owner.yaml has an invalid paths: section and could not be re-read, so a configured confidential-names-file cannot be ruled out',
911
358
  };
912
359
  }
913
- // 'absent' — CP11 was never configured here. Inactive, not disabled:
914
- // ONE WARN naming the dropped section, no FAIL, exit unchanged.
915
360
  return {
916
361
  patterns: [],
917
362
  inertWarn:
@@ -922,15 +367,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
922
367
  return { patterns: [] }; // (b) the ~99% default — inactive, silent.
923
368
  }
924
369
 
925
- // A names file IS configured. Since #1250 the loader REPORTS the class it
926
- // used to collapse into `null`, so the verdict reads straight off `status` —
927
- // no second read of the file to re-derive it. Only `[]` ('empty') is an
928
- // operator choice (inactive, silent); 'missing'/'malformed'/'all-dropped'
929
- // mean a configured guard cannot run, which is (c) and fails closed. The
930
- // reasons carry no path (see above). `inspectConfidentialNames` is the
931
- // discriminated entry point; `loadConfidentialNames` keeps its 4.0.0
932
- // `string[] | null` shape for external deep-importers and cannot express
933
- // this distinction.
934
370
  const { status, names } = inspectConfidentialNames({ namesPath });
935
371
  if (status === 'missing') {
936
372
  return { patterns: [], disabledReason: 'a confidential-names-file is configured but does not exist' };
@@ -942,10 +378,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
942
378
  };
943
379
  }
944
380
  if (status === 'all-dropped') {
945
- // W4 finding F3 (fail-open): the file listed entries and validation rejected
946
- // every one of them (non-strings, blanks, over-long). That is a CORRUPTED
947
- // list, not the `[]` opt-out — CP11 would run with zero patterns and report
948
- // the clean verdict it never earned. Same class as 'malformed'.
949
381
  return {
950
382
  patterns: [],
951
383
  disabledReason:
@@ -955,39 +387,10 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
955
387
  if (status !== 'ok') return { patterns: [] }; // 'empty' — the operator's `[]` opt-out.
956
388
  return { patterns: names.map((name) => new RegExp(`\\b${escapeRegex(name)}\\b`, 'i')) };
957
389
  } catch (err) {
958
- // The helpers resolved but something below threw. CP11 cannot run — fail closed.
959
390
  return { patterns: [], disabledReason: `confidential-names resolution failed (${err?.name ?? 'Error'})` };
960
391
  }
961
392
  }
962
393
 
963
- // ---------------------------------------------------------------------------
964
- // Text-scan extension allowlist (spec A.2)
965
- // ---------------------------------------------------------------------------
966
- // '.html' (#1076): the site/ tree is PUBLICLY SHIPPED (vercel.json outputDirectory:
967
- // "site"), so it is the highest-consequence class to scan, yet it was ungated here and
968
- // therefore invisible to all eleven CP rules.
969
- //
970
- // CORRECTION (#1080 Finding A). An earlier revision of this comment justified '.html'
971
- // partly by claiming its addition "revives canonicalizeLine()'s HTML-entity decoding,
972
- // which was dead in practice". That was true of exactly ONE of the eleven rules.
973
- // matchOwnerPath() (CP1) was the SOLE consumer of the canonical form — CP2-CP8, CP10
974
- // and CP11 each tested the RAW line — so an entity-encoded private host inside an href
975
- // (a link the BROWSER resolves and the scanner did not) still reported nothing. The
976
- // per-line canonical re-test in the scan loop below is what actually closes that, and
977
- // only for the four dot-anchored rules; see the `canon` comment there for why the rest
978
- // stay raw.
979
- //
980
- // '.xml', '.svg', '.css' (#1080 Finding B): the PUBLICLY-SHIPPED sentence above is true
981
- // VERBATIM of site/sitemap.xml and site/favicon.svg — same directory, same publication,
982
- // same consequence — which the '.html' addition walked past. Measured with one identical
983
- // planted defect per class before adding: the .html and .txt copies FAILed, the .xml and
984
- // .svg copies reported nothing. Cost is zero: the five tracked files in these classes
985
- // (assets/icon.svg, assets/og-card.svg, site/favicon.svg, site/sitemap.xml,
986
- // templates/static-html/styles.css) carry no hits — 1542 -> 1547 scanned, 0 findings.
987
- // `.jsonl` closes a gap this very wave opened: the harvested golden-record fixture at
988
- // tests/lib/vault-mirror/fixtures/golden-sessions.jsonl is tracked production data that
989
- // `.json` does not match, so the scanner would have skipped it for good. Measured before
990
- // adding: all 7 tracked `.jsonl` files pass (1525 -> 1532 scanned, 0 findings).
991
394
  const TEXT_EXTS = new Set([
992
395
  '.md',
993
396
  '.mdx',
@@ -1006,9 +409,6 @@ const TEXT_EXTS = new Set([
1006
409
  '.css',
1007
410
  ]);
1008
411
 
1009
- // Dotfiles to include (checked by basename, BEFORE the extension gate —
1010
- // extname('.env.example') is '.example' (truthy), so an extension-first check
1011
- // would make that allowlist entry unreachable; W3-P3 finding, 2026-06-10)
1012
412
  const DOTFILE_ALLOWLIST = new Set(['.env.example', '.nvmrc', '.vault.yaml']);
1013
413
 
1014
414
  function isTextFile(filePath) {
@@ -1018,16 +418,8 @@ function isTextFile(filePath) {
1018
418
  return ext ? TEXT_EXTS.has(ext) : false;
1019
419
  }
1020
420
 
1021
- // ---------------------------------------------------------------------------
1022
- // File enumeration: git ls-files primary, recursive fs walk fallback (spec A.2)
1023
- // Exclusions: .git/, node_modules/, .orchestrator/audits/
1024
- // ---------------------------------------------------------------------------
1025
-
1026
421
  function getTrackedFiles() {
1027
422
  try {
1028
- // NUL delimiters preserve Unicode, spaces and newlines without Git's quoted
1029
- // filename encoding. --exclude-standard applies only to untracked entries;
1030
- // tracked files remain visible even if an ignore rule now matches them.
1031
423
  const args = ['ls-files', '-z', '--cached'];
1032
424
  if (includeUntracked) args.push('--others', '--exclude-standard');
1033
425
  const output = execFileSync('git', args, { cwd: pluginRoot, encoding: 'utf8' });
@@ -1036,7 +428,6 @@ function getTrackedFiles() {
1036
428
  .filter(Boolean)
1037
429
  .map((f) => join(pluginRoot, f));
1038
430
  } catch {
1039
- // git unavailable or not a git repo — fall back to recursive fs walk
1040
431
  return walkDir(pluginRoot);
1041
432
  }
1042
433
  }
@@ -1050,7 +441,6 @@ function walkDir(dir, acc = []) {
1050
441
  }
1051
442
  for (const entry of entries) {
1052
443
  const full = join(dir, entry);
1053
- // Exclusions: .git, node_modules, .orchestrator/audits
1054
444
  if (entry === '.git' || entry === 'node_modules') continue;
1055
445
  const rel = relative(pluginRoot, full);
1056
446
  if (rel === join('.orchestrator', 'audits') || rel.startsWith(join('.orchestrator', 'audits') + sep)) continue;
@@ -1069,89 +459,37 @@ function walkDir(dir, acc = []) {
1069
459
  return acc;
1070
460
  }
1071
461
 
1072
- // ---------------------------------------------------------------------------
1073
- // Allowlist helpers (spec A.4)
1074
- // ---------------------------------------------------------------------------
1075
-
1076
- /**
1077
- * Return true if this gotzendorfer.at hit is covered by the exclusion allowlist.
1078
- * @param {string} relPath - path relative to pluginRoot (forward slashes)
1079
- * @param {string} line - the raw line content
1080
- * @returns {boolean}
1081
- */
1082
462
  function isAllowlisted(relPath, line) {
1083
- // Normalize to forward-slash for matching
463
+ const {policy, CP7_G} = currentOwnerRules();
1084
464
  const norm = relPath.replace(/\\/g, '/');
1085
-
1086
- // A.4 exclusion 5: tests/lib/events-default-url.test.mjs — ONLY the exact JSDoc contract line
1087
- if (norm === 'tests/lib/events-default-url.test.mjs') {
1088
- // The exact doc-comment line: " * - No literal `events.gotzendorfer.at` URL..."
1089
- // Match the literal backtick-quoted pattern in a JSDoc/comment line
1090
- if (/^\s+\*\s+- No literal `events\.gotzendorfer\.at`/.test(line)) {
1091
- return true;
1092
- }
1093
- // Any other gotzendorfer.at in this file is NOT excluded
1094
- return false;
1095
- }
1096
-
1097
- // A.4 exclusion 1: lines with office@ or security@ gotzendorfer.at and NO other gotzendorfer.at token
1098
- // (i.e., the only gotzendorfer.at occurrence is an email address — count occurrences)
1099
- const SANCTIONED_EMAILS = /(?:office|security)@gotzendorfer\.at/g;
1100
- const allGotzTokens = [...line.matchAll(/gotzendorfer\.at/g)];
1101
- const sanctionedMatches = [...line.matchAll(SANCTIONED_EMAILS)];
1102
- if (allGotzTokens.length > 0 && allGotzTokens.length === sanctionedMatches.length) {
1103
- // All gotzendorfer.at occurrences are the sanctioned email addresses
1104
- return true;
465
+ // Only published attribution files permit public URLs; email contacts work everywhere.
466
+ const urlsAllowed = new Set(['README.md', '.claude-plugin/plugin.json', '.claude-plugin/marketplace.json', '.codex-plugin/plugin.json', '.cursor-plugin/plugin.json', 'site/index.html', 'site/de/index.html', 'site/impressum/index.html', 'site/datenschutz/index.html']).has(norm);
467
+ let residue = line;
468
+ for (const email of policy.publicEmails) residue = residue.replace(new RegExp(escapeRegex(email) + '(?![\\w-]|\\.[\\w])', 'g'), '');
469
+ if (urlsAllowed) {
470
+ for (const url of policy.publicUrls) residue = residue.replace(new RegExp(escapeRegex(url) + '(?![\\w-]|\\.[\\w])', 'g'), '');
1105
471
  }
1106
-
1107
- // A.4 exclusion 2 + 3: https://gotzendorfer.at URLs in README.md and the 3 manifest files
1108
- const ALLOWLISTED_URL_PATHS = new Set([
1109
- 'README.md',
1110
- '.claude-plugin/plugin.json',
1111
- '.claude-plugin/marketplace.json',
1112
- '.codex-plugin/plugin.json',
1113
- // A.4 exclusion 7 (#1076): the three PUBLISHED static pages (vercel.json
1114
- // outputDirectory: "site"). Rule-based, never file-based — membership here only
1115
- // buys the line-form test below, and isAllowlisted() is consulted by CP3 and CP7
1116
- // ONLY. A SELF_EXCLUSIONS entry would instead have switched off all eleven rules
1117
- // for these files; that broad form is deliberately NOT used.
1118
- 'site/index.html',
1119
- 'site/impressum/index.html',
1120
- 'site/datenschutz/index.html',
1121
- // The German landing page (2026-09-07 redesign) carries the same publisher
1122
- // identity (JSON-LD @id + footer rel="author") as site/index.html. Exact
1123
- // path, never a site/de/ prefix — site/guide/index.html stays outside.
1124
- 'site/de/index.html',
1125
- ]);
1126
- const inAllowlistedFile = ALLOWLISTED_URL_PATHS.has(norm);
1127
-
1128
- if (inAllowlistedFile) {
1129
- // Check that the only gotzendorfer.at occurrences on this line are sanctioned URLs or emails
1130
- const SANCTIONED_URL = /https?:\/\/gotzendorfer\.at\b/g;
1131
- // The published site uses the www. host throughout, which SANCTIONED_URL cannot
1132
- // match — it requires the bare domain IMMEDIATELY after the scheme. Without this
1133
- // second form the three site entries above would exclude NOTHING and the gate
1134
- // (including .husky/pre-commit) would be permanently red. The two forms are
1135
- // DISJOINT: the www. prefix is mandatory here and impossible there, so no line
1136
- // can be counted twice (README.md stays at 1 token / 1 sanctioned match).
1137
- const SANCTIONED_PUBLIC_SITE = /(?:https?:\/\/)?www\.gotzendorfer\.at\b/g;
1138
- const allGotzOnLine = [...line.matchAll(/gotzendorfer\.at/g)];
1139
- const sanctionedUrlMatches = [...line.matchAll(SANCTIONED_URL)];
1140
- const sanctionedSiteMatches = [...line.matchAll(SANCTIONED_PUBLIC_SITE)];
1141
- const emailMatches = [...line.matchAll(SANCTIONED_EMAILS)];
1142
- const totalSanctioned =
1143
- sanctionedUrlMatches.length + sanctionedSiteMatches.length + emailMatches.length;
1144
- if (allGotzOnLine.length > 0 && allGotzOnLine.length === totalSanctioned) {
1145
- return true;
472
+ // Preserve the single documented contract comment; no arbitrary line exemption.
473
+ if (norm === 'tests/lib/events-default-url.test.mjs') {
474
+ for (const host of policy.eventsHosts) {
475
+ const contract = ' * - No literal `' + host + '` URL appears anywhere in scripts/ or hooks/.';
476
+ if (line === contract) return true;
1146
477
  }
1147
478
  }
1148
-
1149
- return false;
479
+ return countMatches(CP7_G, line) > 0 && countMatches(CP7_G, residue) === 0;
1150
480
  }
1151
481
 
1152
- // ---------------------------------------------------------------------------
1153
- // Counting
1154
- // ---------------------------------------------------------------------------
482
+ function safeDiagnosticPath(value, confidentialPatterns = []) {
483
+ const rules = currentOwnerRules();
484
+ const patterns = [...confidentialPatterns, ...rules.redactionPatterns];
485
+ // Replace whole offending filename segments, retaining only safe location context.
486
+ return value.split('/').map((segment) => {
487
+ const forms = [segment, canonicalizeLine(segment, {preserveDashes:true}), canonicalizeLine(segment)];
488
+ const privateSegment = forms.some((form) => isOwnerLeakySegment(form) ||
489
+ patterns.some((re) => { re.lastIndex = 0; return re.test(form); }));
490
+ return privateSegment ? '[REDACTED]' : segment;
491
+ }).join('/');
492
+ }
1155
493
 
1156
494
  let passed = 0;
1157
495
  let failed = 0;
@@ -1166,73 +504,40 @@ function fail(msg) {
1166
504
  failed++;
1167
505
  }
1168
506
 
1169
- // ---------------------------------------------------------------------------
1170
- // Main check — only runs when invoked as the CLI entry point (#661). When this
1171
- // module is imported (for unit-testing the canonicalization helpers), the scan
1172
- // and process.exit() below are skipped.
1173
- // ---------------------------------------------------------------------------
1174
-
1175
507
  async function runScan() {
1176
508
  console.log('--- Check 11: owner-privacy leakage ---');
1177
509
 
1178
510
  if (!existsSync(pluginRoot)) {
1179
- fail(`plugin root does not exist: ${pluginRoot}`);
511
+ fail('plugin root does not exist');
1180
512
  console.log('');
1181
513
  console.log(`Results: ${passed} passed, ${failed} failed`);
1182
514
  process.exit(1);
1183
515
  }
1184
516
 
1185
- const allFiles = getTrackedFiles();
1186
- const textFiles = allFiles.filter(isTextFile);
517
+ const owner = inspectOwnerPatterns();
518
+ if (owner.status !== 'ok') {
519
+ console.error(`WARN owner-patterns: ${owner.status} (${owner.reason ?? 'source-unavailable'}); host-specific rules unavailable`);
520
+ if (owner.status !== 'missing' || requireOwnerPatterns) fail('<scan-wide> — CP1 (guard disabled): owner patterns unavailable');
521
+ }
522
+ const { CP2, CP3, CP4, CP5_WITH_GOTZ, CP5_EXPORT, CP6_PATTERNS, CP7, CP3_G, CP7_G, CP10_PATTERNS, policy } = currentOwnerRules();
523
+ const allFiles = packedInventoryPath ? getPackedFiles() : getTrackedFiles();
524
+ const textFiles = packedInventoryPath ? allFiles : allFiles.filter(isTextFile);
1187
525
 
1188
- // Exclusions:
1189
- // - .orchestrator/audits/** never scanned (A.2/A.4-5)
1190
- // - This guard's own source file (pattern-doc-comments define the scanner — not leaks).
1191
- // - This guard's own test file (string-literal fixtures exercise the detector — not leaks).
1192
- // Self-exclusions are the design-time fix for the latent bug exposed when scanner
1193
- // fixture files transition from untracked → tracked in the same commit that tightens
1194
- // detection (commit a68e94f for the original two; commit 95c8237 deep-3 W4 added the
1195
- // case-insensitive P6 regex + introduced content-lint.test.mjs in the same commit,
1196
- // producing a pre-commit false-pass — see pipeline #4365 / housekeeping-2 2026-05-19).
1197
526
  const SELF_EXCLUSIONS = new Set([
1198
- 'scripts/lib/validate/check-owner-leakage.mjs',
1199
527
  'tests/lib/validate/check-owner-leakage.test.mjs',
1200
- // content-lint.test.mjs entry removed with the file itself (#985 Tier A,
1201
- // 2026-08-05) — a dangling exclusion would pre-authorize any future file
1202
- // at that path to bypass this scanner.
1203
528
  'tests/husky/pre-commit-owner-leakage.test.mjs',
1204
- // #634: encoding-contract fixtures (`-Users-bernhardg-` expected-value literals
1205
- // are load-bearing for the resolveMemoryDir() assertions; P9 would self-flag them)
1206
529
  'tests/lib/memory-paths.test.mjs',
1207
- // #660: owner-leakage redaction-test fixtures — namespace.test.mjs feeds the real
1208
- // CP1/CP6/CP10 leak literals into resolveRepoNamespace to prove they redact to
1209
- // 'redacted-repo'; the assertion literals are fixtures, not leaks (same class as
1210
- // check-owner-leakage.test.mjs above).
1211
530
  'tests/lib/vault-mirror/namespace.test.mjs',
1212
- // #700: vault-relocation classifier redaction-test fixtures — both files feed the
1213
- // real CP6 private slugs (BuchhaltGenie / aiat-pmo-module) into the classifier to
1214
- // prove they redact to 'redacted-repo'; assertion literals are fixtures, not leaks
1215
- // (same class as namespace.test.mjs above).
1216
531
  'tests/lib/vault-relocation-rules.test.mjs',
1217
532
  'tests/scripts/relocate-vault-corpus.test.mjs',
1218
533
  ]);
1219
534
  const scanFiles = textFiles.filter((f) => {
1220
535
  const rel = relative(pluginRoot, f).replace(/\\/g, '/');
1221
- if (rel.startsWith('.orchestrator/audits/')) return false;
1222
- if (SELF_EXCLUSIONS.has(rel)) return false;
536
+ if (!packedInventoryPath && rel.startsWith('.orchestrator/audits/')) return false;
537
+ if (!packedInventoryPath && SELF_EXCLUSIONS.has(rel)) return false;
1223
538
  return true;
1224
539
  });
1225
540
 
1226
- // CP11 (#728a): load the host-local confidential-names patterns ONCE per scan.
1227
- // [] when unconfigured (the default) OR when the host-local helper modules are
1228
- // unresolvable (standalone single-file copy) → the CP11 block below is a no-op.
1229
- // Awaited once, before the per-line loop, because the helpers are now dynamically
1230
- // imported (standalone-safe) — CP1–CP10 behaviour is unchanged.
1231
- //
1232
- // #1244: a CP11 that was EXPECTED but could not load is a DISABLED guard, not a
1233
- // clean scan — it is announced on stderr and counted as a FAIL so the run exits
1234
- // non-zero. CP1–CP10 still run to completion either way: a disabled CP11 must not
1235
- // suppress the findings the other ten rules can still make.
1236
541
  const cp11 = await getConfidentialNamePatterns();
1237
542
  const cp11Patterns = cp11.patterns;
1238
543
  if (cp11.inertWarn) {
@@ -1240,22 +545,24 @@ if (cp11.inertWarn) {
1240
545
  }
1241
546
  if (cp11.disabledReason) {
1242
547
  console.error(`CP11 DISABLED: ${cp11.disabledReason}`);
1243
- // Shaped like the per-file FAIL lines (`<where> — <CPn label>: <content>`) so the
1244
- // report's `— CPn` attribution parser sees CP11 here too; `<scan-wide>` stands in
1245
- // for the file position because a disabled guard is a property of the RUN, not of
1246
- // any one file. The reason carries no path (see getConfidentialNamePatterns).
1247
548
  fail(`<scan-wide> — CP11 (guard disabled): ${cp11.disabledReason}`);
1248
549
  }
1249
550
 
1250
- /** @type {Array<{relPath: string, lineNum: number, pattern: string, lineContent: string}>} */
1251
551
  const violations = [];
1252
552
 
1253
553
  for (const filePath of scanFiles) {
1254
554
  let content;
1255
555
  try {
1256
- content = readFileSync(filePath, 'utf8');
556
+ if (packedInventoryPath) {
557
+ const fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
558
+ try {
559
+ if (!fstatSync(fd).isFile() || realpathSync(filePath) !== filePath) throw new Error('unsafe-inventory');
560
+ content = readFileSync(fd, 'utf8');
561
+ } finally { closeSync(fd); }
562
+ } else { content = readFileSync(filePath, 'utf8'); }
1257
563
  } catch {
1258
- continue; // unreadable — skip
564
+ fail(`${safeDiagnosticPath(relative(pluginRoot, filePath), cp11Patterns)} — file unreadable`);
565
+ continue;
1259
566
  }
1260
567
 
1261
568
  const relPath = relative(pluginRoot, filePath).replace(/\\/g, '/');
@@ -1264,103 +571,53 @@ for (const filePath of scanFiles) {
1264
571
  lines.forEach((line, idx) => {
1265
572
  const lineNum = idx + 1;
1266
573
 
1267
- // The canonical form of this line, computed ONCE (#1080 Finding A). Until now
1268
- // matchOwnerPath (CP1) was the ONLY consumer of canonicalizeLine; every other rule
1269
- // tested the raw line, so an entity-encoded private host inside an href resolved in
1270
- // the browser and reported nothing here. The four DOT-anchored rules (CP2, CP3, CP7,
1271
- // CP8) are re-tested against it below.
1272
- //
1273
- // Why exactly those four, and not every rule — the discriminator is which character
1274
- // the rule ANCHORS on, and it was measured, not assumed:
1275
- // - Canonicalization can FABRICATE a '/' out of any benign hyphen run
1276
- // (`@goetzendorfer-team` -> `@goetzendorfer/team`, `~-Projects-Bernhard` ->
1277
- // `~/Projects/Bernhard`; both flip false -> true under canon). So the
1278
- // SLASH-anchored rules CP4 and CP10 would gain folding artifacts, and stay raw.
1279
- // - It can never fabricate a '.' from a separator: `gitlab-gotzendorfer-at`
1280
- // canonicalizes to `gitlab/gotzendorfer/at`, dot-free. A '.' appears in the
1281
- // canonical form ONLY by decoding an escape — which is the evasion itself. So for
1282
- // the DOT-anchored rules (CP2, CP3, CP7, CP8) every canonical-only hit is a
1283
- // decoded evasion by construction, and the false-positive surface is empty.
1284
- // - CP6 stays raw for the mirror-image reason: it anchors on `\b`-delimited slug
1285
- // literals, and the dash folding SHREDS five of the seven slugs
1286
- // (`mail-assistant` -> `mail/assistant`, which `\bmail-assistant\b` no longer
1287
- // matches), so a canonical test there is a no-op at best.
1288
- // - CP5 anchors on an identifier and CP11 on host-local names; neither is a path or
1289
- // host spelling, so neither is re-tested.
1290
- // Repo-wide cost of the four, measured over 1547 tracked files before landing: 0 new
1291
- // findings.
1292
574
  const canon = canonicalizeLine(line);
1293
575
 
1294
- // CP1: personal home path — CANONICALIZED match (#661). One structural rule
1295
- // replaces the slash-form (P1, #631) + dash-encoded (P9, #634) treadmill and
1296
- // catches percent/unicode/backslash/homoglyph encodings of the same path.
1297
576
  const ownerPathHit = matchOwnerPath(line);
1298
577
  if (ownerPathHit) {
1299
578
  violations.push({ relPath, lineNum, pattern: ownerPathHit, lineContent: line.trim() });
1300
579
  }
1301
580
 
1302
- // CP2: private GitLab host — raw OR canonical (#1080). ONE `if` per rule, so a
1303
- // line matching BOTH forms still reports exactly ONE violation. (The pre-existing
1304
- // 'CP2: private GitLab host' scan row, which expects fails: 2 for the CP2+CP7 pair,
1305
- // is the standing guard against a double-count regression here.) CP2 consults no
1306
- // allowlist, so the raw/canonical split CP3 and CP7 need does not arise.
1307
581
  if (CP2.test(line) || CP2.test(canon)) {
1308
- violations.push({ relPath, lineNum, pattern: 'CP2 (gitlab.gotzendorfer.at)', lineContent: line.trim() });
582
+ violations.push({ relPath, lineNum, pattern: 'CP2 (private host)', lineContent: line.trim() });
1309
583
  }
1310
584
 
1311
- // CP3: private events domain — the raw form consults the exclusion allowlist
1312
- // exactly as before; a DECODED surplus occurrence (canonical count > raw count)
1313
- // bypasses it, because an encoded spelling is never the sanctioned publication.
1314
- // See CP3_G / CP7_G for why this is a count and not isAllowlisted(relPath, canon).
1315
585
  const cp3Decoded = countMatches(CP3_G, canon) > countMatches(CP3_G, line);
1316
586
  if (CP3.test(line) || cp3Decoded) {
1317
587
  if (cp3Decoded || !isAllowlisted(relPath, line)) {
1318
- violations.push({ relPath, lineNum, pattern: 'CP3 (events.gotzendorfer.at)', lineContent: line.trim() });
588
+ violations.push({ relPath, lineNum, pattern: 'CP3 (private events host)', lineContent: line.trim() });
1319
589
  }
1320
590
  }
1321
591
 
1322
- // CP4: private package scope
1323
592
  if (CP4.test(line)) {
1324
- violations.push({ relPath, lineNum, pattern: 'CP4 (@goetzendorfer/ scope)', lineContent: line.trim() });
593
+ violations.push({ relPath, lineNum, pattern: 'CP4 (private scope/ scope)', lineContent: line.trim() });
1325
594
  }
1326
595
 
1327
- // CP5: DEFAULT_GITLAB_HOST with gotzendorfer OR as exported const
1328
- if (CP5_WITH_GOTZ.test(line) && /gotzendorfer/.test(line)) {
1329
- violations.push({ relPath, lineNum, pattern: 'CP5 (DEFAULT_GITLAB_HOST on gotzendorfer line)', lineContent: line.trim() });
596
+ if (CP5_WITH_GOTZ.test(line) && policy.privateDomains.some((token) => line.includes(token.split('.')[0]))) {
597
+ violations.push({ relPath, lineNum, pattern: 'CP5 (DEFAULT_GITLAB_HOST on owner line)', lineContent: line.trim() });
1330
598
  } else if (CP5_EXPORT.test(line)) {
1331
599
  violations.push({ relPath, lineNum, pattern: 'CP5 (DEFAULT_GITLAB_HOST exported const)', lineContent: line.trim() });
1332
600
  }
1333
601
 
1334
- // CP6: private project slugs
1335
602
  for (let i = 0; i < CP6_PATTERNS.length; i++) {
1336
603
  if (CP6_PATTERNS[i].test(line)) {
1337
- violations.push({ relPath, lineNum, pattern: `CP6 (private slug: ${PRIVATE_SLUGS[i]})`, lineContent: line.trim() });
604
+ violations.push({ relPath, lineNum, pattern: 'CP6 (private slug)', lineContent: line.trim() });
1338
605
  break; // one violation per line per slug is enough
1339
606
  }
1340
607
  }
1341
608
 
1342
- // CP7: catch-all gotzendorfer.at — same split as CP3: the raw form consults the
1343
- // exclusion allowlist, a decoded surplus occurrence bypasses it. This is the arm
1344
- // that catches the sharpest shape of #1080 Finding A — a WORKING link on a published
1345
- // page, `<a href="https://gitlab&#46;gotzendorfer&#46;at/...">`, which the browser
1346
- // resolves and the raw-only rule read as ordinary text.
1347
609
  const cp7Decoded = countMatches(CP7_G, canon) > countMatches(CP7_G, line);
1348
610
  if (CP7.test(line) || cp7Decoded) {
1349
611
  if (cp7Decoded || !isAllowlisted(relPath, line)) {
1350
- violations.push({ relPath, lineNum, pattern: 'CP7 (gotzendorfer.at catch-all)', lineContent: line.trim() });
612
+ violations.push({ relPath, lineNum, pattern: 'CP7 (private domain catch-all)', lineContent: line.trim() });
1351
613
  }
1352
614
  }
1353
615
 
1354
- // CP8: full RFC1918 private dotted-quad — internal IP leak (redaction-test fixtures
1355
- // exempt). Raw OR canonical: `10&#46;11&#46;12&#46;13` decodes to a literal quad.
1356
- // The allowlist here is PATH-scoped, not line-scoped, so it needs no raw/canonical
1357
- // split — it applies identically to both forms.
1358
- if ((CP8.test(line) || CP8.test(canon)) && !CP8_ALLOWLIST.has(relPath)) {
616
+ if ((CP8.test(line) || CP8.test(canon)) && (packedInventoryPath || !CP8_ALLOWLIST.has(relPath))) {
1359
617
  violations.push({ relPath, lineNum, pattern: 'CP8 (RFC1918 private IP)', lineContent: line.trim() });
1360
618
  }
1361
619
 
1362
- // CP10: personal-name segment in a ~/Projects/<name>/ path (allowlisted migration fixtures exempt) — #653
1363
- if (!CP10_ALLOWLIST.has(relPath)) {
620
+ if (packedInventoryPath || !CP10_ALLOWLIST.has(relPath)) {
1364
621
  for (const re of CP10_PATTERNS) {
1365
622
  if (re.test(line)) {
1366
623
  violations.push({ relPath, lineNum, pattern: 'CP10 (~/Projects/<name>/ personal segment)', lineContent: line.trim() });
@@ -1369,28 +626,13 @@ for (const filePath of scanFiles) {
1369
626
  }
1370
627
  }
1371
628
 
1372
- // CP11: host-local confidential customer/repo names (#728a). INACTIVE unless a
1373
- // names file is configured (cp11Patterns is [] by default).
1374
- //
1375
- // DETECTION ONLY here — the confidential-name REDACTION is applied at the print
1376
- // choke-point (redactSpans over EVERY violation's lineContent), so a name that
1377
- // rides in on a CP1–CP10 hit on the same line is scrubbed too (Fix 1), and a
1378
- // name that is a prefix of another configured name cannot leak a suffix residue
1379
- // (Fix 2). cp11Patterns are non-global, so `.test()` is stateless.
1380
629
  if (cp11Patterns.length > 0 && cp11Patterns.some((re) => re.test(line))) {
1381
630
  violations.push({ relPath, lineNum, pattern: 'CP11 (confidential name)', lineContent: line.trim() });
1382
631
  }
1383
632
  });
1384
633
  }
1385
634
 
1386
- // Deduplicate: CP7 and CP3 can overlap — de-dup by (relPath, lineNum, pattern)
1387
- // But CP3 and CP7 are distinct patterns so they'd produce separate entries.
1388
- // However, one line could match both CP3 and CP7 — treat as two violations.
1389
- // The spec does not say to deduplicate, so keep as-is.
1390
-
1391
635
  if (violations.length === 0) {
1392
- // #1244: only claim the clean verdict the run actually earned. With CP11
1393
- // disabled, ten of eleven rules ran — say that instead of "no leakage found".
1394
636
  if (cp11.disabledReason) {
1395
637
  console.log(` (CP1–CP10 found no leakage across ${scanFiles.length} scanned files; CP11 did not run)`);
1396
638
  } else {
@@ -1398,15 +640,8 @@ if (violations.length === 0) {
1398
640
  }
1399
641
  } else {
1400
642
  for (const v of violations) {
1401
- // Choke-point redaction (Fix 1 + Fix 2): scrub every configured confidential
1402
- // name from EVERY violation's lineContent — not only CP11 hits — BEFORE it
1403
- // reaches stdout (the public GitHub-Actions log). redactSpans is order-
1404
- // independent (span-merge, never chained .replace), and is a no-op when no
1405
- // names file is configured (cp11Patterns === []), so the CP1–CP10 default-path
1406
- // output is byte-identical to before. Redact first, THEN truncate, so a name
1407
- // straddling the 120-char boundary can never leak its tail.
1408
- const safeContent = redactSpans(v.lineContent, cp11Patterns);
1409
- fail(`${v.relPath}:${v.lineNum} — ${v.pattern}: ${safeContent.slice(0, 120)}`);
643
+ // Violation content is omitted: canonical detection may hide reversible spellings.
644
+ fail(`${safeDiagnosticPath(v.relPath, cp11Patterns)}:${v.lineNum} — ${v.pattern}: [REDACTED]`);
1410
645
  }
1411
646
  }
1412
647
 
@@ -1415,71 +650,19 @@ console.log(`Results: ${passed} passed, ${failed} failed (${scanFiles.length} sc
1415
650
  process.exit(failed === 0 ? 0 : 1);
1416
651
  }
1417
652
 
1418
- // ---------------------------------------------------------------------------
1419
- // Exported helper for in-process leak detection (Issue #660 namespace guard)
1420
- // ---------------------------------------------------------------------------
1421
-
1422
- /**
1423
- * Test whether a single value (a repo identifier or sanitised slug segment)
1424
- * matches any owner-privacy leakage pattern.
1425
- *
1426
- * Runs the canonical CP1 check (via canonicalizeLine + CP1_CANON) and the
1427
- * word-boundary CP6 (private project slugs) and CP10 (personal name in a
1428
- * Projects path) checks against the raw value. Returns the matched pattern id
1429
- * string or null when clean.
1430
- *
1431
- * CP6 here uses the NARROWER CP6_INPROCESS_PATTERNS (PRIVATE_SLUGS minus
1432
- * VAULT_CLEAR_SLUGS — issue #59 owner decision 2026-07-18), so carved-out slugs
1433
- * (buchhaltgenie, mail-assistant, wien-forschungsfragen-klima, launchpad-ai-factory,
1434
- * angebotschecker) resolve to their own vault namespace here while the tracked-file
1435
- * scanner (runScan, full CP6_PATTERNS) still blocks them from the public mirror.
1436
- *
1437
- * This is the single source of truth for in-process leak detection — do NOT
1438
- * reimplement these pattern checks outside this module. The `namespace.mjs`
1439
- * resolver uses this to guard vault path segments before any filesystem write.
1440
- *
1441
- * @param {string} value — raw repo identifier or sanitised slug to test.
1442
- * @returns {'CP1'|'CP6'|'CP10'|null}
1443
- */
1444
653
  export function isOwnerLeakySegment(value) {
1445
654
  if (typeof value !== 'string' || !value) return null;
1446
-
1447
- // CP1: personal home path — canonicalize then match.
1448
- const ownerPathMatch = matchOwnerPath(value);
1449
- if (ownerPathMatch !== null) return 'CP1';
1450
-
1451
- // CP1 (bare): the personal-username token standing alone — e.g. the macOS login
1452
- // name surfacing via deriveRepo()'s basename(process.cwd()) fallback when cwd is
1453
- // the home directory. CP1_CANON only fires with a `/Users/` prefix, so the bare
1454
- // segment would otherwise slip through into a committed vault path (#660 Q3-LOW-1).
1455
- if (/^[bB][eE][rR][nN][hH][aA][rR][dD][gG][a-z]*$/.test(value)) return 'CP1';
1456
-
1457
- // CP6: private project slugs (word-boundary, case-insensitive). In-process
1458
- // guard uses the VAULT_CLEAR_SLUGS-carved list, NOT the full CP6_PATTERNS the
1459
- // tracked-file scanner uses (issue #59).
1460
- for (const re of CP6_INPROCESS_PATTERNS) {
1461
- if (re.test(value)) return 'CP6';
1462
- }
1463
-
1464
- // CP10: personal name in a ~/Projects/<name>/ path.
1465
- for (const re of CP10_PATTERNS) {
1466
- if (re.test(value)) return 'CP10';
1467
- }
1468
-
655
+ const rules = currentOwnerRules();
656
+ if (rules.status !== 'ok' && (rules.status !== 'missing' || (process.env.SO_OWNER_PATTERNS_FILE || '').trim())) return 'CP1';
657
+ if (matchOwnerPath(value) || rules.CP1_BARE.test(value)) return 'CP1';
658
+ if (rules.CP6_INPROCESS_PATTERNS.some((re) => re.test(value))) return 'CP6';
659
+ if (rules.CP10_PATTERNS.some((re) => re.test(value))) return 'CP10';
1469
660
  return null;
1470
661
  }
1471
662
 
1472
- // Run the scan only when invoked directly as a CLI (#661).
1473
663
  if (isMain) {
1474
- // runScan is async (the CP11 confidential-names helpers are now dynamically
1475
- // imported, standalone-safe) and self-terminates via process.exit(). We do NOT
1476
- // await it — that would make this a top-level-await module and force every static
1477
- // importer of isOwnerLeakySegment to become async. Instead we attach a `.catch`
1478
- // (Fix 4): while runScan's sole async step swallows its own errors today, an
1479
- // unhandled rejection from ANY future refactor must fail CLOSED with context and a
1480
- // deterministic exit 1 — never a silent unhandled-rejection warning + exit 0.
1481
664
  runScan().catch((err) => {
1482
- console.error('check-owner-leakage crashed:', err);
665
+ console.error('check-owner-leakage failed:', (['ENOENT','EACCES','EPERM','ELOOP','ENOTDIR','EISDIR'].includes(err?.code) ? err.code : ['unsafe-inventory','invalid-inventory','inventory-too-large','invalid-inventory-entry'].includes(err?.message) ? err.message : 'scan-error'));
1483
666
  process.exit(1);
1484
667
  });
1485
668
  }