@indigoai-us/hq-cli 5.345.164 → 5.345.165

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.165] — 2026-10-23
6
+ - `hq billing sponsor grant <email|prs_id> --company <slug>` lets a company owner or admin put a member's personal plan on the company bill without a request first; `--all-members` does it for every active member and reports each person as covered, already covered, or skipped. `hq billing sponsor ungrant <email|prs_id>` takes it off again. hq-pro checks the owner/admin role and active membership.
7
+ - `hq scan secrets <files...>` takes `--repo <dir>`, which applies that repo's committed `.gitleaks.toml` allowlists to a file scan, matching each file by its path relative to the repo, as a staged scan does. `--snapshot-root <dir>` (with `--repo`) matches files laid out as `<dir>/<commit>/<repo path>`, the history copies `commit-secret-scan.sh pr-check` writes, as `<repo path>`. Findings still name the files as given. Before this, a file scan ignored the repo's allowlists, so pr-check failed on false positives the commit gate already allowed. Without `--repo` a file scan is unchanged.
8
+ - `--allowlist-rev <rev>` (with `--repo`) reads `.gitleaks.toml` from that commit instead of HEAD; pr-check passes its base branch, so a branch cannot exempt its own earlier commits by adding an allowlist later. An unknown rev exits 2.
9
+ - A file that resolves (after `..` and symlinks) outside the repo and the snapshot root gets no repo allowlist, nor does a snapshot file without a commit folder.
10
+ - A file scan with `--repo` honors only exact-value exemptions. Each allowlist needs `condition = "AND"` and specific `paths`, and lists in `stopwords` the exact secret values it allows, such as known manifest digests (at least 16 characters, no spaces). A detected secret is allowed only when it equals one of them, so other secrets in the same file are still reported. An allowlist with `regexes`, `regexTarget`, `targetRules` or `commits`, or with paths alone, is refused and the scan exits 2, because patterns, alone or combined across entries, can hide a real credential. Staged scans keep the full allowlist format; there each allowlist with content checks is judged by running the real detector on a fixed set of realistic credentials and refused if it hides any of them.
11
+ - An allowlist validation error never prints an exemption value. It names the entry and position (for example `stopwords[0]`) with the value's length and a short SHA-256 prefix, so a credential typed into an allowlist by mistake does not reach terminal or CI logs.
12
+
5
13
  ## [5.345.164] — 2026-10-23
6
14
  - `hq billing status` and `hq billing usage --card` now show trial usage against the $500 trial cap, the $10 included usage, the spend limit and a prompt to add a card when usage passes the included amount. The plan line says "unavailable right now" instead of guessing Starter, invoice-billed companies read as a flat plan billed by invoice, and a failed read of a scheduled switch is reported.
7
15
  - `hq deploy domains` refuses a name that is really an IPv4 address. Inputs such as `1.2`, `0x7f.1` and `1.2.3.4` were rewritten to an address and accepted; a numeric last label is now refused before any request.
@@ -6277,6 +6277,18 @@ export declare const COMMAND_CATALOG: readonly [{
6277
6277
  readonly flags: "--json";
6278
6278
  readonly description: "Print findings as JSON";
6279
6279
  readonly hidden: false;
6280
+ }, {
6281
+ readonly flags: "--repo <dir>";
6282
+ readonly description: "With files: apply this repo's committed .gitleaks.toml allowlists, matching repo-relative paths";
6283
+ readonly hidden: false;
6284
+ }, {
6285
+ readonly flags: "--snapshot-root <dir>";
6286
+ readonly description: "With --repo: files under <dir>/<commit>/<path> are matched as <path>";
6287
+ readonly hidden: false;
6288
+ }, {
6289
+ readonly flags: "--allowlist-rev <rev>";
6290
+ readonly description: "With --repo: read .gitleaks.toml from this commit instead of HEAD";
6291
+ readonly hidden: false;
6280
6292
  }];
6281
6293
  readonly subcommands: readonly [];
6282
6294
  }];
@@ -8820,6 +8832,60 @@ export declare const COMMAND_CATALOG: readonly [{
8820
8832
  readonly hidden: false;
8821
8833
  }];
8822
8834
  readonly subcommands: readonly [];
8835
+ }, {
8836
+ readonly name: "grant";
8837
+ readonly description: "Admin: put a member's personal plan on the company bill, no request needed";
8838
+ readonly aliases: readonly [];
8839
+ readonly hidden: false;
8840
+ readonly helpOption: true;
8841
+ readonly helpCommand: false;
8842
+ readonly usage: "[options] [person]";
8843
+ readonly helpText: readonly [];
8844
+ readonly arguments: readonly [{
8845
+ readonly name: "person";
8846
+ readonly required: false;
8847
+ readonly variadic: false;
8848
+ readonly description: "Member email or prs_ id";
8849
+ }];
8850
+ readonly options: readonly [{
8851
+ readonly flags: "--company <slug>";
8852
+ readonly description: "Company slug (resolves to companyUid)";
8853
+ readonly hidden: false;
8854
+ }, {
8855
+ readonly flags: "--all-members";
8856
+ readonly description: "Sponsor every active member of the company";
8857
+ readonly hidden: false;
8858
+ }, {
8859
+ readonly flags: "--json";
8860
+ readonly description: "Emit raw JSON";
8861
+ readonly hidden: false;
8862
+ }];
8863
+ readonly subcommands: readonly [];
8864
+ }, {
8865
+ readonly name: "ungrant";
8866
+ readonly description: "Admin: take a member's personal plan off the company bill";
8867
+ readonly aliases: readonly [];
8868
+ readonly hidden: false;
8869
+ readonly helpOption: true;
8870
+ readonly helpCommand: false;
8871
+ readonly usage: "[options] <person>";
8872
+ readonly helpText: readonly [];
8873
+ readonly arguments: readonly [{
8874
+ readonly name: "person";
8875
+ readonly required: true;
8876
+ readonly variadic: false;
8877
+ readonly description: "Member email or prs_ id";
8878
+ }];
8879
+ readonly options: readonly [{
8880
+ readonly flags: "--company <slug>";
8881
+ readonly description: "Company slug (resolves to companyUid)";
8882
+ readonly hidden: false;
8883
+ }, {
8884
+ readonly flags: "--json";
8885
+ readonly description: "Emit raw JSON";
8886
+ readonly hidden: false;
8887
+ }];
8888
+ readonly subcommands: readonly [];
8823
8889
  }];
8824
8890
  }, {
8825
8891
  readonly name: "usage";
@@ -7634,6 +7634,21 @@ export const COMMAND_CATALOG = [
7634
7634
  "flags": "--json",
7635
7635
  "description": "Print findings as JSON",
7636
7636
  "hidden": false
7637
+ },
7638
+ {
7639
+ "flags": "--repo <dir>",
7640
+ "description": "With files: apply this repo's committed .gitleaks.toml allowlists, matching repo-relative paths",
7641
+ "hidden": false
7642
+ },
7643
+ {
7644
+ "flags": "--snapshot-root <dir>",
7645
+ "description": "With --repo: files under <dir>/<commit>/<path> are matched as <path>",
7646
+ "hidden": false
7647
+ },
7648
+ {
7649
+ "flags": "--allowlist-rev <rev>",
7650
+ "description": "With --repo: read .gitleaks.toml from this commit instead of HEAD",
7651
+ "hidden": false
7637
7652
  }
7638
7653
  ],
7639
7654
  "subcommands": []
@@ -10757,6 +10772,73 @@ export const COMMAND_CATALOG = [
10757
10772
  }
10758
10773
  ],
10759
10774
  "subcommands": []
10775
+ },
10776
+ {
10777
+ "name": "grant",
10778
+ "description": "Admin: put a member's personal plan on the company bill, no request needed",
10779
+ "aliases": [],
10780
+ "hidden": false,
10781
+ "helpOption": true,
10782
+ "helpCommand": false,
10783
+ "usage": "[options] [person]",
10784
+ "helpText": [],
10785
+ "arguments": [
10786
+ {
10787
+ "name": "person",
10788
+ "required": false,
10789
+ "variadic": false,
10790
+ "description": "Member email or prs_ id"
10791
+ }
10792
+ ],
10793
+ "options": [
10794
+ {
10795
+ "flags": "--company <slug>",
10796
+ "description": "Company slug (resolves to companyUid)",
10797
+ "hidden": false
10798
+ },
10799
+ {
10800
+ "flags": "--all-members",
10801
+ "description": "Sponsor every active member of the company",
10802
+ "hidden": false
10803
+ },
10804
+ {
10805
+ "flags": "--json",
10806
+ "description": "Emit raw JSON",
10807
+ "hidden": false
10808
+ }
10809
+ ],
10810
+ "subcommands": []
10811
+ },
10812
+ {
10813
+ "name": "ungrant",
10814
+ "description": "Admin: take a member's personal plan off the company bill",
10815
+ "aliases": [],
10816
+ "hidden": false,
10817
+ "helpOption": true,
10818
+ "helpCommand": false,
10819
+ "usage": "[options] <person>",
10820
+ "helpText": [],
10821
+ "arguments": [
10822
+ {
10823
+ "name": "person",
10824
+ "required": true,
10825
+ "variadic": false,
10826
+ "description": "Member email or prs_ id"
10827
+ }
10828
+ ],
10829
+ "options": [
10830
+ {
10831
+ "flags": "--company <slug>",
10832
+ "description": "Company slug (resolves to companyUid)",
10833
+ "hidden": false
10834
+ },
10835
+ {
10836
+ "flags": "--json",
10837
+ "description": "Emit raw JSON",
10838
+ "hidden": false
10839
+ }
10840
+ ],
10841
+ "subcommands": []
10760
10842
  }
10761
10843
  ]
10762
10844
  },
@@ -13,6 +13,13 @@
13
13
  * hq billing sponsor decline <id> — admin: say no
14
14
  * hq billing sponsor revoke <id> — admin: end an active sponsorship
15
15
  * hq billing sponsor withdraw <id> — me: take back my own request
16
+ * hq billing sponsor grant <person> --company <slug>
17
+ * — admin: sponsor a member's plan
18
+ * directly, no request needed
19
+ * hq billing sponsor grant --all-members --company <slug>
20
+ * — admin: sponsor every active member
21
+ * hq billing sponsor ungrant <person> --company <slug>
22
+ * — admin: end a direct sponsorship
16
23
  *
17
24
  * Every subcommand talks to the SAME hq-pro routes the console uses
18
25
  * (`/v1/billing/sponsorship/...`). hq-pro is the authoritative gate on all of
@@ -82,6 +89,19 @@ export interface SponsorshipSelfState {
82
89
  }
83
90
  /** Plain label for a request status. Never shows the raw enum. */
84
91
  export declare function statusLabel(status: string): string;
92
+ /** Per-person outcome of an owner/admin grant, as hq-pro returns it. */
93
+ export interface PlanGrantResult {
94
+ personUid: string;
95
+ resourceId: string;
96
+ outcome: "sponsored" | "already_sponsored" | "sponsored_by_another_company" | "usage_pricing" | "not_a_member";
97
+ }
98
+ /** Plain line for one grant outcome. */
99
+ export declare function grantOutcomeLabel(outcome: PlanGrantResult["outcome"]): string;
100
+ /**
101
+ * Turn `<person>` into a person uid: a `prs_` uid passes through, an email is
102
+ * looked up in the company's active members. hq-pro re-checks membership.
103
+ */
104
+ export declare function resolveMemberPersonUid(token: string, companyUid: string, person: string): Promise<string>;
85
105
  export declare function registerBillingSponsorCommands(billing: Command): void;
86
106
  export {};
87
107
  //# sourceMappingURL=billing-sponsor.d.ts.map
@@ -13,6 +13,13 @@
13
13
  * hq billing sponsor decline <id> — admin: say no
14
14
  * hq billing sponsor revoke <id> — admin: end an active sponsorship
15
15
  * hq billing sponsor withdraw <id> — me: take back my own request
16
+ * hq billing sponsor grant <person> --company <slug>
17
+ * — admin: sponsor a member's plan
18
+ * directly, no request needed
19
+ * hq billing sponsor grant --all-members --company <slug>
20
+ * — admin: sponsor every active member
21
+ * hq billing sponsor ungrant <person> --company <slug>
22
+ * — admin: end a direct sponsorship
16
23
  *
17
24
  * Every subcommand talks to the SAME hq-pro routes the console uses
18
25
  * (`/v1/billing/sponsorship/...`). hq-pro is the authoritative gate on all of
@@ -35,6 +42,7 @@ import { diagnosticsEnabled } from "../lib/diagnostics.js";
35
42
  import { ensureCognitoToken } from "../utils/cognito-session.js";
36
43
  import { isCompanySelectionError } from "../utils/company-selection-error.js";
37
44
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
45
+ import { listActiveMembers } from "./members.js";
38
46
  import { fetchCompanyPricingPlan, fetchPersonPricingPlan, isUsageOnly, usageOnlySponsorLines, } from "../lib/billing/usage-only.js";
39
47
  /** The request kind this command files. Matches hq-pro's contract. */
40
48
  export const PLAN_RESOURCE = "plan";
@@ -90,6 +98,9 @@ export function messageForError(status, body) {
90
98
  if (status === 403) {
91
99
  return "You're not allowed to do that. Only an owner or admin of the company can answer a sponsorship request.";
92
100
  }
101
+ if (status === 404 && code === "NOT_A_MEMBER") {
102
+ return "That person isn't an active member of this company.";
103
+ }
93
104
  if (status === 404)
94
105
  return "No sponsorship request with that id.";
95
106
  if (status === 409 && code === "DUPLICATE_PENDING_REQUEST") {
@@ -168,6 +179,41 @@ async function transition(requestId, op) {
168
179
  });
169
180
  return { ...data.request, code: data.code, addCardUrl: data.addCardUrl };
170
181
  }
182
+ /** Plain line for one grant outcome. */
183
+ export function grantOutcomeLabel(outcome) {
184
+ switch (outcome) {
185
+ case "sponsored":
186
+ return "Now on the company bill";
187
+ case "already_sponsored":
188
+ return "Already on the company bill";
189
+ case "sponsored_by_another_company":
190
+ return "Skipped: another company already covers them";
191
+ case "usage_pricing":
192
+ return "Skipped: on usage pricing, no personal plan to cover";
193
+ case "not_a_member":
194
+ return "Skipped: not an active member";
195
+ default:
196
+ return outcome;
197
+ }
198
+ }
199
+ /**
200
+ * Turn `<person>` into a person uid: a `prs_` uid passes through, an email is
201
+ * looked up in the company's active members. hq-pro re-checks membership.
202
+ */
203
+ export async function resolveMemberPersonUid(token, companyUid, person) {
204
+ const trimmed = person.trim();
205
+ if (/^prs_[A-Za-z0-9]+$/.test(trimmed))
206
+ return trimmed;
207
+ const email = trimmed.toLowerCase();
208
+ if (!/^[^\s]+@[^\s]+$/.test(email)) {
209
+ throw new SponsorError("Pass the person's email or their prs_ id.");
210
+ }
211
+ const members = await listActiveMembers(token, companyUid);
212
+ const match = members.find((m) => m.personEmail?.trim().toLowerCase() === email);
213
+ if (match?.personUid)
214
+ return match.personUid;
215
+ throw new SponsorError(`No active member has the email ${email}. Run \`hq members list\`.`);
216
+ }
171
217
  /** Whether the sponsoring company is on usage pricing. Unknown counts as no. */
172
218
  async function companyIsUsageOnly(companyUid) {
173
219
  if (!companyUid)
@@ -402,5 +448,75 @@ export function registerBillingSponsorCommands(billing) {
402
448
  fail(err);
403
449
  }
404
450
  });
451
+ sponsor
452
+ .command("grant")
453
+ .argument("[person]", "Member email or prs_ id")
454
+ .description("Admin: put a member's personal plan on the company bill, no request needed")
455
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
456
+ .option("--all-members", "Sponsor every active member of the company")
457
+ .option("--json", "Emit raw JSON")
458
+ .action(async function (person, opts) {
459
+ try {
460
+ if (Boolean(person) === Boolean(opts.allMembers)) {
461
+ throw new SponsorError("Name one person, or pass --all-members.");
462
+ }
463
+ const token = await ensureCognitoToken();
464
+ const named = opts.company ?? billing.opts().company;
465
+ const companyUid = await getCompanyUid(token, named);
466
+ const body = { companyUid };
467
+ if (person)
468
+ body.personUid = await resolveMemberPersonUid(token, companyUid, person);
469
+ else
470
+ body.allMembers = true;
471
+ const data = await callApi(token, "/v1/billing/sponsorship/plans", { method: "POST", body });
472
+ if (opts.json) {
473
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
474
+ return;
475
+ }
476
+ if (data.results.length === 0) {
477
+ console.log("No active members to sponsor.");
478
+ return;
479
+ }
480
+ for (const r of data.results) {
481
+ console.log(` ${chalk.bold(r.personUid)} ${grantOutcomeLabel(r.outcome)}`);
482
+ }
483
+ const added = data.results.filter((r) => r.outcome === "sponsored").length;
484
+ console.log(added === 1
485
+ ? "1 personal plan moved onto the company bill."
486
+ : `${added} personal plans moved onto the company bill.`);
487
+ const single = person ? data.results[0] : undefined;
488
+ if (single && single.outcome !== "sponsored" && single.outcome !== "already_sponsored") {
489
+ process.exitCode = 1;
490
+ }
491
+ }
492
+ catch (err) {
493
+ fail(err);
494
+ }
495
+ });
496
+ sponsor
497
+ .command("ungrant")
498
+ .argument("<person>", "Member email or prs_ id")
499
+ .description("Admin: take a member's personal plan off the company bill")
500
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
501
+ .option("--json", "Emit raw JSON")
502
+ .action(async function (person, opts) {
503
+ try {
504
+ const token = await ensureCognitoToken();
505
+ const named = opts.company ?? billing.opts().company;
506
+ const companyUid = await getCompanyUid(token, named);
507
+ const personUid = await resolveMemberPersonUid(token, companyUid, person);
508
+ const data = await callApi(token, "/v1/billing/sponsorship/plans", { method: "DELETE", body: { companyUid, personUid } });
509
+ if (opts.json) {
510
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
511
+ return;
512
+ }
513
+ console.log(data.revoked
514
+ ? "Ended. Their personal plan goes back to them from the next bill."
515
+ : "This company wasn't covering their plan. Nothing changed.");
516
+ }
517
+ catch (err) {
518
+ fail(err);
519
+ }
520
+ });
405
521
  }
406
522
  //# sourceMappingURL=billing-sponsor.js.map
@@ -4,6 +4,9 @@
4
4
  * hq scan secrets scan the staged changes of the current repo
5
5
  * hq scan secrets <file...> scan whole files instead
6
6
  * hq scan secrets --json findings as JSON
7
+ * hq scan secrets --repo <dir> [--snapshot-root <dir>] [--allowlist-rev <rev>] <file...>
8
+ * scan files with that repo's committed
9
+ * .gitleaks.toml allowlists (used by pr-check)
7
10
  *
8
11
  * Rules: the vendored gitleaks default rule set (MIT, vendor/gitleaks/) plus
9
12
  * HQ's own token patterns. No network, no account, no quota. A staged scan also
@@ -18,6 +21,9 @@ import { Command } from "commander";
18
21
  interface ScanSecretsOptions {
19
22
  json?: boolean;
20
23
  staged?: boolean;
24
+ repo?: string;
25
+ snapshotRoot?: string;
26
+ allowlistRev?: string;
21
27
  }
22
28
  export declare function runScanSecrets(paths: string[], opts: ScanSecretsOptions, cwd?: string): number;
23
29
  export declare function registerScanCommand(program: Command): void;
@@ -4,6 +4,9 @@
4
4
  * hq scan secrets scan the staged changes of the current repo
5
5
  * hq scan secrets <file...> scan whole files instead
6
6
  * hq scan secrets --json findings as JSON
7
+ * hq scan secrets --repo <dir> [--snapshot-root <dir>] [--allowlist-rev <rev>] <file...>
8
+ * scan files with that repo's committed
9
+ * .gitleaks.toml allowlists (used by pr-check)
7
10
  *
8
11
  * Rules: the vendored gitleaks default rule set (MIT, vendor/gitleaks/) plus
9
12
  * HQ's own token patterns. No network, no account, no quota. A staged scan also
@@ -31,7 +34,11 @@ function report(findings, scope) {
31
34
  export function runScanSecrets(paths, opts, cwd = process.cwd()) {
32
35
  let findings;
33
36
  try {
34
- findings = paths.length && !opts.staged ? scanFiles(paths) : scanStaged(cwd);
37
+ if (opts.snapshotRoot !== undefined && opts.repo === undefined)
38
+ throw new ScanInputError("--snapshot-root needs --repo");
39
+ if (opts.allowlistRev !== undefined && opts.repo === undefined)
40
+ throw new ScanInputError("--allowlist-rev needs --repo");
41
+ findings = paths.length && !opts.staged ? scanFiles(paths, { repo: opts.repo, snapshotRoot: opts.snapshotRoot, allowlistRev: opts.allowlistRev }) : scanStaged(cwd);
35
42
  }
36
43
  catch (err) {
37
44
  const msg = err instanceof ScanInputError || err instanceof ScanConfigError ? err.message : `scan failed: ${err instanceof Error ? err.message : String(err)}`;
@@ -56,6 +63,9 @@ export function registerScanCommand(program) {
56
63
  .argument("[files...]", "Scan these files instead of the staged changes")
57
64
  .option("--staged", "Scan the staged changes (default when no files are given)")
58
65
  .option("--json", "Print findings as JSON")
66
+ .option("--repo <dir>", "With files: apply this repo's committed .gitleaks.toml allowlists, matching repo-relative paths")
67
+ .option("--snapshot-root <dir>", "With --repo: files under <dir>/<commit>/<path> are matched as <path>")
68
+ .option("--allowlist-rev <rev>", "With --repo: read .gitleaks.toml from this commit instead of HEAD")
59
69
  .action((files, opts) => {
60
70
  process.exitCode = runScanSecrets(files ?? [], opts);
61
71
  });
@@ -38,6 +38,8 @@ function allowed(al, c) {
38
38
  }
39
39
  if (al.commits.length)
40
40
  checks.push(c.commit !== undefined && al.commits.includes(c.commit.toLowerCase()));
41
+ if (al.exact?.length)
42
+ checks.push(al.exact.includes(c.secret));
41
43
  if (!checks.length)
42
44
  return false;
43
45
  return al.condition === "AND" ? checks.every(Boolean) : checks.some(Boolean);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A fixed corpus of realistic credential lines, used to judge a repo
3
+ * allowlist by what it does: each line is placed in a file at the allowlist's
4
+ * paths and run through the real detector. Values are generated from a fixed
5
+ * seed at runtime, so no secret-shaped literal sits in this file and the
6
+ * corpus is the same on every run.
7
+ */
8
+ export interface Probe {
9
+ kind: string;
10
+ line: string;
11
+ }
12
+ export declare function credentialProbes(): Probe[];
13
+ //# sourceMappingURL=probes.d.ts.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A fixed corpus of realistic credential lines, used to judge a repo
3
+ * allowlist by what it does: each line is placed in a file at the allowlist's
4
+ * paths and run through the real detector. Values are generated from a fixed
5
+ * seed at runtime, so no secret-shaped literal sits in this file and the
6
+ * corpus is the same on every run.
7
+ */
8
+ const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
9
+ const LOWER = "abcdefghijklmnopqrstuvwxyz";
10
+ const DIGITS = "0123456789";
11
+ const ALNUM = UPPER + LOWER + DIGITS;
12
+ const HEX = "0123456789abcdef";
13
+ const B64URL = ALNUM + "-_";
14
+ /** Deterministic xorshift32 generator. */
15
+ function generator(seed) {
16
+ let s = seed >>> 0 || 1;
17
+ return (alphabet, n) => {
18
+ let out = "";
19
+ for (let i = 0; i < n; i++) {
20
+ s ^= s << 13;
21
+ s >>>= 0;
22
+ s ^= s >>> 17;
23
+ s ^= s << 5;
24
+ s >>>= 0;
25
+ out += alphabet[s % alphabet.length];
26
+ }
27
+ return out;
28
+ };
29
+ }
30
+ const b64url = (s) => Buffer.from(s).toString("base64url");
31
+ const j = (...parts) => parts.join("");
32
+ let cached;
33
+ export function credentialProbes() {
34
+ if (cached)
35
+ return cached;
36
+ const r = generator(0x9e3779b9);
37
+ const probes = [];
38
+ const add = (kind, line) => probes.push({ kind, line });
39
+ // GitHub tokens: classic PAT, OAuth, app and fine-grained PAT.
40
+ add("GitHub PAT", `token = "${j("gh", "p_", r(ALNUM, 36))}"`);
41
+ add("GitHub PAT", `export GH_TOKEN=${j("gh", "o_", r(ALNUM, 36))}`);
42
+ add("GitHub PAT", ` "github": "${j("gh", "s_", r(ALNUM, 36))}",`);
43
+ add("GitHub PAT", j("gith", "ub_pat_", r(ALNUM, 22), "_", r(ALNUM, 59)));
44
+ // AWS access key ids.
45
+ add("AWS key", `aws_access_key_id = ${j("AK", "IA", r(UPPER + "234567", 16))}`);
46
+ add("AWS key", `AWS_ACCESS_KEY_ID="${j("AS", "IA", r(UPPER + "234567", 16))}"`);
47
+ add("AWS key", j("AK", "IA", r(UPPER + "234567", 16)));
48
+ // Slack bot and user tokens.
49
+ add("Slack token", `SLACK_BOT_TOKEN=${j("xo", "xb-", r(DIGITS, 12), "-", r(DIGITS, 13), "-", r(ALNUM, 24))}`);
50
+ add("Slack token", `slack: "${j("xo", "xb-", r(DIGITS, 11), "-", r(DIGITS, 12), "-", r(ALNUM, 24))}"`);
51
+ add("Slack token", `token = "${j("xo", "xp-", r(DIGITS, 12), "-", r(DIGITS, 12), "-", r(DIGITS, 13), "-", r(HEX, 32))}"`);
52
+ // 64-character hex secrets under credential-looking keys.
53
+ add("hex-64", `api_secret = "${r(HEX, 64)}"`);
54
+ add("hex-64", ` "auth_token": "${r(HEX, 64)}",`);
55
+ add("hex-64", `SECRET_KEY=${r(HEX, 64)}`);
56
+ // JWTs of different lengths.
57
+ for (const n of [24, 48, 96]) {
58
+ const header = b64url('{"alg":"HS256","typ":"JWT"}');
59
+ const payload = b64url(JSON.stringify({ sub: r(DIGITS, 10), name: r(LOWER, n / 4), iat: 1700000000 }));
60
+ add("JWT", `const jwt = "${header}.${payload}.${r(B64URL, 43)}";`);
61
+ }
62
+ // Generic key = value credentials of different lengths.
63
+ add("generic api_key", `api_key = "${r(ALNUM, 20)}"`);
64
+ add("generic api_key", `client_secret: '${r(ALNUM, 32)}'`);
65
+ add("generic api_key", `ACCESS_TOKEN=${r(ALNUM, 48)}`);
66
+ cached = probes;
67
+ return probes;
68
+ }
69
+ //# sourceMappingURL=probes.js.map
@@ -18,13 +18,28 @@
18
18
  * literal prefix (`^golden/...`); `regexes` and `stopwords` need
19
19
  * `condition = "AND"`, so they never reach files outside those paths.
20
20
  *
21
+ * File scans (`hq scan secrets <files> --repo`, used by pr-check) honor only
22
+ * exact-value exemptions: each allowlist names its paths and lists, in
23
+ * `stopwords`, the exact secret values it allows (e.g. known digests). A
24
+ * detected secret is allowed only when it equals one of them. Regexes,
25
+ * targetRules, commits, regexTarget and path-only allowlists are rejected
26
+ * there, because a pattern can always be widened, alone or combined with other
27
+ * entries, to hide a real credential.
28
+ *
21
29
  * Fails closed: anything not acceptable is a ScanConfigError, and the scan
22
30
  * exits 2 rather than running with a guess.
23
31
  */
32
+ import { type Probe } from "./probes.js";
24
33
  import { type CompiledAllowlist } from "./rules.js";
25
34
  export declare class ScanConfigError extends Error {
26
35
  }
27
36
  export declare const REPO_CONFIG_FILE = ".gitleaks.toml";
37
+ /**
38
+ * The first probe credential the allowlist would hide, judged as the detector
39
+ * judges it (every regex ORed, every check combined by its condition, its
40
+ * targetRules applied), for a file at one of its paths. undefined: none.
41
+ */
42
+ export declare function suppressedProbe(al: CompiledAllowlist): Probe | undefined;
28
43
  /**
29
44
  * Length of the literal text right after a leading `^`: letters, digits,
30
45
  * `/ _ - @ ~` and backslash-escaped punctuation. A quantifier on the last
@@ -33,9 +48,18 @@ export declare const REPO_CONFIG_FILE = ".gitleaks.toml";
33
48
  export declare function literalPathPrefix(src: string): number;
34
49
  /** Validated allowlists from the text of a .gitleaks.toml. */
35
50
  export declare function parseRepoAllowlists(text: string, file?: string): CompiledAllowlist[];
51
+ /** Validated exact-value allowlists for a file scan, from the text of a .gitleaks.toml. */
52
+ export declare function parseExactAllowlists(text: string, file?: string): CompiledAllowlist[];
36
53
  /**
37
54
  * Compiled allowlists for a staged scan of the repo at `repoRoot`, read from
38
55
  * git as described at the top of this file. [] when the repo has none.
39
56
  */
40
57
  export declare function loadRepoAllowlists(repoRoot: string): CompiledAllowlist[];
58
+ /**
59
+ * Exact-value allowlists from the copy committed at `rev` (default HEAD), for a
60
+ * file scan (`hq scan secrets --repo [--allowlist-rev]`). The index and working
61
+ * tree are ignored. pr-check passes the base branch, so a branch cannot exempt
62
+ * its own commits by adding an allowlist. An unknown rev is an error.
63
+ */
64
+ export declare function loadCommittedAllowlists(repoRoot: string, rev?: string): CompiledAllowlist[];
41
65
  //# sourceMappingURL=repo-config.d.ts.map
@@ -18,13 +18,24 @@
18
18
  * literal prefix (`^golden/...`); `regexes` and `stopwords` need
19
19
  * `condition = "AND"`, so they never reach files outside those paths.
20
20
  *
21
+ * File scans (`hq scan secrets <files> --repo`, used by pr-check) honor only
22
+ * exact-value exemptions: each allowlist names its paths and lists, in
23
+ * `stopwords`, the exact secret values it allows (e.g. known digests). A
24
+ * detected secret is allowed only when it equals one of them. Regexes,
25
+ * targetRules, commits, regexTarget and path-only allowlists are rejected
26
+ * there, because a pattern can always be widened, alone or combined with other
27
+ * entries, to hide a real credential.
28
+ *
21
29
  * Fails closed: anything not acceptable is a ScanConfigError, and the scan
22
30
  * exits 2 rather than running with a guess.
23
31
  */
24
32
  import { execFileSync } from "node:child_process";
33
+ import { createHash } from "node:crypto";
25
34
  import { parse } from "smol-toml";
26
35
  import { compileGoRegex } from "./go-regex.js";
27
- import { compileAllowlist } from "./rules.js";
36
+ import { scanFragment } from "./detect.js";
37
+ import { credentialProbes } from "./probes.js";
38
+ import { compileAllowlist, compiledRules } from "./rules.js";
28
39
  export class ScanConfigError extends Error {
29
40
  }
30
41
  export const REPO_CONFIG_FILE = ".gitleaks.toml";
@@ -37,6 +48,25 @@ const MAX_REGEX_LENGTH = 256;
37
48
  const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[*+}](?:[^()\\]|\\.)*\)[*+{]/;
38
49
  /** Paths no real allowlist path should match all of. */
39
50
  const PROBE_PATHS = ["a", ".env", "src/index.ts", "config/credentials.yml", "deep/nested/dir/file.json"];
51
+ /** File the probes are placed in; the allowlist's own paths are treated as matching it. */
52
+ const PROBE_FILE = "probe/settings.txt";
53
+ let probeBaseline;
54
+ function probeFragment(line) {
55
+ return { file: PROBE_FILE, text: `${line}\n`, startLine: 1 };
56
+ }
57
+ /**
58
+ * The first probe credential the allowlist would hide, judged as the detector
59
+ * judges it (every regex ORed, every check combined by its condition, its
60
+ * targetRules applied), for a file at one of its paths. undefined: none.
61
+ */
62
+ export function suppressedProbe(al) {
63
+ const base = compiledRules();
64
+ const probes = credentialProbes();
65
+ probeBaseline ??= probes.map((p) => scanFragment(probeFragment(p.line), base).length > 0);
66
+ const atPath = { ...al, paths: al.paths.length ? [/(?:)/] : [] };
67
+ const rules = { ...base, repo: [atPath] };
68
+ return probes.find((p, i) => probeBaseline[i] && scanFragment(probeFragment(p.line), rules).length === 0);
69
+ }
40
70
  function fail(file, where, msg) {
41
71
  throw new ScanConfigError(`${file}: ${where}: ${msg}`);
42
72
  }
@@ -96,16 +126,38 @@ function topLevelAlternation(src) {
96
126
  }
97
127
  return false;
98
128
  }
99
- function compileChecked(file, where, key, src) {
129
+ /**
130
+ * How an error names an exemption value: its length and a short hash, never
131
+ * the value itself, which may be a real credential (e.g. one with a typo).
132
+ */
133
+ function redact(value) {
134
+ return `<${value.length} chars, sha256:${createHash("sha256").update(value).digest("hex").slice(0, 12)}>`;
135
+ }
136
+ /** Names a regexes entry by index and redacted value; paths are shown as written. */
137
+ function entryName(key, src, i) {
138
+ return key === "paths" ? `paths entry ${JSON.stringify(src)}` : `${key}[${i}] ${redact(src)}`;
139
+ }
140
+ function compileChecked(file, where, key, src, i = 0) {
100
141
  if (src.length > MAX_REGEX_LENGTH)
101
142
  fail(file, where, `${key} entry is longer than ${MAX_REGEX_LENGTH} characters`);
102
143
  if (NESTED_QUANTIFIER.test(src))
103
- fail(file, where, `${key} entry ${JSON.stringify(src)} has a nested quantifier; simplify it`);
144
+ fail(file, where, `${entryName(key, src, i)} has a nested quantifier; simplify it`);
104
145
  try {
105
146
  return compileGoRegex(src);
106
147
  }
107
148
  catch (err) {
108
- fail(file, where, `${key} entry ${JSON.stringify(src)} is not a supported regex (${err instanceof Error ? err.message : String(err)})`);
149
+ fail(file, where, `${entryName(key, src, i)} is not a supported regex${key === "paths" ? ` (${err instanceof Error ? err.message : String(err)})` : ""}`);
150
+ }
151
+ }
152
+ function checkPaths(file, where, paths) {
153
+ for (const src of paths) {
154
+ const re = compileChecked(file, where, "paths", src);
155
+ if (literalPathPrefix(src) < MIN_PATH_PREFIX || topLevelAlternation(src)) {
156
+ fail(file, where, `paths entry ${JSON.stringify(src)} must start with ^ and at least ${MIN_PATH_PREFIX} literal characters (e.g. '^fixtures/')`);
157
+ }
158
+ if (re.test("") || PROBE_PATHS.every((p) => re.test(p))) {
159
+ fail(file, where, `paths entry ${JSON.stringify(src)} matches everything; allowlist specific files`);
160
+ }
109
161
  }
110
162
  }
111
163
  function validate(file, where, raw) {
@@ -141,37 +193,29 @@ function validate(file, where, raw) {
141
193
  if (!al.paths?.length) {
142
194
  fail(file, where, "needs paths, each starting with ^ and a literal directory or file name (e.g. '^fixtures/')");
143
195
  }
144
- for (const src of al.paths) {
145
- const re = compileChecked(file, where, "paths", src);
146
- if (literalPathPrefix(src) < MIN_PATH_PREFIX || topLevelAlternation(src)) {
147
- fail(file, where, `paths entry ${JSON.stringify(src)} must start with ^ and at least ${MIN_PATH_PREFIX} literal characters (e.g. '^fixtures/')`);
148
- }
149
- if (re.test("") || PROBE_PATHS.every((p) => re.test(p))) {
150
- fail(file, where, `paths entry ${JSON.stringify(src)} matches everything; allowlist specific files`);
151
- }
152
- }
196
+ checkPaths(file, where, al.paths);
153
197
  if ((al.regexes?.length || al.stopwords?.length) && al.condition !== "AND") {
154
198
  fail(file, where, 'regexes and stopwords need condition = "AND", so they only apply inside paths');
155
199
  }
156
- for (const src of al.regexes ?? []) {
157
- if (compileChecked(file, where, "regexes", src).test(""))
158
- fail(file, where, `regexes entry ${JSON.stringify(src)} matches everything`);
159
- }
160
- for (const s of al.stopwords ?? []) {
200
+ al.regexes?.forEach((src, i) => compileChecked(file, where, "regexes", src, i));
201
+ al.stopwords?.forEach((s, i) => {
161
202
  if (s.trim().length < MIN_STOPWORD)
162
- fail(file, where, `stopword ${JSON.stringify(s)} is shorter than ${MIN_STOPWORD} characters`);
203
+ fail(file, where, `stopwords[${i}] ${redact(s)} is shorter than ${MIN_STOPWORD} characters`);
204
+ });
205
+ // A path-only allowlist exempts the files it names. One with content checks
206
+ // must still report a real credential in those files, so run the detector on
207
+ // the probe corpus with this allowlist, exactly as a scan would apply it.
208
+ if (al.regexes?.length || al.stopwords?.length) {
209
+ const hidden = suppressedProbe(compileAllowlist(al));
210
+ if (hidden) {
211
+ fail(file, where, `matches everything: it would hide a real ${hidden.kind} credential in its paths; narrow its regexes or stopwords to the known false positive`);
212
+ }
163
213
  }
164
214
  return al;
165
215
  }
166
216
  /** Validated allowlists from the text of a .gitleaks.toml. */
167
217
  export function parseRepoAllowlists(text, file = REPO_CONFIG_FILE) {
168
- let doc;
169
- try {
170
- doc = parse(text);
171
- }
172
- catch (err) {
173
- throw new ScanConfigError(`${file}: could not be parsed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
174
- }
218
+ const doc = parseToml(text, file);
175
219
  const out = [];
176
220
  if (doc.allowlist !== undefined)
177
221
  out.push(validate(file, "[allowlist]", doc.allowlist));
@@ -182,6 +226,75 @@ export function parseRepoAllowlists(text, file = REPO_CONFIG_FILE) {
182
226
  }
183
227
  return out.map(compileAllowlist);
184
228
  }
229
+ /** Shortest exact value a file-scan allowlist may list; real secrets and digests are longer. */
230
+ const MIN_EXACT_VALUE = 16;
231
+ const EXACT_KEYS = new Set(["description", "condition", "paths", "stopwords"]);
232
+ /**
233
+ * A file-scan allowlist: paths plus the exact secret values to allow, listed
234
+ * in `stopwords`. Anything pattern-based is rejected (see the top of the file).
235
+ */
236
+ function validateExact(file, where, raw) {
237
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
238
+ fail(file, where, "must be a table");
239
+ const t = raw;
240
+ for (const k of Object.keys(t)) {
241
+ if (!KEYS.has(k))
242
+ fail(file, where, `unknown key "${k}"`);
243
+ if (!EXACT_KEYS.has(k)) {
244
+ fail(file, where, `file scans honor only exact-value exemptions: remove "${k}" and list the exact secret values in stopwords`);
245
+ }
246
+ }
247
+ if (t.description !== undefined && typeof t.description !== "string")
248
+ fail(file, where, "description must be a string");
249
+ if (typeof t.condition !== "string" || t.condition.trim().toUpperCase() !== "AND") {
250
+ fail(file, where, 'file scans need condition = "AND", so the exact values only apply inside paths');
251
+ }
252
+ const paths = stringList(file, where, "paths", t.paths);
253
+ if (!paths?.length) {
254
+ fail(file, where, "needs paths, each starting with ^ and a literal directory or file name (e.g. '^fixtures/')");
255
+ }
256
+ checkPaths(file, where, paths);
257
+ const values = stringList(file, where, "stopwords", t.stopwords);
258
+ if (!values?.length) {
259
+ fail(file, where, "file scans honor only exact-value exemptions: list the exact secret values to allow in stopwords");
260
+ }
261
+ values.forEach((v, i) => {
262
+ if (v.length < MIN_EXACT_VALUE || /\s/.test(v)) {
263
+ fail(file, where, `stopwords[${i}] ${redact(v)} must be an exact secret value of at least ${MIN_EXACT_VALUE} characters with no spaces`);
264
+ }
265
+ });
266
+ return {
267
+ condition: "AND",
268
+ target: "secret",
269
+ paths: paths.map((p) => compileGoRegex(p)),
270
+ regexes: [],
271
+ stopwords: [],
272
+ commits: [],
273
+ targetRules: [],
274
+ exact: [...values],
275
+ };
276
+ }
277
+ /** Validated exact-value allowlists for a file scan, from the text of a .gitleaks.toml. */
278
+ export function parseExactAllowlists(text, file = REPO_CONFIG_FILE) {
279
+ const doc = parseToml(text, file);
280
+ const out = [];
281
+ if (doc.allowlist !== undefined)
282
+ out.push(validateExact(file, "[allowlist]", doc.allowlist));
283
+ if (doc.allowlists !== undefined) {
284
+ if (!Array.isArray(doc.allowlists))
285
+ fail(file, "allowlists", "must be an array of tables ([[allowlists]])");
286
+ doc.allowlists.forEach((a, i) => out.push(validateExact(file, `[[allowlists]] #${i + 1}`, a)));
287
+ }
288
+ return out;
289
+ }
290
+ function parseToml(text, file) {
291
+ try {
292
+ return parse(text);
293
+ }
294
+ catch (err) {
295
+ throw new ScanConfigError(`${file}: could not be parsed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
296
+ }
297
+ }
185
298
  function git(repoRoot, args) {
186
299
  try {
187
300
  return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
@@ -228,10 +341,27 @@ export function loadRepoAllowlists(repoRoot) {
228
341
  // Changed in this commit: scan with the version already committed at HEAD.
229
342
  return headAllowlists(repoRoot);
230
343
  }
231
- function headAllowlists(repoRoot) {
344
+ /**
345
+ * Exact-value allowlists from the copy committed at `rev` (default HEAD), for a
346
+ * file scan (`hq scan secrets --repo [--allowlist-rev]`). The index and working
347
+ * tree are ignored. pr-check passes the base branch, so a branch cannot exempt
348
+ * its own commits by adding an allowlist. An unknown rev is an error.
349
+ */
350
+ export function loadCommittedAllowlists(repoRoot, rev = "HEAD") {
351
+ if (rev === "HEAD")
352
+ return headAllowlists(repoRoot, parseExactAllowlists);
353
+ if (rev.startsWith("-") || git(repoRoot, ["rev-parse", "--verify", "-q", `${rev}^{commit}`]) === null) {
354
+ throw new ScanConfigError(`allowlist rev ${JSON.stringify(rev)} is not a commit in ${repoRoot}`);
355
+ }
356
+ return revAllowlists(repoRoot, rev, parseExactAllowlists);
357
+ }
358
+ function headAllowlists(repoRoot, parser = parseRepoAllowlists) {
232
359
  if (git(repoRoot, ["rev-parse", "--verify", "-q", "HEAD^{commit}"]) === null)
233
360
  return [];
234
- const head = entry(git(repoRoot, ["ls-tree", "HEAD", "--", REPO_CONFIG_FILE]), 2);
235
- return head ? parseRepoAllowlists(readEntry(repoRoot, head, "HEAD"), `${REPO_CONFIG_FILE} (HEAD)`) : [];
361
+ return revAllowlists(repoRoot, "HEAD", parser);
362
+ }
363
+ function revAllowlists(repoRoot, rev, parser) {
364
+ const e = entry(git(repoRoot, ["ls-tree", rev, "--", REPO_CONFIG_FILE]), 2);
365
+ return e ? parser(readEntry(repoRoot, e, rev), `${REPO_CONFIG_FILE} (${rev})`) : [];
236
366
  }
237
367
  //# sourceMappingURL=repo-config.js.map
@@ -12,6 +12,8 @@ export interface CompiledAllowlist {
12
12
  commits: string[];
13
13
  /** Empty: applies to every rule. */
14
14
  targetRules: string[];
15
+ /** Exact secret values: allows only a detected secret equal to one of them (file scans with a repo). */
16
+ exact?: string[];
15
17
  }
16
18
  export interface CompiledRule {
17
19
  id: string;
@@ -18,5 +18,19 @@ export declare function repoRoot(cwd: string): string;
18
18
  * relative to the repo root, as gitleaks' allowlist paths are.
19
19
  */
20
20
  export declare function scanStaged(cwd: string): SecretFinding[];
21
- export declare function scanFiles(paths: string[]): SecretFinding[];
21
+ export interface ScanFilesOptions {
22
+ /**
23
+ * Apply this repo's committed .gitleaks.toml allowlists. Files under the repo
24
+ * are matched by their repo-relative path, as in a staged scan.
25
+ */
26
+ repo?: string;
27
+ /**
28
+ * Files laid out as <snapshotRoot>/<commit>/<repo path> (historical copies,
29
+ * as `commit-secret-scan.sh pr-check` writes them) are matched as <repo path>.
30
+ */
31
+ snapshotRoot?: string;
32
+ /** Read the allowlists from .gitleaks.toml at this commit instead of HEAD (pr-check passes its base). */
33
+ allowlistRev?: string;
34
+ }
35
+ export declare function scanFiles(paths: string[], opts?: ScanFilesOptions): SecretFinding[];
22
36
  //# sourceMappingURL=staged.d.ts.map
@@ -2,9 +2,10 @@
2
2
  * Scan inputs: the staged diff of a git repo (pre-commit), or whole files.
3
3
  */
4
4
  import { execFileSync } from "node:child_process";
5
- import { readFileSync, statSync } from "node:fs";
5
+ import { readFileSync, realpathSync, statSync } from "node:fs";
6
+ import { isAbsolute, relative, resolve, sep } from "node:path";
6
7
  import { scanFragment, scanPath } from "./detect.js";
7
- import { loadRepoAllowlists } from "./repo-config.js";
8
+ import { loadCommittedAllowlists, loadRepoAllowlists } from "./repo-config.js";
8
9
  import { compiledRules } from "./rules.js";
9
10
  export class ScanInputError extends Error {
10
11
  }
@@ -110,7 +111,43 @@ export function scanStaged(cwd) {
110
111
  const { fragments, binaryFiles } = parseUnifiedDiff(diff);
111
112
  return [...fragments.flatMap((f) => scanFragment(f, rules)), ...binaryFiles.flatMap((f) => scanPath(f, rules))];
112
113
  }
113
- export function scanFiles(paths) {
114
+ /** `p` relative to `root`, or null when `p` is not inside `root`. */
115
+ function inside(root, p) {
116
+ const rel = relative(root, p);
117
+ if (!rel || rel.startsWith("..") || isAbsolute(rel))
118
+ return null;
119
+ return rel.split(sep).join("/");
120
+ }
121
+ function real(p) {
122
+ try {
123
+ return realpathSync(p);
124
+ }
125
+ catch {
126
+ return resolve(p);
127
+ }
128
+ }
129
+ /**
130
+ * The path allowlists see for `p`: repo-relative, or `<path>` for a snapshot at
131
+ * `<snapshotRoot>/<commit>/<path>`. null when `p` (after symlinks and `..`) is
132
+ * outside both roots: such a file gets no repo allowlist at all.
133
+ */
134
+ function matchPath(p, repo, snapshotRoot) {
135
+ const abs = real(p);
136
+ if (snapshotRoot) {
137
+ const rel = inside(snapshotRoot, abs);
138
+ if (rel !== null) {
139
+ const slash = rel.indexOf("/");
140
+ if (slash > 0)
141
+ return rel.slice(slash + 1);
142
+ }
143
+ }
144
+ return inside(repo, abs);
145
+ }
146
+ export function scanFiles(paths, opts = {}) {
147
+ const root = opts.repo !== undefined ? repoRoot(opts.repo) : undefined;
148
+ const rules = root !== undefined ? { ...compiledRules(), repo: loadCommittedAllowlists(root, opts.allowlistRev) } : compiledRules();
149
+ const repo = root !== undefined ? real(root) : undefined;
150
+ const snapshotRoot = opts.snapshotRoot !== undefined ? real(opts.snapshotRoot) : undefined;
114
151
  const out = [];
115
152
  for (const p of paths) {
116
153
  let text;
@@ -122,7 +159,12 @@ export function scanFiles(paths) {
122
159
  catch (err) {
123
160
  throw new ScanInputError(`could not read ${p}: ${err instanceof Error ? err.message : String(err)}`);
124
161
  }
125
- out.push(...scanPath(p), ...scanFragment({ file: p, text, startLine: 1 }));
162
+ const mapped = repo !== undefined ? matchPath(p, repo, snapshotRoot) : p;
163
+ const file = mapped ?? p;
164
+ const use = mapped === null ? compiledRules() : rules;
165
+ // Findings still name the file as given, so a history copy keeps its commit folder.
166
+ const found = [...scanPath(file, use), ...scanFragment({ file, text, startLine: 1 }, use)];
167
+ out.push(...found.map((f) => ({ ...f, file: p })));
126
168
  }
127
169
  return out;
128
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.164",
3
+ "version": "5.345.165",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -114,11 +114,11 @@
114
114
  "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
115
115
  },
116
116
  "optionalDependencies": {
117
- "@indigoai-us/hq-hook-win32-x64": "5.345.164",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.164",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.164",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.164",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.164",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.164"
117
+ "@indigoai-us/hq-hook-win32-x64": "5.345.165",
118
+ "@indigoai-us/hq-hook-win32-arm64": "5.345.165",
119
+ "@indigoai-us/hq-hook-darwin-x64": "5.345.165",
120
+ "@indigoai-us/hq-hook-darwin-arm64": "5.345.165",
121
+ "@indigoai-us/hq-hook-linux-x64": "5.345.165",
122
+ "@indigoai-us/hq-hook-linux-arm64": "5.345.165"
123
123
  }
124
124
  }