@indigoai-us/hq-cli 5.345.170 → 5.345.171

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 (111) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
  3. package/dist/command-catalog.generated.d.ts +132 -5
  4. package/dist/command-catalog.generated.js +164 -6
  5. package/dist/commands/access.d.ts +32 -1
  6. package/dist/commands/access.js +112 -3
  7. package/dist/commands/agent-kit.d.ts +1 -1
  8. package/dist/commands/agent-kit.js +3 -1
  9. package/dist/commands/agents.js +2 -1
  10. package/dist/commands/bot.d.ts +3 -0
  11. package/dist/commands/bot.js +137 -14
  12. package/dist/commands/cloud.d.ts +58 -1
  13. package/dist/commands/cloud.js +219 -5
  14. package/dist/commands/core-company-index-scope-flag.d.ts +8 -1
  15. package/dist/commands/core-company-index-scope-flag.js +9 -2
  16. package/dist/commands/core-delegate-w1b.js +5 -2
  17. package/dist/commands/core-dm-bind-native.js +19 -4
  18. package/dist/commands/core-rebuild-index-fast.js +24 -6
  19. package/dist/commands/core.js +18 -6
  20. package/dist/commands/dm.d.ts +17 -3
  21. package/dist/commands/dm.js +121 -26
  22. package/dist/commands/integrations-api.d.ts +4 -0
  23. package/dist/commands/integrations-api.js +18 -2
  24. package/dist/commands/integrations-connect.d.ts +10 -3
  25. package/dist/commands/integrations-connect.js +70 -6
  26. package/dist/commands/integrations-core.d.ts +23 -0
  27. package/dist/commands/integrations-core.js +100 -0
  28. package/dist/commands/integrations-manage.js +8 -1
  29. package/dist/commands/integrations.js +49 -5
  30. package/dist/commands/lanes-loop.d.ts +20 -0
  31. package/dist/commands/lanes-loop.js +69 -11
  32. package/dist/commands/lanes.js +9 -1
  33. package/dist/commands/rescue.d.ts +9 -0
  34. package/dist/commands/rescue.js +31 -0
  35. package/dist/commands/run.js +115 -40
  36. package/dist/commands/scan.d.ts +5 -1
  37. package/dist/commands/scan.js +16 -4
  38. package/dist/commands/secrets.js +50 -9
  39. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2367 -2109
  40. package/dist/hq-cloud-sync-runner/package.json +1 -1
  41. package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
  42. package/dist/lib/agent-kit/inbox-state.js +41 -20
  43. package/dist/lib/agent-kit/kit-config.d.ts +2 -0
  44. package/dist/lib/agent-kit/kit-config.js +6 -0
  45. package/dist/lib/agent-kit/mcp/tools.js +12 -5
  46. package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
  47. package/dist/lib/agent-kit/run/heartbeat.js +6 -3
  48. package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
  49. package/dist/lib/agent-kit/run/inbox.js +42 -10
  50. package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
  51. package/dist/lib/agent-kit/run/request-timeout.js +51 -0
  52. package/dist/lib/bot/api.d.ts +1 -1
  53. package/dist/lib/bot/config.d.ts +10 -0
  54. package/dist/lib/bot/config.js +17 -1
  55. package/dist/lib/bot/fleet-local.d.ts +127 -0
  56. package/dist/lib/bot/fleet-local.js +638 -0
  57. package/dist/lib/bot/index.d.ts +1 -0
  58. package/dist/lib/bot/index.js +1 -0
  59. package/dist/lib/bot/local-config.d.ts +3 -2
  60. package/dist/lib/bot/local-config.js +5 -2
  61. package/dist/lib/bot/promote.d.ts +10 -0
  62. package/dist/lib/bot/promote.js +3 -2
  63. package/dist/lib/bot/runtime/index.js +8 -4
  64. package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
  65. package/dist/lib/bot/runtime/scratch-dir.js +41 -0
  66. package/dist/lib/caller-principal.d.ts +4 -0
  67. package/dist/lib/caller-principal.js +40 -0
  68. package/dist/lib/capacity/collectors/grok.js +8 -2
  69. package/dist/lib/cli-flag-keys.d.ts +2 -0
  70. package/dist/lib/cli-flag-keys.js +2 -0
  71. package/dist/lib/daemon/catalog.js +4 -1
  72. package/dist/lib/daemon/sync-core-canary.d.ts +3 -0
  73. package/dist/lib/daemon/sync-core-canary.js +8 -3
  74. package/dist/lib/doctor/checks/release-links.d.ts +62 -1
  75. package/dist/lib/doctor/checks/release-links.js +188 -2
  76. package/dist/lib/doctor/fix/apply.js +54 -0
  77. package/dist/lib/doctor/fix/remediation.d.ts +8 -1
  78. package/dist/lib/doctor/fix/remediation.js +22 -0
  79. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  80. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  81. package/dist/lib/index-render/index.d.ts +9 -0
  82. package/dist/lib/index-render/index.js +65 -5
  83. package/dist/lib/index-render/session-scope.d.ts +69 -0
  84. package/dist/lib/index-render/session-scope.js +279 -0
  85. package/dist/lib/lanes/admission.js +7 -20
  86. package/dist/lib/lanes/reconcile.js +13 -15
  87. package/dist/lib/lanes/stale-lock.d.ts +50 -0
  88. package/dist/lib/lanes/stale-lock.js +105 -0
  89. package/dist/lib/lanes/store.js +11 -76
  90. package/dist/lib/notify-recipient.d.ts +10 -4
  91. package/dist/lib/notify-recipient.js +16 -4
  92. package/dist/lib/secret-input-link.d.ts +42 -0
  93. package/dist/lib/secret-input-link.js +93 -0
  94. package/dist/lib/secret-scan/range.d.ts +57 -0
  95. package/dist/lib/secret-scan/range.js +266 -0
  96. package/dist/lib/secret-scan/repo-config.d.ts +18 -3
  97. package/dist/lib/secret-scan/repo-config.js +135 -2
  98. package/dist/run/script-bound-schema.d.ts +27 -0
  99. package/dist/run/script-bound-schema.js +97 -0
  100. package/dist/secrets-env-action.d.ts +2 -0
  101. package/dist/secrets-env-action.js +7 -2
  102. package/dist/utils/cognito-session.d.ts +3 -30
  103. package/dist/utils/cognito-session.js +2 -100
  104. package/dist/utils/machine-principal-state.d.ts +38 -0
  105. package/dist/utils/machine-principal-state.js +110 -0
  106. package/dist/utils/safe-link.js +9 -0
  107. package/dist/utils/script-bound-exec.d.ts +158 -0
  108. package/dist/utils/script-bound-exec.js +568 -0
  109. package/dist/utils/sync-watchdog.d.ts +92 -0
  110. package/dist/utils/sync-watchdog.js +250 -0
  111. package/package.json +8 -8
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Range scan: the lines a branch added between <base> and <head>, as
3
+ * `commit-secret-scan.sh pr-check` needs them.
4
+ *
5
+ * What is scanned:
6
+ * - The added lines of every non-merge commit in base..head, each against
7
+ * its own parent. A secret committed in one commit and deleted in a later
8
+ * one is still reported: it stays in the branch history.
9
+ * - For every merge commit in base..head, the lines that differ from all of
10
+ * its parents (git's combined diff, "++" lines): content the merge itself
11
+ * introduced, such as a conflict resolution. Lines a merge only carries
12
+ * over from one parent are either base content or scanned in that
13
+ * parent's own commits.
14
+ * - The added lines of the net diff merge-base(base, head)..head.
15
+ * - Binary files those diffs add or change: path rules, plus their bytes
16
+ * scanned as text up to MAX_BINARY_BYTES (larger ones are warned about).
17
+ * Lines that already existed on the base, and earlier versions of lines the
18
+ * branch did not touch, are not scanned.
19
+ *
20
+ * Config comes from the repo's .gitleaks.toml:
21
+ * - Allowlists: the exact-value exemptions committed on the base (the same
22
+ * contract as `scan secrets --repo --allowlist-rev`). A branch cannot
23
+ * exempt its own commits by adding an allowlist.
24
+ * - Rules: [[rules]] committed on the base, added to the built-in rules.
25
+ * Rules the branch adds or changes are not compiled or run: a branch-added
26
+ * regex could be written to backtrack for minutes (ReDoS), and the base
27
+ * copy has been reviewed.
28
+ * A rejected allowlist or rules section is warned about and dropped; the scan
29
+ * still runs with the built-in rules (stricter, never looser).
30
+ *
31
+ * Range errors: an unknown rev, a reversed range (head behind base) and a
32
+ * merge-base failure other than "no common history" exit 2. base == head is an
33
+ * empty range: a warning and no findings.
34
+ */
35
+ import { execFileSync, spawnSync } from "node:child_process";
36
+ import { scanFragment, scanPath } from "./detect.js";
37
+ import { InvalidAllowlistError, InvalidRepoRulesError, ScanConfigError, loadCommittedAllowlists, loadCommittedRules } from "./repo-config.js";
38
+ import { compiledRules } from "./rules.js";
39
+ import { ALLOWLIST_IGNORED_WARNING, parseUnifiedDiff, repoRoot } from "./staged.js";
40
+ export const RULES_IGNORED_WARNING = "HQ secret scan: warning: the repo .gitleaks.toml [[rules]] were rejected; scanning with the built-in rules only";
41
+ export const EMPTY_RANGE_WARNING = "HQ secret scan: warning: the range is empty (base and head are the same commit); nothing to scan";
42
+ /** Binary blobs larger than this are not read; the scan warns instead. */
43
+ export const MAX_BINARY_BYTES = 10 * 1024 * 1024;
44
+ const PATCH_ARGS = [
45
+ "--no-color",
46
+ "--no-ext-diff",
47
+ "--no-textconv",
48
+ "--unified=0",
49
+ "--diff-filter=ACMR",
50
+ "--src-prefix=a/",
51
+ "--dst-prefix=b/",
52
+ ];
53
+ function git(root, args) {
54
+ return execFileSync("git", ["-c", "core.quotePath=false", ...args], {
55
+ cwd: root,
56
+ encoding: "utf8",
57
+ maxBuffer: 1024 * 1024 * 1024,
58
+ stdio: ["ignore", "pipe", "pipe"],
59
+ });
60
+ }
61
+ function commit(root, rev, label) {
62
+ if (!rev || rev.startsWith("-"))
63
+ throw new ScanConfigError(`${label} ${JSON.stringify(rev)} is not a commit`);
64
+ try {
65
+ return git(root, ["rev-parse", "--verify", "-q", `${rev}^{commit}`]).trim();
66
+ }
67
+ catch {
68
+ throw new ScanConfigError(`${label} ${JSON.stringify(rev)} is not a commit in ${root}`);
69
+ }
70
+ }
71
+ /** `<base>..<head>` or `<base>` (head = HEAD). A three-dot range is rejected: the net diff is always taken from the merge base. */
72
+ export function parseRange(range) {
73
+ if (range.includes("..."))
74
+ throw new ScanConfigError(`--range takes <base>..<head> or <base>, not ${JSON.stringify(range)}`);
75
+ const i = range.indexOf("..");
76
+ if (i < 0)
77
+ return { base: range, head: "HEAD" };
78
+ return { base: range.slice(0, i), head: range.slice(i + 2) || "HEAD" };
79
+ }
80
+ function warnAndDrop(load, warning, kind) {
81
+ try {
82
+ return load();
83
+ }
84
+ catch (err) {
85
+ if (!(err instanceof kind))
86
+ throw err;
87
+ process.stderr.write(`${warning}\n`);
88
+ return [];
89
+ }
90
+ }
91
+ function rangeRules(root, base) {
92
+ const builtIn = compiledRules();
93
+ const repo = warnAndDrop(() => loadCommittedAllowlists(root, base), ALLOWLIST_IGNORED_WARNING, InvalidAllowlistError);
94
+ const extra = warnAndDrop(() => loadCommittedRules(root, base), RULES_IGNORED_WARNING, InvalidRepoRulesError);
95
+ return { ...builtIn, rules: [...builtIn.rules, ...extra], repo };
96
+ }
97
+ /** merge-base, "" when the two have no common history; any other git failure is an error. */
98
+ function mergeBase(root, base, head) {
99
+ const r = spawnSync("git", ["merge-base", base, head], { cwd: root, encoding: "utf8" });
100
+ if (r.status === 0)
101
+ return r.stdout.trim();
102
+ if (r.status === 1 && !r.stdout.trim() && !r.stderr.trim())
103
+ return "";
104
+ throw new ScanConfigError(`git merge-base failed: ${(r.stderr || String(r.error ?? "")).trim() || `exit ${r.status}`}`);
105
+ }
106
+ /**
107
+ * Lines a merge introduced, from `git diff-tree --cc` output: a line counts
108
+ * only when every parent column is "+", i.e. it is in none of the parents.
109
+ */
110
+ export function parseCombinedDiff(diff) {
111
+ const fragments = [];
112
+ let file = null;
113
+ let parents = 0;
114
+ let nextLine = 0;
115
+ let cur = null;
116
+ const flush = () => {
117
+ if (file && cur && cur.lines.length)
118
+ fragments.push({ file, text: cur.lines.join("\n"), startLine: cur.startLine });
119
+ cur = null;
120
+ };
121
+ for (const raw of diff.split("\n")) {
122
+ if (raw.startsWith("diff --cc ") || raw.startsWith("diff --combined ")) {
123
+ flush();
124
+ file = raw.replace(/^diff --(?:cc|combined) /, "");
125
+ parents = 0;
126
+ continue;
127
+ }
128
+ if (parents === 0 && raw.startsWith("+++ ")) {
129
+ const p = raw.slice(4);
130
+ file = p === "/dev/null" ? null : p.replace(/^b\//, "");
131
+ continue;
132
+ }
133
+ const h = /^(@{3,}) .*?\+(\d+)(?:,\d+)? \1/.exec(raw);
134
+ if (h) {
135
+ flush();
136
+ parents = h[1].length - 1;
137
+ nextLine = Number(h[2]);
138
+ continue;
139
+ }
140
+ if (parents === 0)
141
+ continue;
142
+ const cols = raw.slice(0, parents);
143
+ if (cols.length < parents) {
144
+ flush();
145
+ continue;
146
+ }
147
+ if (cols.includes("-")) {
148
+ flush();
149
+ continue;
150
+ } // a line removed from some parent: not in the result
151
+ if (/^\++$/.test(cols)) {
152
+ if (!cur)
153
+ cur = { lines: [], startLine: nextLine };
154
+ cur.lines.push(raw.slice(parents));
155
+ }
156
+ else {
157
+ flush();
158
+ }
159
+ nextLine++;
160
+ }
161
+ flush();
162
+ return fragments;
163
+ }
164
+ /**
165
+ * Binary files a merge commit changed relative to every parent. The combined
166
+ * diff only says "Binary files differ" for them, so their content is read
167
+ * from the merge's tree instead. Deleted files are left out.
168
+ */
169
+ function mergeBinaryFiles(root, merge) {
170
+ const files = git(root, ["diff-tree", "-c", "--name-only", "--no-commit-id", "-r", merge]).split("\n").filter(Boolean);
171
+ return files.filter((file) => {
172
+ try {
173
+ // git's own binary test: a NUL byte in the first 8000 bytes of the blob.
174
+ const head = execFileSync("git", ["cat-file", "blob", `${merge}:${file}`], {
175
+ cwd: root, maxBuffer: MAX_BINARY_BYTES + 1024, stdio: ["ignore", "pipe", "ignore"],
176
+ }).subarray(0, 8000);
177
+ return head.includes(0);
178
+ }
179
+ catch (err) {
180
+ // Too large to read here: still a binary candidate, so binaryText warns about it.
181
+ if (err.code === "ENOBUFS")
182
+ return true;
183
+ return false; // deleted in the merge, or a submodule
184
+ }
185
+ });
186
+ }
187
+ function patches(root, base, head) {
188
+ const out = [];
189
+ const mb = mergeBase(root, base, head);
190
+ // No common history: everything on head is new.
191
+ const net = mb ? git(root, ["diff", ...PATCH_ARGS, mb, head]) : git(root, ["show", "--format=", ...PATCH_ARGS, "--root", head]);
192
+ out.push({ rev: head, ...parseUnifiedDiff(net) });
193
+ const commits = git(root, ["rev-list", "--reverse", "--parents", `${base}..${head}`]).split("\n").filter(Boolean);
194
+ for (const line of commits) {
195
+ const [c, ...parents] = line.split(" ");
196
+ if (parents.length > 1) {
197
+ const cc = git(root, ["diff-tree", "--cc", "-p", "--no-commit-id", "--no-color", "--no-ext-diff", "--no-textconv", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/", c]);
198
+ out.push({ commit: c, rev: c, fragments: parseCombinedDiff(cc), binaryFiles: mergeBinaryFiles(root, c) });
199
+ }
200
+ else {
201
+ out.push({ commit: c, rev: c, ...parseUnifiedDiff(git(root, ["diff-tree", "-p", "--root", "--no-commit-id", ...PATCH_ARGS, c])) });
202
+ }
203
+ }
204
+ return out;
205
+ }
206
+ /** A binary blob's bytes as text (latin1 keeps every byte), or null when it is too large to read. */
207
+ function binaryText(root, rev, file) {
208
+ let size;
209
+ try {
210
+ size = Number(git(root, ["cat-file", "-s", `${rev}:${file}`]).trim());
211
+ }
212
+ catch {
213
+ return "";
214
+ }
215
+ if (!(size <= MAX_BINARY_BYTES))
216
+ return null;
217
+ const buf = execFileSync("git", ["cat-file", "blob", `${rev}:${file}`], { cwd: root, maxBuffer: MAX_BINARY_BYTES + 1024, stdio: ["ignore", "pipe", "pipe"] });
218
+ return buf.toString("latin1");
219
+ }
220
+ /**
221
+ * Findings for the lines added on <head> since <base>. A finding from a
222
+ * commit that the net diff no longer shows is named `<file>@<commit12>`, so
223
+ * the reader can find the version that held it.
224
+ */
225
+ export function scanRange(cwd, range) {
226
+ const root = repoRoot(cwd);
227
+ const parsed = parseRange(range);
228
+ const base = commit(root, parsed.base, "range base");
229
+ const head = commit(root, parsed.head, "range head");
230
+ if (base === head) {
231
+ process.stderr.write(`${EMPTY_RANGE_WARNING}\n`);
232
+ return [];
233
+ }
234
+ if (!git(root, ["rev-list", "-n", "1", `${base}..${head}`]).trim()) {
235
+ throw new ScanConfigError(`range ${JSON.stringify(range)} has no commits: head is behind base (reversed range?)`);
236
+ }
237
+ const rules = rangeRules(root, base);
238
+ const out = [];
239
+ const seen = new Set();
240
+ const tooLarge = new Set();
241
+ for (const patch of patches(root, base, head)) {
242
+ const found = [...patch.fragments.flatMap((f) => scanFragment(f, rules))];
243
+ for (const file of patch.binaryFiles) {
244
+ found.push(...scanPath(file, rules));
245
+ const text = binaryText(root, patch.rev, file);
246
+ if (text === null)
247
+ tooLarge.add(file);
248
+ else if (text)
249
+ found.push(...scanFragment({ file, text, startLine: 1 }, rules));
250
+ }
251
+ for (const f of found) {
252
+ // The same rule on the same file at the same line is one report. A
253
+ // repeat never hides the only finding, so a dirty range always fails.
254
+ const key = `${f.ruleId}\u0000${f.file}\u0000${f.line}`;
255
+ if (seen.has(key))
256
+ continue;
257
+ seen.add(key);
258
+ out.push(patch.commit ? { ...f, file: `${f.file}@${patch.commit.slice(0, 12)}` } : f);
259
+ }
260
+ }
261
+ for (const file of tooLarge) {
262
+ process.stderr.write(`HQ secret scan: warning: binary ${file} is larger than ${MAX_BINARY_BYTES} bytes; its content was not scanned\n`);
263
+ }
264
+ return out;
265
+ }
266
+ //# sourceMappingURL=range.js.map
@@ -3,8 +3,10 @@
3
3
  * the scanned repo's own `.gitleaks.toml`, with gitleaks semantics for
4
4
  * `paths`, `regexes`, `regexTarget`, `stopwords`, `commits`, `targetRules` and
5
5
  * `condition`. They apply on top of the built-in rules and allowlists, to that
6
- * repo only. Repo `[[rules]]`, `[extend]` and the rest of the file are ignored:
7
- * a repo can allow known false positives, not change what is detected.
6
+ * repo only. `[extend]` and the rest of the file are ignored: a repo can allow
7
+ * known false positives, not switch built-in detection off. Repo `[[rules]]`
8
+ * are used by range scans only (`--range`, pr-check), and only add detection
9
+ * on top of the built-in rules (parseRepoRules below).
8
10
  *
9
11
  * The file is read from git, never the working tree:
10
12
  * - The staged (index) copy applies only when it is identical to the copy
@@ -33,7 +35,7 @@
33
35
  * scan exits 2.
34
36
  */
35
37
  import { type Probe } from "./probes.js";
36
- import { type CompiledAllowlist } from "./rules.js";
38
+ import { type CompiledAllowlist, type CompiledRule } from "./rules.js";
37
39
  export declare class ScanConfigError extends Error {
38
40
  }
39
41
  /** The repo's .gitleaks.toml cannot be used; the scan ignores it (see the top of the file). */
@@ -68,4 +70,17 @@ export declare function loadRepoAllowlists(repoRoot: string): CompiledAllowlist[
68
70
  * its own commits by adding an allowlist. An unknown rev is an error.
69
71
  */
70
72
  export declare function loadCommittedAllowlists(repoRoot: string, rev?: string): CompiledAllowlist[];
73
+ /** The repo's [[rules]] cannot be used; a range scan warns and keeps the built-in rules only. */
74
+ export declare class InvalidRepoRulesError extends ScanConfigError {
75
+ }
76
+ /**
77
+ * Extra detection rules from the `[[rules]]` of a .gitleaks.toml. They are
78
+ * added to the built-in rules and can only report more: a repo rule cannot
79
+ * replace or disable a built-in one (a built-in id is refused, `[extend]` and
80
+ * `useDefault` are ignored), and a rule's own allowlists apply to that rule
81
+ * only. [] when the file has no rules.
82
+ */
83
+ export declare function parseRepoRules(text: string, file?: string): CompiledRule[];
84
+ /** Repo rules from the copy of .gitleaks.toml committed at `rev`. [] when there is none. */
85
+ export declare function loadCommittedRules(repoRoot: string, rev: string): CompiledRule[];
71
86
  //# sourceMappingURL=repo-config.d.ts.map
@@ -3,8 +3,10 @@
3
3
  * the scanned repo's own `.gitleaks.toml`, with gitleaks semantics for
4
4
  * `paths`, `regexes`, `regexTarget`, `stopwords`, `commits`, `targetRules` and
5
5
  * `condition`. They apply on top of the built-in rules and allowlists, to that
6
- * repo only. Repo `[[rules]]`, `[extend]` and the rest of the file are ignored:
7
- * a repo can allow known false positives, not change what is detected.
6
+ * repo only. `[extend]` and the rest of the file are ignored: a repo can allow
7
+ * known false positives, not switch built-in detection off. Repo `[[rules]]`
8
+ * are used by range scans only (`--range`, pr-check), and only add detection
9
+ * on top of the built-in rules (parseRepoRules below).
8
10
  *
9
11
  * The file is read from git, never the working tree:
10
12
  * - The staged (index) copy applies only when it is identical to the copy
@@ -361,6 +363,137 @@ export function loadCommittedAllowlists(repoRoot, rev = "HEAD") {
361
363
  }
362
364
  return revAllowlists(repoRoot, rev, parseExactAllowlists);
363
365
  }
366
+ /** The repo's [[rules]] cannot be used; a range scan warns and keeps the built-in rules only. */
367
+ export class InvalidRepoRulesError extends ScanConfigError {
368
+ }
369
+ const RULE_KEYS = new Set(["id", "description", "regex", "path", "entropy", "secretGroup", "keywords", "allowlists", "tags"]);
370
+ const RULE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
371
+ /** Detection regexes are longer than allowlist ones; gitleaks' own rules run to a few hundred characters. */
372
+ const MAX_RULE_REGEX_LENGTH = 1024;
373
+ function failRules(file, where, msg) {
374
+ throw new InvalidRepoRulesError(`${file}: ${where}: ${msg}`);
375
+ }
376
+ function ruleRegex(file, where, key, src, flags = "") {
377
+ if (typeof src !== "string" || !src)
378
+ failRules(file, where, `${key} must be a non-empty string`);
379
+ if (src.length > MAX_RULE_REGEX_LENGTH)
380
+ failRules(file, where, `${key} is longer than ${MAX_RULE_REGEX_LENGTH} characters`);
381
+ if (NESTED_QUANTIFIER.test(src))
382
+ failRules(file, where, `${key} has a nested quantifier; simplify it`);
383
+ try {
384
+ return compileGoRegex(src, flags);
385
+ }
386
+ catch {
387
+ failRules(file, where, `${key} is not a supported regex`);
388
+ }
389
+ }
390
+ function ruleAllowlist(file, where, raw) {
391
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
392
+ failRules(file, where, "must be a table");
393
+ const t = raw;
394
+ for (const k of Object.keys(t))
395
+ if (!KEYS.has(k) || k === "targetRules")
396
+ failRules(file, where, `unknown key "${k}"`);
397
+ const list = (key) => {
398
+ const v = t[key];
399
+ if (v === undefined)
400
+ return undefined;
401
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string"))
402
+ failRules(file, where, `${key} must be a list of strings`);
403
+ return v;
404
+ };
405
+ const regexes = list("regexes");
406
+ const paths = list("paths");
407
+ regexes?.forEach((src, i) => ruleRegex(file, where, `regexes[${i}]`, src));
408
+ paths?.forEach((src, i) => ruleRegex(file, where, `paths[${i}]`, src));
409
+ const condition = typeof t.condition === "string" && t.condition.trim().toUpperCase() === "AND" ? "AND" : "OR";
410
+ const target = t.regexTarget;
411
+ if (target !== undefined && target !== "secret" && target !== "match" && target !== "line") {
412
+ failRules(file, where, 'regexTarget must be "secret", "match" or "line"');
413
+ }
414
+ return compileAllowlist({
415
+ condition,
416
+ regexTarget: target,
417
+ paths,
418
+ regexes,
419
+ stopwords: list("stopwords"),
420
+ commits: list("commits"),
421
+ });
422
+ }
423
+ /**
424
+ * Extra detection rules from the `[[rules]]` of a .gitleaks.toml. They are
425
+ * added to the built-in rules and can only report more: a repo rule cannot
426
+ * replace or disable a built-in one (a built-in id is refused, `[extend]` and
427
+ * `useDefault` are ignored), and a rule's own allowlists apply to that rule
428
+ * only. [] when the file has no rules.
429
+ */
430
+ export function parseRepoRules(text, file = REPO_CONFIG_FILE) {
431
+ let doc;
432
+ try {
433
+ doc = parse(text);
434
+ }
435
+ catch (err) {
436
+ throw new InvalidRepoRulesError(`${file}: could not be parsed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
437
+ }
438
+ if (doc.rules === undefined)
439
+ return [];
440
+ if (!Array.isArray(doc.rules))
441
+ failRules(file, "rules", "must be an array of tables ([[rules]])");
442
+ const builtIn = new Set(compiledRules().rules.map((r) => r.id));
443
+ const seen = new Set();
444
+ return doc.rules.map((raw, i) => {
445
+ const where = `[[rules]] #${i + 1}`;
446
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
447
+ failRules(file, where, "must be a table");
448
+ const t = raw;
449
+ for (const k of Object.keys(t))
450
+ if (!RULE_KEYS.has(k))
451
+ failRules(file, where, `unknown key "${k}"`);
452
+ if (typeof t.id !== "string" || !RULE_ID.test(t.id))
453
+ failRules(file, where, "id must be 1-64 letters, digits, dots, dashes or underscores");
454
+ if (builtIn.has(t.id))
455
+ failRules(file, where, `id "${t.id}" is a built-in rule; a repo rule can add detection, not replace it`);
456
+ if (seen.has(t.id))
457
+ failRules(file, where, `id "${t.id}" is used twice`);
458
+ seen.add(t.id);
459
+ if (t.regex === undefined && t.path === undefined)
460
+ failRules(file, where, "needs regex or path");
461
+ if (t.description !== undefined && typeof t.description !== "string")
462
+ failRules(file, where, "description must be a string");
463
+ if (t.entropy !== undefined && (typeof t.entropy !== "number" || !(t.entropy >= 0)))
464
+ failRules(file, where, "entropy must be a number >= 0");
465
+ if (t.secretGroup !== undefined && (!Number.isInteger(t.secretGroup) || t.secretGroup < 0)) {
466
+ failRules(file, where, "secretGroup must be an integer >= 0");
467
+ }
468
+ if (t.keywords !== undefined && (!Array.isArray(t.keywords) || t.keywords.some((k) => typeof k !== "string" || !k))) {
469
+ failRules(file, where, "keywords must be a list of non-empty strings");
470
+ }
471
+ if (t.allowlists !== undefined && !Array.isArray(t.allowlists))
472
+ failRules(file, where, "allowlists must be an array of tables");
473
+ return {
474
+ id: t.id,
475
+ description: typeof t.description === "string" && t.description ? t.description : `Repo rule ${t.id}`,
476
+ regex: t.regex === undefined ? undefined : ruleRegex(file, where, "regex", t.regex, "g"),
477
+ path: t.path === undefined ? undefined : ruleRegex(file, where, "path", t.path),
478
+ entropy: t.entropy,
479
+ secretGroup: t.secretGroup,
480
+ keywords: (t.keywords ?? []).map((k) => k.toLowerCase()),
481
+ allowlists: (t.allowlists ?? []).map((a, j) => ruleAllowlist(file, `${where} allowlists #${j + 1}`, a)),
482
+ };
483
+ });
484
+ }
485
+ /** Repo rules from the copy of .gitleaks.toml committed at `rev`. [] when there is none. */
486
+ export function loadCommittedRules(repoRoot, rev) {
487
+ const e = entry(git(repoRoot, ["ls-tree", rev, "--", REPO_CONFIG_FILE]), 2);
488
+ if (!e)
489
+ return [];
490
+ if (e.mode === "120000")
491
+ throw new InvalidRepoRulesError(`${REPO_CONFIG_FILE} (${rev}) is a symlink; commit a regular file`);
492
+ const text = git(repoRoot, ["cat-file", "blob", e.blob]);
493
+ if (text === null)
494
+ throw new InvalidRepoRulesError(`${REPO_CONFIG_FILE} (${rev}) could not be read from git`);
495
+ return parseRepoRules(text, `${REPO_CONFIG_FILE} (${rev})`);
496
+ }
364
497
  function headAllowlists(repoRoot, parser = parseRepoAllowlists) {
365
498
  if (git(repoRoot, ["rev-parse", "--verify", "-q", "HEAD^{commit}"]) === null)
366
499
  return [];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Strict env-schema reader for `hq run --script` (feedback_0e6abcb8).
3
+ *
4
+ * With --script, `hq run` must not load the caller's schema through varlock:
5
+ * a varlock graph can run `exec()` resolvers, load plugins, or import other
6
+ * files, and any of that runs inside the CLI process while the fetched
7
+ * secrets are in memory. A caller could write `LEAK=exec(...)` next to a
8
+ * script-locked `hq()` item and read the secret before the script binding is
9
+ * checked.
10
+ *
11
+ * So the --script path reads the schema and local files with this parser and
12
+ * never builds a varlock graph. Only these lines are accepted (allowlist):
13
+ * - blank lines and `#` comments. A comment may carry only the decorators
14
+ * listed in ALLOWED_DECORATORS; any other `@decorator` is refused.
15
+ * - `KEY=hq()` or `KEY=hq("NAME")` / `KEY=hq('NAME')`: an HQ secret.
16
+ * - `KEY=literal`, optionally quoted, with no `(`, `)`, `$`, `{`, `}` or
17
+ * backtick: a static value. An empty value is allowed.
18
+ * Everything else is refused with an error before any secret is fetched.
19
+ * Later files override earlier ones, as in the varlock path.
20
+ */
21
+ export interface StrictSchema {
22
+ statics: Record<string, string>;
23
+ /** Env key -> vault secret name. */
24
+ secrets: Map<string, string>;
25
+ }
26
+ export declare function parseStrictSchemaFiles(paths: string[]): StrictSchema;
27
+ //# sourceMappingURL=script-bound-schema.d.ts.map
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Strict env-schema reader for `hq run --script` (feedback_0e6abcb8).
3
+ *
4
+ * With --script, `hq run` must not load the caller's schema through varlock:
5
+ * a varlock graph can run `exec()` resolvers, load plugins, or import other
6
+ * files, and any of that runs inside the CLI process while the fetched
7
+ * secrets are in memory. A caller could write `LEAK=exec(...)` next to a
8
+ * script-locked `hq()` item and read the secret before the script binding is
9
+ * checked.
10
+ *
11
+ * So the --script path reads the schema and local files with this parser and
12
+ * never builds a varlock graph. Only these lines are accepted (allowlist):
13
+ * - blank lines and `#` comments. A comment may carry only the decorators
14
+ * listed in ALLOWED_DECORATORS; any other `@decorator` is refused.
15
+ * - `KEY=hq()` or `KEY=hq("NAME")` / `KEY=hq('NAME')`: an HQ secret.
16
+ * - `KEY=literal`, optionally quoted, with no `(`, `)`, `$`, `{`, `}` or
17
+ * backtick: a static value. An empty value is allowed.
18
+ * Everything else is refused with an error before any secret is fetched.
19
+ * Later files override earlier ones, as in the varlock path.
20
+ */
21
+ import * as fs from 'node:fs';
22
+ const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
23
+ const SECRET_NAME = /^[A-Z][A-Z0-9_]*(\/[A-Z][A-Z0-9_]+)*$/;
24
+ const HQ_CALL = /^hq\(\s*(?:(["'])([^"']*)\1)?\s*\)$/;
25
+ const UNSAFE_LITERAL = /[()${}`]/;
26
+ const ALLOWED_DECORATORS = new Set([
27
+ 'hqCompany',
28
+ 'required',
29
+ 'optional',
30
+ 'sensitive',
31
+ 'public',
32
+ 'defaultRequired',
33
+ 'defaultSensitive',
34
+ ]);
35
+ function refuse(file, lineNo, reason) {
36
+ throw new Error(`hq run --script reads ${file} in strict mode and refused line ${lineNo}: ${reason}. ` +
37
+ 'With --script only KEY=hq(...), KEY=literal and the decorators ' +
38
+ `${[...ALLOWED_DECORATORS].map((d) => '@' + d).join(', ')} are allowed; resolvers such as exec() and @plugin/@import are not. ` +
39
+ 'Remove them, or run without --script.');
40
+ }
41
+ function unquote(value) {
42
+ if (value.length >= 2) {
43
+ const q = value[0];
44
+ if ((q === '"' || q === "'") && value[value.length - 1] === q) {
45
+ const inner = value.slice(1, -1);
46
+ if (!inner.includes(q))
47
+ return inner;
48
+ }
49
+ }
50
+ return value;
51
+ }
52
+ export function parseStrictSchemaFiles(paths) {
53
+ const statics = {};
54
+ const secrets = new Map();
55
+ for (const file of paths) {
56
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
57
+ lines.forEach((raw, index) => {
58
+ const lineNo = index + 1;
59
+ const line = raw.trim();
60
+ if (line === '')
61
+ return;
62
+ if (line.startsWith('#')) {
63
+ for (const match of line.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)/g)) {
64
+ if (!ALLOWED_DECORATORS.has(match[1]))
65
+ refuse(file, lineNo, `decorator @${match[1]} is not allowed`);
66
+ }
67
+ // An allowed decorator may take only a quoted literal argument.
68
+ const stripped = line.replace(/@hqCompany\(\s*"[^"()$`{}]*"\s*\)/g, '');
69
+ if (/[()]/.test(stripped) && /@/.test(stripped))
70
+ refuse(file, lineNo, 'decorator arguments are not allowed');
71
+ return;
72
+ }
73
+ const eq = line.indexOf('=');
74
+ if (eq <= 0)
75
+ refuse(file, lineNo, 'expected KEY=value');
76
+ const key = line.slice(0, eq).trim().replace(/^export\s+/, '');
77
+ if (!KEY.test(key))
78
+ refuse(file, lineNo, 'invalid key');
79
+ const value = line.slice(eq + 1).trim();
80
+ const hq = HQ_CALL.exec(value);
81
+ if (hq) {
82
+ const name = hq[2] || key;
83
+ if (!SECRET_NAME.test(name))
84
+ refuse(file, lineNo, `invalid secret name for ${key}`);
85
+ secrets.set(key, name);
86
+ delete statics[key];
87
+ return;
88
+ }
89
+ if (UNSAFE_LITERAL.test(value))
90
+ refuse(file, lineNo, `${key} is not a plain literal or hq() call`);
91
+ statics[key] = unquote(value);
92
+ secrets.delete(key);
93
+ });
94
+ }
95
+ return { statics, secrets };
96
+ }
97
+ //# sourceMappingURL=script-bound-schema.js.map
@@ -76,6 +76,8 @@ export declare function requestedApiKeyCompany(flagCompany: string | undefined):
76
76
  export declare function assertApiKeyCompany(token: string, requested: string, commandLabel: string): Promise<string>;
77
77
  export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[], usage?: SecretUsage, options?: {
78
78
  allowOfflineFallback?: boolean;
79
+ /** Filled with each returned row's scriptLock.mode (undefined when absent). */
80
+ lockModes?: Map<string, string | undefined>;
79
81
  }): Promise<Map<string, string>>;
80
82
  /**
81
83
  * Emit `hq secrets env` output and terminate deterministically.
@@ -129,7 +129,7 @@ export function parseSecretNameList(input) {
129
129
  // refuse a key bound to a different company. Without it the fetch route reads
130
130
  // from the key's own company, which made `--company <other>` silently return
131
131
  // the key company's same-named secret (P0 triage, 2026-10-11).
132
- async function loadRevealedSecretsViaApiKey(token, keys, companyUid) {
132
+ async function loadRevealedSecretsViaApiKey(token, keys, companyUid, lockModes) {
133
133
  const resolved = new Map();
134
134
  const requested = [...new Set(keys)];
135
135
  const cacheScope = "__api_key__";
@@ -170,6 +170,10 @@ async function loadRevealedSecretsViaApiKey(token, keys, companyUid) {
170
170
  throw new Error(`Failed to fetch secret '${name}': malformed fetch response`);
171
171
  }
172
172
  removeCacheEntry(cacheScope, name);
173
+ // The API-key fetch route returns the secret's scriptLock (the vault denies
174
+ // an enforced secret on this route, since it carries no script usage).
175
+ const mode = secret.scriptLock?.mode;
176
+ lockModes?.set(name, typeof mode === "string" ? mode : undefined);
173
177
  resolved.set(name, secret.value);
174
178
  }
175
179
  return resolved;
@@ -219,7 +223,7 @@ export async function assertApiKeyCompany(token, requested, commandLabel) {
219
223
  }
220
224
  export async function loadRevealedSecrets(token, companyUid, keys, usage, options = {}) {
221
225
  if (token.startsWith(HQ_API_KEY_PREFIX)) {
222
- return loadRevealedSecretsViaApiKey(token, keys, companyUid);
226
+ return loadRevealedSecretsViaApiKey(token, keys, companyUid, options.lockModes);
223
227
  }
224
228
  const resolved = new Map();
225
229
  const requested = [...new Set(keys)];
@@ -284,6 +288,7 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage, option
284
288
  throw new Error("Invalid secret load response from vault");
285
289
  }
286
290
  seenNames.add(s.name);
291
+ options.lockModes?.set(s.name, s.scriptLock?.mode);
287
292
  if (s.revokedAt != null) {
288
293
  errorsByName.set(s.name, {
289
294
  code: "revoked",