@indigoai-us/hq-cli 5.345.169 → 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 (140) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
  3. package/dist/command-catalog.generated.d.ts +181 -9
  4. package/dist/command-catalog.generated.js +224 -10
  5. package/dist/commands/access.d.ts +32 -1
  6. package/dist/commands/access.js +119 -5
  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 +141 -14
  12. package/dist/commands/cloud.d.ts +78 -1
  13. package/dist/commands/cloud.js +274 -9
  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/feedback-triage.d.ts +43 -1
  23. package/dist/commands/feedback-triage.js +123 -23
  24. package/dist/commands/feedback.d.ts +40 -1
  25. package/dist/commands/feedback.js +184 -4
  26. package/dist/commands/files.d.ts +42 -0
  27. package/dist/commands/files.js +223 -30
  28. package/dist/commands/integrations-api.d.ts +4 -0
  29. package/dist/commands/integrations-api.js +18 -2
  30. package/dist/commands/integrations-connect.d.ts +10 -3
  31. package/dist/commands/integrations-connect.js +70 -6
  32. package/dist/commands/integrations-core.d.ts +23 -0
  33. package/dist/commands/integrations-core.js +100 -0
  34. package/dist/commands/integrations-manage.js +8 -1
  35. package/dist/commands/integrations.js +49 -5
  36. package/dist/commands/lanes-loop.d.ts +20 -0
  37. package/dist/commands/lanes-loop.js +69 -11
  38. package/dist/commands/lanes.js +9 -1
  39. package/dist/commands/mesh.js +6 -0
  40. package/dist/commands/rescue.d.ts +9 -0
  41. package/dist/commands/rescue.js +31 -0
  42. package/dist/commands/run.js +115 -40
  43. package/dist/commands/scan.d.ts +5 -1
  44. package/dist/commands/scan.js +16 -4
  45. package/dist/commands/secrets.js +50 -9
  46. package/dist/commands/sync-manifest.d.ts +16 -0
  47. package/dist/commands/sync-manifest.js +17 -1
  48. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2367 -2109
  49. package/dist/hq-cloud-sync-runner/package.json +1 -1
  50. package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
  51. package/dist/lib/agent-kit/inbox-state.js +41 -20
  52. package/dist/lib/agent-kit/kit-config.d.ts +2 -0
  53. package/dist/lib/agent-kit/kit-config.js +6 -0
  54. package/dist/lib/agent-kit/mcp/tools.js +12 -5
  55. package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
  56. package/dist/lib/agent-kit/run/heartbeat.js +6 -3
  57. package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
  58. package/dist/lib/agent-kit/run/inbox.js +42 -10
  59. package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
  60. package/dist/lib/agent-kit/run/request-timeout.js +51 -0
  61. package/dist/lib/bot/api.d.ts +1 -1
  62. package/dist/lib/bot/config.d.ts +10 -0
  63. package/dist/lib/bot/config.js +17 -1
  64. package/dist/lib/bot/decisions.d.ts +62 -0
  65. package/dist/lib/bot/decisions.js +228 -0
  66. package/dist/lib/bot/fleet-local.d.ts +127 -0
  67. package/dist/lib/bot/fleet-local.js +638 -0
  68. package/dist/lib/bot/index.d.ts +2 -0
  69. package/dist/lib/bot/index.js +2 -0
  70. package/dist/lib/bot/local-config.d.ts +3 -2
  71. package/dist/lib/bot/local-config.js +5 -2
  72. package/dist/lib/bot/promote.d.ts +10 -0
  73. package/dist/lib/bot/promote.js +3 -2
  74. package/dist/lib/bot/run.d.ts +7 -0
  75. package/dist/lib/bot/run.js +104 -1
  76. package/dist/lib/bot/runtime/index.js +8 -4
  77. package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
  78. package/dist/lib/bot/runtime/scratch-dir.js +41 -0
  79. package/dist/lib/caller-principal.d.ts +4 -0
  80. package/dist/lib/caller-principal.js +40 -0
  81. package/dist/lib/capacity/collectors/grok.js +8 -2
  82. package/dist/lib/cli-flag-keys.d.ts +2 -0
  83. package/dist/lib/cli-flag-keys.js +2 -0
  84. package/dist/lib/daemon/catalog.d.ts +4 -0
  85. package/dist/lib/daemon/catalog.js +63 -29
  86. package/dist/lib/daemon/config.d.ts +7 -0
  87. package/dist/lib/daemon/config.js +32 -0
  88. package/dist/lib/daemon/run.d.ts +3 -2
  89. package/dist/lib/daemon/run.js +23 -3
  90. package/dist/lib/daemon/supervisor.d.ts +1 -0
  91. package/dist/lib/daemon/supervisor.js +4 -0
  92. package/dist/lib/daemon/sync-core-canary.d.ts +12 -0
  93. package/dist/lib/daemon/sync-core-canary.js +61 -0
  94. package/dist/lib/doctor/checks/release-links.d.ts +62 -1
  95. package/dist/lib/doctor/checks/release-links.js +188 -2
  96. package/dist/lib/doctor/checks/sync-health.d.ts +32 -0
  97. package/dist/lib/doctor/checks/sync-health.js +54 -1
  98. package/dist/lib/doctor/fix/apply.d.ts +26 -4
  99. package/dist/lib/doctor/fix/apply.js +149 -11
  100. package/dist/lib/doctor/fix/remediation.d.ts +8 -1
  101. package/dist/lib/doctor/fix/remediation.js +22 -0
  102. package/dist/lib/doctor/types.d.ts +1 -1
  103. package/dist/lib/doctor/types.js +1 -0
  104. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  105. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  106. package/dist/lib/index-render/index.d.ts +9 -0
  107. package/dist/lib/index-render/index.js +65 -5
  108. package/dist/lib/index-render/session-scope.d.ts +69 -0
  109. package/dist/lib/index-render/session-scope.js +279 -0
  110. package/dist/lib/lanes/admission.js +7 -20
  111. package/dist/lib/lanes/reconcile.js +13 -15
  112. package/dist/lib/lanes/stale-lock.d.ts +50 -0
  113. package/dist/lib/lanes/stale-lock.js +105 -0
  114. package/dist/lib/lanes/store.js +11 -76
  115. package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
  116. package/dist/lib/notify-recipient.d.ts +10 -4
  117. package/dist/lib/notify-recipient.js +16 -4
  118. package/dist/lib/secret-input-link.d.ts +42 -0
  119. package/dist/lib/secret-input-link.js +93 -0
  120. package/dist/lib/secret-scan/range.d.ts +57 -0
  121. package/dist/lib/secret-scan/range.js +266 -0
  122. package/dist/lib/secret-scan/repo-config.d.ts +18 -3
  123. package/dist/lib/secret-scan/repo-config.js +135 -2
  124. package/dist/lib/work-context/organize.d.ts +7 -0
  125. package/dist/lib/work-context/organize.js +21 -1
  126. package/dist/run/script-bound-schema.d.ts +27 -0
  127. package/dist/run/script-bound-schema.js +97 -0
  128. package/dist/secrets-env-action.d.ts +2 -0
  129. package/dist/secrets-env-action.js +7 -2
  130. package/dist/sentry-before-send.js +1 -0
  131. package/dist/utils/cognito-session.d.ts +3 -30
  132. package/dist/utils/cognito-session.js +2 -100
  133. package/dist/utils/machine-principal-state.d.ts +38 -0
  134. package/dist/utils/machine-principal-state.js +110 -0
  135. package/dist/utils/safe-link.js +9 -0
  136. package/dist/utils/script-bound-exec.d.ts +158 -0
  137. package/dist/utils/script-bound-exec.js +568 -0
  138. package/dist/utils/sync-watchdog.d.ts +92 -0
  139. package/dist/utils/sync-watchdog.js +250 -0
  140. package/package.json +8 -8
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { readLane, retryUpdateLane, updateLane } from "../lib/lanes/store.js";
5
5
  import { LanesError, isLaneVersionConflict } from "../lib/lanes/errors.js";
6
+ import { liveNodeBinary } from "../lib/lanes/node-binary.js";
6
7
  import { readLatestEnvelopeRecord } from "../lib/lanes/watcher.js";
7
8
  import { spawnLaneWorker } from "../lib/lanes/spawn.js";
8
9
  import { raiseLaneQuestion } from "../lib/lanes/questions.js";
@@ -33,6 +34,44 @@ export function settleLoopLaneIdle(hqRoot, laneId) {
33
34
  export function prepareIdleLoopLaneForResume(hqRoot, laneId) {
34
35
  return casLoopLaneState(hqRoot, laneId, "running", "done");
35
36
  }
37
+ /**
38
+ * LanesError codes from the resume admission path that mean admission could
39
+ * not run right now and a later admission can retry: the admission or store
40
+ * lock timed out (store_lock_timeout), the lane changed while admission was
41
+ * checked (lane_admission_conflict), or a lane record CAS miss
42
+ * (version_conflict). Any other resume error is permanent and is rethrown.
43
+ */
44
+ export const TRANSIENT_LOOP_ADMISSION_ERRORS = new Set([
45
+ "store_lock_timeout",
46
+ "lane_admission_conflict",
47
+ "version_conflict",
48
+ ]);
49
+ /**
50
+ * Park a loop phase whose resume was refused before an engine launched. The
51
+ * lane becomes `queued` with `admit_failed` and the error, and carries the
52
+ * phase as `pending_resume`, so admission can launch it later and the lane is
53
+ * never left looking active with no engine.
54
+ */
55
+ export function deferRefusedLoopPhase(hqRoot, laneId, at, error, phase) {
56
+ return retryUpdateLane(hqRoot, laneId, "command", (record) => ({
57
+ ...record,
58
+ state: "queued",
59
+ queued_reason: "admit_failed",
60
+ queued_details: { reason: "admit_failed", error: error.error, message: error.message || error.error },
61
+ queued_at: at,
62
+ admission_reserved_at: undefined,
63
+ pending_resume: {
64
+ ...(phase.sessionId ? { session_id: phase.sessionId } : { fresh_thread: true }),
65
+ trigger: "resume",
66
+ caller: "loop",
67
+ resumed_from: "done",
68
+ text: phase.text,
69
+ compact: true,
70
+ working_directory: phase.workingDirectory,
71
+ at,
72
+ },
73
+ }));
74
+ }
36
75
  function casLoopLaneState(hqRoot, laneId, from, to) {
37
76
  for (let attempt = 0;; attempt += 1) {
38
77
  const current = readLane(hqRoot, laneId);
@@ -67,8 +106,8 @@ export function startLaneLoopController(ctx, laneId, initialAdmission = false) {
67
106
  const args = [entry, "lanes", "_loop-worker", laneId];
68
107
  const env = { ...ctx.env, HQ_LANE_LOOP_CONTROLLER: "1", ...(initialAdmission ? { HQ_LANE_LOOP_INITIAL: "1" } : {}) };
69
108
  const child = ctx.spawnProcess
70
- ? ctx.spawnProcess(process.execPath, args, { cwd: ctx.hqRoot(), detached: true, windowsHide: true, stdio: ["ignore", 1, 2], env })
71
- : spawn(process.execPath, args, { cwd: ctx.hqRoot(), detached: true, stdio: "ignore", env });
109
+ ? ctx.spawnProcess(liveNodeBinary(), args, { cwd: ctx.hqRoot(), detached: true, windowsHide: true, stdio: ["ignore", 1, 2], env })
110
+ : spawn(liveNodeBinary(), args, { cwd: ctx.hqRoot(), detached: true, stdio: "ignore", env });
72
111
  child.unref();
73
112
  if (child.pid === undefined)
74
113
  throw new LanesError("loop_controller_spawn_failed", "Loop controller did not start", { lane_id: laneId });
@@ -208,15 +247,34 @@ export async function runLaneLoopController(ctx, laneId, deps) {
208
247
  else {
209
248
  if (current.state === "running")
210
249
  current = prepareIdleLoopLaneForResume(hqRoot, laneId);
211
- await deps.resumeLane(laneId, {
212
- text: `Read ${briefPath} and follow it as the complete instruction for this phase.`,
213
- project: current.project_id,
214
- ...(storyId ? { story: storyId } : {}),
215
- freshThread: fresh,
216
- ...(sessionId ? { loopSessionId: sessionId } : {}),
217
- ...(repairError !== undefined ? { loopHandoffRepair: true } : {}),
218
- workingDirectory: worktree,
219
- });
250
+ const resumeText = `Read ${briefPath} and follow it as the complete instruction for this phase.`;
251
+ try {
252
+ await deps.resumeLane(laneId, {
253
+ text: resumeText,
254
+ project: current.project_id,
255
+ ...(storyId ? { story: storyId } : {}),
256
+ freshThread: fresh,
257
+ ...(sessionId ? { loopSessionId: sessionId } : {}),
258
+ ...(repairError !== undefined ? { loopHandoffRepair: true } : {}),
259
+ workingDirectory: worktree,
260
+ });
261
+ }
262
+ catch (error) {
263
+ // Transient admission refusal before any engine launched (for
264
+ // example a store lock timeout). The envelope is already active, so keep it
265
+ // and park the lane as queued with a reason `hq lanes show`
266
+ // displays; the next admission launches this phase from
267
+ // pending_resume and the wait below picks up its result.
268
+ if (!(error instanceof LanesError) ||
269
+ !TRANSIENT_LOOP_ADMISSION_ERRORS.has(error.error) ||
270
+ readLane(hqRoot, laneId).state !== "done")
271
+ throw error;
272
+ deferRefusedLoopPhase(hqRoot, laneId, ctx.now().toISOString(), error, {
273
+ text: resumeText,
274
+ sessionId: fresh ? undefined : sessionId ?? current.provider_session_id,
275
+ workingDirectory: worktree,
276
+ });
277
+ }
220
278
  }
221
279
  let interruptPromise;
222
280
  const abortPhase = () => {
@@ -508,9 +508,17 @@ function livenessDepsFromCtx(ctx) {
508
508
  * envelopes; every other lane spawns its worker directly. Every admission path
509
509
  * (reconcile, in-process queue drain, hq lanes admit) goes through here so a
510
510
  * loop lane never runs its brief without a controller.
511
+ *
512
+ * A queued loop lane whose controller is still alive is a deferred phase: the
513
+ * controller already activated the envelope and is waiting for its engine.
514
+ * That lane spawns the phase worker from pending_resume; starting a second
515
+ * controller would lose the runner lease and leave the lane running with no
516
+ * engine (feedback_865fe8a0).
511
517
  */
512
518
  function startAdmittedLane(ctx, root, candidate) {
513
- if (candidate.loop_mode !== true || candidate.state !== "queued") {
519
+ const deferredLoopPhase = candidate.loop_mode === true && candidate.pending_resume !== undefined &&
520
+ loopControllerIsAlive(runDir(root, candidate.lane_id));
521
+ if (candidate.loop_mode !== true || candidate.state !== "queued" || deferredLoopPhase) {
514
522
  const admissionHost = reconcileHostFromCtx(ctx);
515
523
  return spawnLaneWorker(root, candidate, {
516
524
  env: ctx.env,
@@ -2070,11 +2070,16 @@ async function runContextOrganize(opts) {
2070
2070
  !opts.untracked);
2071
2071
  if (isList) {
2072
2072
  let fetchCandidates;
2073
+ let resolveCompanyUid;
2073
2074
  let offline = Boolean(opts.offline);
2074
2075
  if (!offline) {
2075
2076
  try {
2076
2077
  const token = await requireToken();
2077
2078
  fetchCandidates = createCandidatesFetcher({ token });
2079
+ resolveCompanyUid = async (slug) => {
2080
+ const membership = await resolveActiveMembershipCompany(token, slug, AbortSignal.timeout(ONLINE_MEMBERSHIP_VALIDATION_TIMEOUT_MS));
2081
+ return membership?.companyUid ?? null;
2082
+ };
2078
2083
  }
2079
2084
  catch {
2080
2085
  offline = true;
@@ -2084,6 +2089,7 @@ async function runContextOrganize(opts) {
2084
2089
  sessionId,
2085
2090
  root,
2086
2091
  fetchCandidates,
2092
+ resolveCompanyUid,
2087
2093
  offline,
2088
2094
  });
2089
2095
  if (opts.json || opts.machine) {
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { Command } from 'commander';
23
23
  import { retrustCodexHooksAfterRescue } from '../utils/hook-trust.js';
24
+ import { repairDanglingReleaseLinks } from '../lib/doctor/checks/release-links.js';
24
25
  export interface RescueTarget {
25
26
  source: string;
26
27
  /** undefined → let the script apply its own default ref (`main`). */
@@ -52,6 +53,14 @@ export declare function shouldAttemptSelfUpdate(opts: {
52
53
  * changes the rescue's exit status, because the core update itself succeeded.
53
54
  */
54
55
  export declare function retrustHooksAfterRescue(hqRoot: string, retrust?: typeof retrustCodexHooksAfterRescue): Promise<void>;
56
+ /**
57
+ * Repair HQ-managed release links (`.agents/skills`, `.codex/claude`, ...)
58
+ * that do not resolve after a rescue, for example because they were laid at
59
+ * the rescue's temp extraction folder, which is then deleted
60
+ * (feedback_2ab63e50). Exported for tests. Best-effort: a failure is reported
61
+ * but never changes the rescue's exit status.
62
+ */
63
+ export declare function repairReleaseLinksAfterRescue(hqRoot: string, repair?: typeof repairDanglingReleaseLinks): void;
55
64
  /** Latest release tag for a repo, e.g. `v12.3.0`. Throws on failure. */
56
65
  export declare function getLatestReleaseTag(repo: string, token?: string): Promise<string>;
57
66
  /** Resolve a tag to its commit SHA, dereferencing annotated tags. */
@@ -6,6 +6,7 @@ import { rescue } from '@indigoai-us/hq-cloud';
6
6
  import { findHqRoot } from '../utils/manifest.js';
7
7
  import { selfUpdateAndReexec } from '../utils/self-update.js';
8
8
  import { retrustCodexHooksAfterRescue } from '../utils/hook-trust.js';
9
+ import { repairDanglingReleaseLinks } from '../lib/doctor/checks/release-links.js';
9
10
  import { PROD_SOURCE, STAGING_SOURCE, resolveGhToken } from '../utils/shipped-codex-hooks.js';
10
11
  /**
11
12
  * Resolve the source repo + ref from the user's flags. Pure + exported for
@@ -49,6 +50,35 @@ export async function retrustHooksAfterRescue(hqRoot, retrust = retrustCodexHook
49
50
  'Codex will skip changed HQ hooks until you run `hq reindex`.'));
50
51
  }
51
52
  }
53
+ /**
54
+ * Repair HQ-managed release links (`.agents/skills`, `.codex/claude`, ...)
55
+ * that do not resolve after a rescue, for example because they were laid at
56
+ * the rescue's temp extraction folder, which is then deleted
57
+ * (feedback_2ab63e50). Exported for tests. Best-effort: a failure is reported
58
+ * but never changes the rescue's exit status.
59
+ */
60
+ export function repairReleaseLinksAfterRescue(hqRoot, repair = repairDanglingReleaseLinks) {
61
+ try {
62
+ for (const entry of repair(hqRoot)) {
63
+ if (entry.action === 'repointed') {
64
+ console.log(`rescue: repointed dangling link ${entry.rel} -> ${entry.linkText ?? entry.target}`);
65
+ }
66
+ else if (entry.action === 'removed') {
67
+ console.log(`rescue: removed dangling link ${entry.rel} (its target is missing)`);
68
+ }
69
+ else if (entry.action === 'skipped') {
70
+ console.log(`rescue: left dangling link ${entry.rel} alone: ${entry.reason ?? 'not an HQ-managed target'}`);
71
+ }
72
+ else {
73
+ console.warn(chalk.yellow(`rescue: could not repair ${entry.rel}: ${entry.reason ?? 'unknown error'}. Run \`hq doctor --fix\`.`));
74
+ }
75
+ }
76
+ }
77
+ catch (err) {
78
+ console.warn(chalk.yellow(`rescue: could not repair dangling release links: ${err instanceof Error ? err.message : 'unknown error'}. ` +
79
+ 'Run `hq doctor --fix`.'));
80
+ }
81
+ }
52
82
  function ghHeaders(token) {
53
83
  const headers = {
54
84
  Accept: 'application/vnd.github+json',
@@ -185,6 +215,7 @@ export function registerRescueCommand(program) {
185
215
  ghToken: token,
186
216
  });
187
217
  if (status === 0 && !opts.check) {
218
+ repairReleaseLinksAfterRescue(hqRoot);
188
219
  await retrustHooksAfterRescue(hqRoot);
189
220
  }
190
221
  process.exit(status);
@@ -1,3 +1,5 @@
1
+ import { assertInjectedNamesAllowed, assertLiteralNamesAllowed, assertScriptUnchanged, planScriptBinding, selectChildLaunch } from '../utils/script-bound-exec.js';
2
+ import { parseStrictSchemaFiles } from '../run/script-bound-schema.js';
1
3
  import { spawn } from 'node:child_process';
2
4
  import * as path from 'node:path';
3
5
  import * as fs from 'node:fs';
@@ -33,6 +35,47 @@ export async function buildRunUsage(scriptPath, scriptId) {
33
35
  },
34
36
  };
35
37
  }
38
+ /**
39
+ * `hq run --script` resolution: read the schema files strictly, fetch only the
40
+ * hq() secrets, and return literals plus secret values. No varlock graph.
41
+ */
42
+ async function resolveStrictSchema(paths, companyUid, usage, fetchBatch) {
43
+ const strict = parseStrictSchemaFiles(paths);
44
+ // Refuse proxy, CA/TLS, pager/editor, HOME/XDG and loader names before any
45
+ // fetch, so a schema literal cannot redirect what the bound script calls.
46
+ // Literals must be on the small --script allowlist; hq() names keep the
47
+ // denylist check (creating a secret needs vault write).
48
+ assertLiteralNamesAllowed(Object.keys(strict.statics));
49
+ assertInjectedNamesAllowed(strict.secrets.keys());
50
+ const names = [...new Set(strict.secrets.values())];
51
+ if (names.length > 100) {
52
+ throw new Error(`hq run supports at most 100 hq() resolvers per schema; got ${names.length}`);
53
+ }
54
+ const values = new Map();
55
+ if (names.length > 0) {
56
+ const data = await fetchBatch(companyUid, names, usage);
57
+ if (!Array.isArray(data?.secrets) || !Array.isArray(data?.errors)) {
58
+ throw new Error('Invalid secret load response from vault');
59
+ }
60
+ const firstError = data.errors[0];
61
+ if (firstError) {
62
+ throw new Error(`Failed to fetch secret '${firstError.name}': ${firstError.message ?? firstError.code}`);
63
+ }
64
+ for (const row of data.secrets) {
65
+ if (row && typeof row.name === 'string' && typeof row.value === 'string' && row.revokedAt == null) {
66
+ values.set(row.name, row.value);
67
+ }
68
+ }
69
+ }
70
+ const env = { ...strict.statics };
71
+ for (const [key, name] of strict.secrets) {
72
+ const value = values.get(name);
73
+ if (value === undefined)
74
+ throw new Error(`Failed to fetch secret '${name}': not returned by vault`);
75
+ env[key] = value;
76
+ }
77
+ return env;
78
+ }
36
79
  export function registerRunCommand(program) {
37
80
  program
38
81
  .command('run')
@@ -89,8 +132,19 @@ export function registerRunCommand(program) {
89
132
  const uid = await getCompanyUid(token, slug, undefined, {
90
133
  allowOfflineScopeFallback: true,
91
134
  });
135
+ // feedback_0e6abcb8: with --script, script-locked secrets may reach
136
+ // only the approved script itself. Resolve the command now; a refusal
137
+ // is applied once the vault says which secrets are locked.
138
+ const scriptPlan = opts.check ? undefined : await planScriptBinding(opts.script, childArgs);
92
139
  const usage = await buildRunUsage(opts.script, opts.scriptId);
140
+ if (scriptPlan?.bound) {
141
+ await assertScriptUnchanged(scriptPlan.bound, usage?.script?.sha256);
142
+ }
143
+ const requestedNames = new Set();
144
+ const lockModes = new Map();
93
145
  const fetchBatch = async (companyUid, names, requestUsage) => withRunSecretReadTelemetry(companyUid, async () => {
146
+ for (const name of names)
147
+ requestedNames.add(name);
94
148
  const res = await vaultApiFetch({
95
149
  token,
96
150
  path: `/secrets/${encodeURIComponent(companyUid)}/load`,
@@ -115,7 +169,12 @@ export function registerRunCommand(program) {
115
169
  }
116
170
  throw new Error(`Failed to batch-load secrets: ${message}`);
117
171
  }
118
- return res.json();
172
+ const data = await res.json();
173
+ for (const row of Array.isArray(data?.secrets) ? data.secrets : []) {
174
+ if (row && typeof row.name === 'string')
175
+ lockModes.set(row.name, row.scriptLock?.mode);
176
+ }
177
+ return data;
119
178
  });
120
179
  const pluginOpts = {
121
180
  companyOverride: opts.company,
@@ -127,61 +186,77 @@ export function registerRunCommand(program) {
127
186
  };
128
187
  // LAST entry = highest precedence; .env.local files trail .env.schema files so any .env.local beats any schema regardless of depth.
129
188
  const paths = [...schemaPaths, ...envLocalPaths];
130
- let state;
131
- const graph = await internal.loadEnvGraph({
132
- entryFilePaths: paths,
133
- // hq run owns its short-lived in-memory resolution. Varlock's default
134
- // cache auto-detection can write ~/.config/varlock and initialize
135
- // the native local-encryption helper, neither of which belongs on
136
- // this command path.
137
- skipCache: true,
138
- afterInit: async (g) => { state = installHqPlugin(g, pluginOpts); },
139
- });
140
- // Refuse to resolve a graph varlock never finished loading (a malformed
141
- // .env.schema / .env.local): resolving an unprocessed item whose key is
142
- // also in process.env reaches varlock's `expected dataType to be set`
143
- // invariant as a FLOATED, never-settling rejection — the CLI then exits
144
- // 0 with the command never run (HQ-CLI-W). Run this BEFORE prewarm so a
145
- // bad file can never trigger a secret fetch.
146
- assertEnvGraphLoaded(graph);
147
- await prewarmHqSecrets(graph, pluginOpts, state);
148
- // Defence in depth: convert a floated resolution rejection into a thrown
149
- // error instead of hanging on varlock's never-settling promise, in case
150
- // the shape-dependent guard above ever silently degrades.
151
- await resolveEnvValuesOrThrow(graph);
152
- // varlock's item.errors includes warning-severity entries (isWarning),
153
- // e.g. "Value is very short! Is it actually sensitive?" for a short
154
- // sensitive value. Print those and carry on; only real errors are fatal.
155
- const schemaItems = Object.entries(graph.configSchema);
156
- const itemMessages = (wantWarnings) => schemaItems.flatMap(([k, item]) => (item.errors ?? [])
157
- .filter((e) => Boolean(e?.isWarning) === wantWarnings)
158
- .map((e) => ` ${k}: ${e?.message ?? String(e)}`));
159
- const warningMsgs = itemMessages(true);
160
- if (warningMsgs.length > 0) {
161
- process.stderr.write(`Warning: env var checks reported warnings:\n${warningMsgs.join('\n')}\n`);
189
+ let resolvedEnv;
190
+ if (opts.script) {
191
+ // feedback_0e6abcb8: with --script, never build a varlock graph. Its
192
+ // exec()/plugin/import resolvers would run in this process while the
193
+ // fetched secrets are in memory, before the script binding is
194
+ // checked. The strict reader accepts only hq() items and literals.
195
+ resolvedEnv = await resolveStrictSchema(paths, uid, usage, fetchBatch);
162
196
  }
163
- const errorMsgs = itemMessages(false);
164
- if (errorMsgs.length > 0) {
165
- process.stderr.write(`Error: failed to resolve env vars:\n${errorMsgs.join('\n')}\n`);
166
- process.exit(1);
197
+ else {
198
+ let state;
199
+ const graph = await internal.loadEnvGraph({
200
+ entryFilePaths: paths,
201
+ // hq run owns its short-lived in-memory resolution. Varlock's default
202
+ // cache auto-detection can write ~/.config/varlock and initialize
203
+ // the native local-encryption helper, neither of which belongs on
204
+ // this command path.
205
+ skipCache: true,
206
+ afterInit: async (g) => { state = installHqPlugin(g, pluginOpts); },
207
+ });
208
+ // Refuse to resolve a graph varlock never finished loading (a malformed
209
+ // .env.schema / .env.local): resolving an unprocessed item whose key is
210
+ // also in process.env reaches varlock's `expected dataType to be set`
211
+ // invariant as a FLOATED, never-settling rejection — the CLI then exits
212
+ // 0 with the command never run (HQ-CLI-W). Run this BEFORE prewarm so a
213
+ // bad file can never trigger a secret fetch.
214
+ assertEnvGraphLoaded(graph);
215
+ await prewarmHqSecrets(graph, pluginOpts, state);
216
+ // Defence in depth: convert a floated resolution rejection into a thrown
217
+ // error instead of hanging on varlock's never-settling promise, in case
218
+ // the shape-dependent guard above ever silently degrades.
219
+ await resolveEnvValuesOrThrow(graph);
220
+ // varlock's item.errors includes warning-severity entries (isWarning),
221
+ // e.g. "Value is very short! Is it actually sensitive?" for a short
222
+ // sensitive value. Print those and carry on; only real errors are fatal.
223
+ const schemaItems = Object.entries(graph.configSchema);
224
+ const itemMessages = (wantWarnings) => schemaItems.flatMap(([k, item]) => (item.errors ?? [])
225
+ .filter((e) => Boolean(e?.isWarning) === wantWarnings)
226
+ .map((e) => ` ${k}: ${e?.message ?? String(e)}`));
227
+ const warningMsgs = itemMessages(true);
228
+ if (warningMsgs.length > 0) {
229
+ process.stderr.write(`Warning: env var checks reported warnings:\n${warningMsgs.join('\n')}\n`);
230
+ }
231
+ const errorMsgs = itemMessages(false);
232
+ if (errorMsgs.length > 0) {
233
+ process.stderr.write(`Error: failed to resolve env vars:\n${errorMsgs.join('\n')}\n`);
234
+ process.exit(1);
235
+ }
236
+ resolvedEnv = graph.getResolvedEnvObject();
167
237
  }
168
- const resolvedEnv = graph.getResolvedEnvObject();
169
238
  const varCount = Object.keys(resolvedEnv).length;
170
239
  process.stderr.write(`Loaded ${varCount} env vars from .env.schema (company: ${slug})\n`);
171
240
  if (opts.check) {
172
241
  process.exit(0);
173
242
  }
174
- const [childCmd, ...restArgs] = childArgs;
243
+ // Script-locked (or unknown) secrets go only to a private, re-hashed
244
+ // copy of the approved script; unlocked secrets keep the command as is.
245
+ const launch = selectChildLaunch(scriptPlan, [...requestedNames], lockModes, usage?.script?.sha256, childArgs, process.env, resolvedEnv);
246
+ const childCmd = launch.file;
247
+ const restArgs = launch.args;
175
248
  const child = spawn(childCmd, restArgs, {
176
249
  stdio: 'inherit',
177
- env: { ...process.env, ...resolvedEnv },
250
+ env: launch.env,
178
251
  });
179
252
  child.once('spawn', () => releaseChildWrapperPackageUseLeaseAfterStart("childWrapper", isHqCliChild(childCmd, restArgs)));
180
253
  child.on('error', (err) => {
181
254
  process.stderr.write(`Error: failed to start command '${childCmd}': ${err.message}\n`);
255
+ launch.cleanup();
182
256
  process.exit(1);
183
257
  });
184
258
  child.on('close', (code, signal) => {
259
+ launch.cleanup();
185
260
  if (signal) {
186
261
  process.kill(process.pid, signal);
187
262
  }
@@ -6,7 +6,10 @@
6
6
  * hq scan secrets --json findings as JSON
7
7
  * hq scan secrets --repo <dir> [--snapshot-root <dir>] [--allowlist-rev <rev>] <file...>
8
8
  * scan files with that repo's committed
9
- * .gitleaks.toml allowlists (used by pr-check)
9
+ * .gitleaks.toml allowlists
10
+ * hq scan secrets --range <base>[..<head>] [--repo <dir>]
11
+ * scan the lines a branch added since <base>,
12
+ * every commit included (used by pr-check)
10
13
  *
11
14
  * Rules: the vendored gitleaks default rule set (MIT, vendor/gitleaks/) plus
12
15
  * HQ's own token patterns. No network, no account, no quota. A staged scan also
@@ -24,6 +27,7 @@ interface ScanSecretsOptions {
24
27
  repo?: string;
25
28
  snapshotRoot?: string;
26
29
  allowlistRev?: string;
30
+ range?: string;
27
31
  }
28
32
  export declare function runScanSecrets(paths: string[], opts: ScanSecretsOptions, cwd?: string): number;
29
33
  export declare function registerScanCommand(program: Command): void;
@@ -6,7 +6,10 @@
6
6
  * hq scan secrets --json findings as JSON
7
7
  * hq scan secrets --repo <dir> [--snapshot-root <dir>] [--allowlist-rev <rev>] <file...>
8
8
  * scan files with that repo's committed
9
- * .gitleaks.toml allowlists (used by pr-check)
9
+ * .gitleaks.toml allowlists
10
+ * hq scan secrets --range <base>[..<head>] [--repo <dir>]
11
+ * scan the lines a branch added since <base>,
12
+ * every commit included (used by pr-check)
10
13
  *
11
14
  * Rules: the vendored gitleaks default rule set (MIT, vendor/gitleaks/) plus
12
15
  * HQ's own token patterns. No network, no account, no quota. A staged scan also
@@ -18,6 +21,7 @@
18
21
  * Output names the rule, file and line; secret values are never printed.
19
22
  */
20
23
  import { ScanConfigError } from "../lib/secret-scan/repo-config.js";
24
+ import { scanRange } from "../lib/secret-scan/range.js";
21
25
  import { ScanInputError, scanFiles, scanStaged } from "../lib/secret-scan/staged.js";
22
26
  function report(findings, scope) {
23
27
  if (!findings.length)
@@ -38,7 +42,14 @@ export function runScanSecrets(paths, opts, cwd = process.cwd()) {
38
42
  throw new ScanInputError("--snapshot-root needs --repo");
39
43
  if (opts.allowlistRev !== undefined && opts.repo === undefined)
40
44
  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);
45
+ if (opts.range !== undefined) {
46
+ if (paths.length || opts.staged)
47
+ throw new ScanInputError("--range scans a commit range; do not pass files or --staged");
48
+ if (opts.snapshotRoot !== undefined || opts.allowlistRev !== undefined) {
49
+ throw new ScanInputError("--range reads .gitleaks.toml from the range base; drop --snapshot-root and --allowlist-rev");
50
+ }
51
+ }
52
+ findings = opts.range !== undefined ? scanRange(opts.repo ?? cwd, opts.range) : paths.length && !opts.staged ? scanFiles(paths, { repo: opts.repo, snapshotRoot: opts.snapshotRoot, allowlistRev: opts.allowlistRev }) : scanStaged(cwd);
42
53
  }
43
54
  catch (err) {
44
55
  const msg = err instanceof ScanInputError || err instanceof ScanConfigError ? err.message : `scan failed: ${err instanceof Error ? err.message : String(err)}`;
@@ -51,7 +62,7 @@ export function runScanSecrets(paths, opts, cwd = process.cwd()) {
51
62
  process.stdout.write(`${JSON.stringify({ ok: findings.length === 0, findings })}\n`);
52
63
  }
53
64
  else {
54
- report(findings, paths.length && !opts.staged ? "the given files" : "staged changes");
65
+ report(findings, opts.range !== undefined ? `the lines added in ${opts.range}` : paths.length && !opts.staged ? "the given files" : "staged changes");
55
66
  }
56
67
  return findings.length ? 1 : 0;
57
68
  }
@@ -63,9 +74,10 @@ export function registerScanCommand(program) {
63
74
  .argument("[files...]", "Scan these files instead of the staged changes")
64
75
  .option("--staged", "Scan the staged changes (default when no files are given)")
65
76
  .option("--json", "Print findings as JSON")
66
- .option("--repo <dir>", "With files: apply this repo's committed .gitleaks.toml allowlists, matching repo-relative paths")
77
+ .option("--repo <dir>", "With files: apply this repo's committed .gitleaks.toml allowlists, matching repo-relative paths. With --range: the repo to scan (default: the current directory)")
67
78
  .option("--snapshot-root <dir>", "With --repo: files under <dir>/<commit>/<path> are matched as <path>")
68
79
  .option("--allowlist-rev <rev>", "With --repo: read .gitleaks.toml from this commit instead of HEAD")
80
+ .option("--range <range>", "Scan the lines added in <base>..<head> (head defaults to HEAD), every commit included, with the base's .gitleaks.toml allowlists and the repo's [[rules]]")
69
81
  .action((files, opts) => {
70
82
  process.exitCode = runScanSecrets(files ?? [], opts);
71
83
  });
@@ -1,3 +1,4 @@
1
+ import { assertScriptUnchanged, planScriptBinding, selectChildLaunch } from "../utils/script-bound-exec.js";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import * as yaml from "js-yaml";
@@ -77,6 +78,7 @@ import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, format
77
78
  import { vaultApiFetch, getCompanyUid, getEntityUid, looksLikeCompanyUid, } from "../utils/vault-api.js";
78
79
  import { formatApiKeyCapabilityDenial, peekHqApiKey, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
79
80
  import { runSandboxJobAfterSubmission } from "../utils/secrets-sandbox-resident.js";
81
+ import { renderSecretInputLink, SECRET_LINK_FORMATS, } from "../lib/secret-input-link.js";
80
82
  export { vaultApiFetch, getCompanyUid, getEntityUid };
81
83
  /**
82
84
  * Mirrors `RESOURCE_SURFACES` in hq-pro
@@ -1370,6 +1372,10 @@ export function registerSecretsCommand(program) {
1370
1372
  process.exit(1);
1371
1373
  }
1372
1374
  const keys = parseSecretNameList(_opts.only);
1375
+ // feedback_0e6abcb8: with --script, a script-locked secret may reach
1376
+ // only the approved script itself. Resolve the command now; a refusal
1377
+ // is applied once the vault says which secrets are locked.
1378
+ const scriptPlan = await planScriptBinding(_opts.script, childArgs);
1373
1379
  const parentScope = { ...secrets.opts() };
1374
1380
  const inheritedCompany = parentScope.company;
1375
1381
  let requestedCompany = _opts.company ?? inheritedCompany;
@@ -1466,9 +1472,14 @@ export function registerSecretsCommand(program) {
1466
1472
  secret_names: keys,
1467
1473
  });
1468
1474
  }
1469
- const readSecrets = async () => loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
1475
+ const execUsage = cred.kind === "cognito"
1470
1476
  ? await buildSecretUsage("exec", _opts.script, _opts.scriptId)
1471
- : undefined, { allowOfflineFallback: true });
1477
+ : undefined;
1478
+ if (scriptPlan?.bound) {
1479
+ await assertScriptUnchanged(scriptPlan.bound, execUsage?.script?.sha256);
1480
+ }
1481
+ const lockModes = new Map();
1482
+ const readSecrets = async () => loadRevealedSecrets(cred.token, companyUid, keys, execUsage, { allowOfflineFallback: true, lockModes });
1472
1483
  // API-key auth has no signed-in person identity for the CLI telemetry
1473
1484
  // endpoint. Do not attribute that read to an unrelated cached session.
1474
1485
  let revealed;
@@ -1489,18 +1500,24 @@ export function registerSecretsCommand(program) {
1489
1500
  }
1490
1501
  secretEnv[key] = value;
1491
1502
  }
1492
- const [childCmd, ...childCmdArgs] = childArgs;
1503
+ // Script-locked (or unknown) secrets go only to a private, re-hashed
1504
+ // copy of the approved script; unlocked secrets keep the command as is.
1505
+ const launch = selectChildLaunch(scriptPlan, keys, lockModes, execUsage?.script?.sha256, childArgs, process.env, secretEnv);
1506
+ const childCmd = launch.file;
1507
+ const childCmdArgs = launch.args;
1493
1508
  const { spawn } = await import("node:child_process");
1494
1509
  const child = spawn(childCmd, childCmdArgs, {
1495
1510
  stdio: "inherit",
1496
- env: { ...process.env, ...secretEnv },
1511
+ env: launch.env,
1497
1512
  });
1498
1513
  child.once("spawn", () => releaseChildWrapperPackageUseLeaseAfterStart("childWrapper", isHqCliChild(childCmd, childCmdArgs)));
1499
1514
  child.on("error", (err) => {
1500
1515
  console.error(chalk.red(`Failed to start command '${childCmd}': ${err.message}`));
1516
+ launch.cleanup();
1501
1517
  process.exit(1);
1502
1518
  });
1503
1519
  child.on("close", (code, signal) => {
1520
+ launch.cleanup();
1504
1521
  if (signal) {
1505
1522
  process.kill(process.pid, signal);
1506
1523
  }
@@ -1525,8 +1542,19 @@ export function registerSecretsCommand(program) {
1525
1542
  .command("generate-link <name>")
1526
1543
  .description("Generate a one-time link for someone to submit a secret value")
1527
1544
  .option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
1545
+ .option("--format <format>", "Output format: text (default; OSC-8 hyperlink on a supporting TTY, plain URL otherwise; FORCE_HYPERLINK=1/0 overrides detection on a TTY only), markdown (one Markdown link line on stdout, details on stderr), json")
1546
+ .option("--json", "Print { url, expiresAt, secretName } as JSON (same as --format json)")
1528
1547
  .action(async (name, opts) => {
1529
1548
  try {
1549
+ const format = (opts.json ? "json" : (opts.format ?? "text"));
1550
+ if (!SECRET_LINK_FORMATS.includes(format)) {
1551
+ console.error(chalk.red(`Invalid format '${opts.format}': must be one of ${SECRET_LINK_FORMATS.join(", ")}`));
1552
+ process.exit(1);
1553
+ }
1554
+ if (opts.json && opts.format !== undefined && opts.format !== "json") {
1555
+ console.error(chalk.red(`--json conflicts with --format ${opts.format}`));
1556
+ process.exit(1);
1557
+ }
1530
1558
  if (!SECRET_NAME_PATTERN.test(name)) {
1531
1559
  console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_API_KEY or DEV/MY_KEY)`));
1532
1560
  process.exit(1);
@@ -1556,11 +1584,24 @@ export function registerSecretsCommand(program) {
1556
1584
  process.exit(1);
1557
1585
  }
1558
1586
  const data = (await res.json());
1559
- console.log(chalk.green("Secret input link generated:"));
1560
- console.log(`\n ${data.url}\n`);
1561
- console.log(chalk.dim(` Secret: ${data.secretName}`));
1562
- console.log(chalk.dim(` Expires: ${data.expiresAt}`));
1563
- console.log(chalk.dim(" One-time use — link is invalidated after first submission."));
1587
+ const rendered = renderSecretInputLink(data, {
1588
+ format,
1589
+ isTTY: process.stdout.isTTY === true,
1590
+ env: process.env,
1591
+ });
1592
+ if (format === "text") {
1593
+ const [heading, link, ...meta] = rendered.stdout;
1594
+ console.log(chalk.green(heading));
1595
+ console.log(link);
1596
+ for (const line of meta)
1597
+ console.log(chalk.dim(line));
1598
+ }
1599
+ else {
1600
+ for (const line of rendered.stdout)
1601
+ console.log(line);
1602
+ for (const line of rendered.stderr)
1603
+ console.error(chalk.dim(line));
1604
+ }
1564
1605
  }
1565
1606
  catch (err) {
1566
1607
  // Same shared plan-limit renderer as `secrets set` (see main.ts).