@indigoai-us/hq-cli 5.345.170 → 5.345.172

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 (142) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
  3. package/dist/command-catalog.generated.d.ts +206 -7
  4. package/dist/command-catalog.generated.js +252 -8
  5. package/dist/command-registration-plan.d.ts +7 -0
  6. package/dist/command-registration-plan.js +1 -0
  7. package/dist/commands/access.d.ts +32 -1
  8. package/dist/commands/access.js +112 -3
  9. package/dist/commands/agent-kit.d.ts +1 -1
  10. package/dist/commands/agent-kit.js +3 -1
  11. package/dist/commands/agents.js +2 -1
  12. package/dist/commands/bot.d.ts +3 -0
  13. package/dist/commands/bot.js +179 -18
  14. package/dist/commands/cloud.d.ts +58 -1
  15. package/dist/commands/cloud.js +228 -10
  16. package/dist/commands/core-company-index-scope-flag.d.ts +8 -1
  17. package/dist/commands/core-company-index-scope-flag.js +9 -2
  18. package/dist/commands/core-delegate-w1b.js +5 -2
  19. package/dist/commands/core-dm-bind-native.js +19 -4
  20. package/dist/commands/core-rebuild-index-fast.js +24 -6
  21. package/dist/commands/core.js +18 -6
  22. package/dist/commands/daemon.js +16 -1
  23. package/dist/commands/dm.d.ts +17 -3
  24. package/dist/commands/dm.js +121 -26
  25. package/dist/commands/files.d.ts +1 -0
  26. package/dist/commands/files.js +22 -4
  27. package/dist/commands/integrations-api.d.ts +4 -0
  28. package/dist/commands/integrations-api.js +18 -2
  29. package/dist/commands/integrations-connect.d.ts +10 -3
  30. package/dist/commands/integrations-connect.js +70 -6
  31. package/dist/commands/integrations-core.d.ts +23 -0
  32. package/dist/commands/integrations-core.js +100 -0
  33. package/dist/commands/integrations-manage.js +8 -1
  34. package/dist/commands/integrations.js +49 -5
  35. package/dist/commands/lanes-loop.d.ts +20 -0
  36. package/dist/commands/lanes-loop.js +69 -11
  37. package/dist/commands/lanes.js +9 -1
  38. package/dist/commands/rescue.d.ts +9 -0
  39. package/dist/commands/rescue.js +31 -0
  40. package/dist/commands/run.js +115 -40
  41. package/dist/commands/scan.d.ts +5 -1
  42. package/dist/commands/scan.js +16 -4
  43. package/dist/commands/secrets.js +50 -9
  44. package/dist/commands/sync-core.d.ts +22 -0
  45. package/dist/commands/sync-core.js +124 -0
  46. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2447 -2135
  47. package/dist/hq-cloud-sync-runner/package.json +1 -1
  48. package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
  49. package/dist/lib/agent-kit/inbox-state.js +41 -20
  50. package/dist/lib/agent-kit/kit-config.d.ts +2 -0
  51. package/dist/lib/agent-kit/kit-config.js +6 -0
  52. package/dist/lib/agent-kit/mcp/tools.js +12 -5
  53. package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
  54. package/dist/lib/agent-kit/run/heartbeat.js +6 -3
  55. package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
  56. package/dist/lib/agent-kit/run/inbox.js +42 -10
  57. package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
  58. package/dist/lib/agent-kit/run/request-timeout.js +51 -0
  59. package/dist/lib/bot/api.d.ts +1 -1
  60. package/dist/lib/bot/config.d.ts +10 -0
  61. package/dist/lib/bot/config.js +17 -1
  62. package/dist/lib/bot/fleet-local.d.ts +142 -0
  63. package/dist/lib/bot/fleet-local.js +689 -0
  64. package/dist/lib/bot/fleet-python.d.ts +47 -0
  65. package/dist/lib/bot/fleet-python.js +218 -0
  66. package/dist/lib/bot/index.d.ts +2 -0
  67. package/dist/lib/bot/index.js +2 -0
  68. package/dist/lib/bot/local-config.d.ts +3 -2
  69. package/dist/lib/bot/local-config.js +5 -2
  70. package/dist/lib/bot/promote.d.ts +10 -0
  71. package/dist/lib/bot/promote.js +3 -2
  72. package/dist/lib/bot/prompt.d.ts +6 -1
  73. package/dist/lib/bot/prompt.js +8 -3
  74. package/dist/lib/bot/runtime/index.js +8 -4
  75. package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
  76. package/dist/lib/bot/runtime/scratch-dir.js +41 -0
  77. package/dist/lib/caller-principal.d.ts +4 -0
  78. package/dist/lib/caller-principal.js +40 -0
  79. package/dist/lib/capacity/collectors/grok.js +8 -2
  80. package/dist/lib/cli-flag-keys.d.ts +2 -0
  81. package/dist/lib/cli-flag-keys.js +2 -0
  82. package/dist/lib/daemon/catalog.d.ts +12 -4
  83. package/dist/lib/daemon/catalog.js +70 -24
  84. package/dist/lib/daemon/config.d.ts +0 -7
  85. package/dist/lib/daemon/config.js +0 -32
  86. package/dist/lib/daemon/run.d.ts +5 -1
  87. package/dist/lib/daemon/run.js +48 -11
  88. package/dist/lib/daemon/supervisor.js +3 -1
  89. package/dist/lib/doctor/checks/release-links.d.ts +62 -1
  90. package/dist/lib/doctor/checks/release-links.js +188 -2
  91. package/dist/lib/doctor/fix/apply.js +54 -0
  92. package/dist/lib/doctor/fix/remediation.d.ts +8 -1
  93. package/dist/lib/doctor/fix/remediation.js +22 -0
  94. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  95. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  96. package/dist/lib/index-render/index.d.ts +9 -0
  97. package/dist/lib/index-render/index.js +65 -5
  98. package/dist/lib/index-render/session-scope.d.ts +69 -0
  99. package/dist/lib/index-render/session-scope.js +279 -0
  100. package/dist/lib/lanes/admission.js +7 -20
  101. package/dist/lib/lanes/reconcile.js +13 -15
  102. package/dist/lib/lanes/stale-lock.d.ts +50 -0
  103. package/dist/lib/lanes/stale-lock.js +105 -0
  104. package/dist/lib/lanes/store.js +11 -76
  105. package/dist/lib/notify-recipient.d.ts +10 -4
  106. package/dist/lib/notify-recipient.js +16 -4
  107. package/dist/lib/secret-input-link.d.ts +42 -0
  108. package/dist/lib/secret-input-link.js +93 -0
  109. package/dist/lib/secret-scan/range.d.ts +57 -0
  110. package/dist/lib/secret-scan/range.js +266 -0
  111. package/dist/lib/secret-scan/repo-config.d.ts +18 -3
  112. package/dist/lib/secret-scan/repo-config.js +135 -2
  113. package/dist/lib/sync-core/engine-home.d.ts +44 -0
  114. package/dist/lib/sync-core/engine-home.js +193 -0
  115. package/dist/lib/sync-core/flag.d.ts +17 -0
  116. package/dist/lib/sync-core/flag.js +47 -0
  117. package/dist/lib/sync-core/reconcile.d.ts +31 -0
  118. package/dist/lib/sync-core/reconcile.js +196 -0
  119. package/dist/lib/sync-core/release.d.ts +32 -0
  120. package/dist/lib/sync-core/release.js +140 -0
  121. package/dist/lib/sync-core/rollout-state.d.ts +24 -0
  122. package/dist/lib/sync-core/rollout-state.js +94 -0
  123. package/dist/lib/vault-sync-switches.d.ts +5 -0
  124. package/dist/lib/vault-sync-switches.js +43 -0
  125. package/dist/run/script-bound-schema.d.ts +27 -0
  126. package/dist/run/script-bound-schema.js +97 -0
  127. package/dist/secrets-env-action.d.ts +2 -0
  128. package/dist/secrets-env-action.js +7 -2
  129. package/dist/utils/cognito-session.d.ts +3 -30
  130. package/dist/utils/cognito-session.js +2 -100
  131. package/dist/utils/machine-principal-state.d.ts +38 -0
  132. package/dist/utils/machine-principal-state.js +110 -0
  133. package/dist/utils/remote-list-not-converging.d.ts +21 -0
  134. package/dist/utils/remote-list-not-converging.js +40 -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
  141. package/dist/lib/daemon/sync-core-canary.d.ts +0 -9
  142. package/dist/lib/daemon/sync-core-canary.js +0 -56
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Bind a script-locked secret release to the process that is actually run
3
+ * (feedback_0e6abcb8).
4
+ *
5
+ * `--script <path>` sends the script's path and hash to the vault, which
6
+ * releases a script-locked secret when that pair is approved. Before this
7
+ * module the command after `--` was never compared with that script, so any
8
+ * command that named an approved script received the secret.
9
+ *
10
+ * When any requested secret is script-locked (or its lock state is unknown),
11
+ * the command after `--` must be one of:
12
+ * 1. the approved script itself: `-- ./approved.sh [args...]` (realpath
13
+ * match). Its `#!` line is parsed and the interpreter it names goes
14
+ * through the same allowlist; `#!/usr/bin/env X` resolves X against a
15
+ * fixed system PATH, never the caller's PATH;
16
+ * 2. an allowlisted interpreter followed directly by the script:
17
+ * `-- bash approved.sh [args...]`, also resolved against the fixed PATH.
18
+ * Anything else (wrappers such as env, xargs, sudo, a shell `-c` string, an
19
+ * interpreter option before the script) is refused and nothing is injected.
20
+ *
21
+ * Allowlisted interpreters: sh, dash, ksh, bash (run with --noprofile
22
+ * --norc), zsh (run with -f), python3/python (run with -I -S), node. Ruby and
23
+ * perl are not allowlisted. The interpreter's realpath and every parent
24
+ * directory must be owned by root and not group- or world-writable, so
25
+ * Homebrew or other user-writable installs are refused.
26
+ *
27
+ * At spawn time the script bytes are read once, hashed, compared with the hash
28
+ * sent to the vault, written to a fresh 0700 private directory, re-hashed, and
29
+ * that copy is passed to the interpreter. The original path is exposed to the
30
+ * script as HQ_APPROVED_SCRIPT and its directory as HQ_APPROVED_SCRIPT_DIR.
31
+ * `$0` / `__file__` / `__dirname` name the copy, so a script that loads
32
+ * neighbour files relative to itself must use HQ_APPROVED_SCRIPT_DIR. The
33
+ * working directory is left as the caller's.
34
+ *
35
+ * The child environment gets a fixed system PATH and loses the variables that
36
+ * make an interpreter, the loader or the macOS xcrun shims run other code.
37
+ * The same names are refused when they arrive in the injected secrets or in
38
+ * an env-schema / env-local value, because those are merged last.
39
+ *
40
+ * The child environment is built from an allowlist (see scriptBoundChildEnv),
41
+ * not inherited from the caller.
42
+ *
43
+ * What the binding does and does not protect: it controls which entrypoint
44
+ * receives the secret. It does not isolate what that entrypoint runs. A
45
+ * same-user caller can still influence tools the script calls through files
46
+ * in HOME (for example ~/.gitconfig and fsmonitor hooks, ~/.curlrc,
47
+ * ~/.ssh/config, ~/.netrc) or through anything else the user can write. Real
48
+ * isolation needs the vault-side sandbox release (`hq secrets sandbox`).
49
+ *
50
+ * Remaining limits, documented on purpose:
51
+ * - Only the script file is hashed. Files it sources, requires or imports
52
+ * (neighbour files, modules) are not covered by the approval.
53
+ * - A process running as the same user can still race the private copy
54
+ * between its re-hash and the interpreter opening it. The window is short
55
+ * and the path is random, but same-user code is not a boundary this
56
+ * client-side check can enforce.
57
+ * - The check runs in the CLI. A modified CLI, or a direct call to the vault
58
+ * API with a self-asserted hash, is outside what it can stop.
59
+ */
60
+ import * as fs from "node:fs";
61
+ import * as os from "node:os";
62
+ import * as nodePath from "node:path";
63
+ import { createHash } from "node:crypto";
64
+ import { computeSha256 } from "./integrity.js";
65
+ export class ScriptBindingError extends Error {
66
+ constructor(message) {
67
+ super(message);
68
+ this.name = "ScriptBindingError";
69
+ }
70
+ }
71
+ /** Root-owned search path used for interpreters and for the child. */
72
+ export const FIXED_SYSTEM_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
73
+ const INTERPRETER_NAMES = new Set(["sh", "dash", "ksh", "bash", "zsh", "python", "python3", "node"]);
74
+ function interpreterKind(realPath) {
75
+ const base = nodePath.basename(realPath);
76
+ if (/^(sh|dash|ksh)$/.test(base))
77
+ return "sh";
78
+ if (base === "bash")
79
+ return "bash";
80
+ if (base === "zsh")
81
+ return "zsh";
82
+ if (/^python(3(\.\d+)?)?$/.test(base))
83
+ return "python";
84
+ if (base === "node")
85
+ return "node";
86
+ return undefined;
87
+ }
88
+ const SAFE_FLAGS = {
89
+ sh: [],
90
+ bash: ["--noprofile", "--norc"],
91
+ zsh: ["-f"],
92
+ python: ["-I", "-S"],
93
+ node: [],
94
+ };
95
+ const INJECTION_ENV_NAMES = new Set([
96
+ "BASH_ENV",
97
+ "ENV",
98
+ "SHELLOPTS",
99
+ "BASHOPTS",
100
+ "PS4",
101
+ "PROMPT_COMMAND",
102
+ "ZDOTDIR",
103
+ "CDPATH",
104
+ "IFS",
105
+ "DEVELOPER_DIR",
106
+ "SDKROOT",
107
+ "TOOLCHAINS",
108
+ "XCRUN_LOG",
109
+ // Node: code-loading switches only, so NODE_ENV and similar stay usable.
110
+ "NODE_OPTIONS",
111
+ "NODE_PATH",
112
+ "NODE_REPL_EXTERNAL_MODULE",
113
+ // zsh (even with -f) and ksh autoload functions from FPATH.
114
+ "FPATH",
115
+ // glibc loads iconv modules from GCONV_PATH and locale data from LOCPATH.
116
+ "GCONV_PATH",
117
+ "LOCPATH",
118
+ "GLIBC_TUNABLES",
119
+ "NLSPATH",
120
+ // Reserved for the values this module sets.
121
+ "PATH",
122
+ "HQ_APPROVED_SCRIPT",
123
+ "HQ_APPROVED_SCRIPT_DIR",
124
+ ]);
125
+ const INJECTION_ENV_PREFIXES = [
126
+ "BASH_FUNC_",
127
+ "PYTHON",
128
+ "RUBY",
129
+ "GEM_",
130
+ "BUNDLE_",
131
+ "PERL",
132
+ "LD_",
133
+ "DYLD_",
134
+ // OpenSSL reads OPENSSL_CONF / OPENSSL_MODULES / OPENSSL_ENGINES and can load
135
+ // engines or providers from them. GIO_MODULE_DIR / GIO_EXTRA_MODULES load
136
+ // GLib modules. SSL_CERT_* only changes trust roots and is left alone.
137
+ "OPENSSL_",
138
+ "GIO_",
139
+ // Config and helper hooks of tools a script commonly calls.
140
+ "GIT_",
141
+ "SSH_",
142
+ "CURL_",
143
+ "WGET",
144
+ ];
145
+ /**
146
+ * Caller variables a bound script may inherit. Everything else is dropped;
147
+ * PATH is replaced by FIXED_SYSTEM_PATH.
148
+ */
149
+ const INHERITED_ENV_NAMES = new Set(["HOME", "USER", "LOGNAME", "LANG", "TERM", "TZ", "TMPDIR"]);
150
+ const INHERITED_ENV_PREFIXES = ["LC_"];
151
+ /** True for a variable a bound script's environment must not take from the caller. */
152
+ export function isDeniedEnvName(name) {
153
+ return INJECTION_ENV_NAMES.has(name) || INJECTION_ENV_PREFIXES.some((prefix) => name.startsWith(prefix));
154
+ }
155
+ /**
156
+ * Names that change where a tool connects, which CA it trusts, which helper
157
+ * program it runs, or where it reads config. Injected values (secrets and
158
+ * `hq run` schema literals) may not use them in a bound launch.
159
+ */
160
+ const DANGEROUS_INJECTED_NAMES = new Set([
161
+ "NO_PROXY",
162
+ "no_proxy",
163
+ "REQUESTS_CA_BUNDLE",
164
+ "CURL_CA_BUNDLE",
165
+ "HOME",
166
+ "PAGER",
167
+ "MANPAGER",
168
+ "GIT_PAGER",
169
+ "EDITOR",
170
+ "VISUAL",
171
+ "NLSPATH",
172
+ "SHELL",
173
+ ]);
174
+ const DANGEROUS_INJECTED_PATTERNS = [
175
+ /_proxy$/i, // HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, FTP_PROXY, lower-case forms
176
+ /^SSL_/,
177
+ /^NODE_(?!ENV$)/,
178
+ /^XDG_/,
179
+ /^LESS/,
180
+ /^TERMINFO/,
181
+ // Tool config and endpoint overrides (fifth review).
182
+ /^AWS_ENDPOINT_URL/,
183
+ /^AWS_(CA_BUNDLE|CONFIG_FILE|SHARED_CREDENTIALS_FILE|PROFILE)$/,
184
+ /^KUBECONFIG$/,
185
+ /^DOCKER_/,
186
+ /^PIP_/i,
187
+ /^NPM_CONFIG_/i,
188
+ /^BROWSER$/,
189
+ /^GNUPGHOME$/,
190
+ /^TF_CLI_CONFIG_FILE$/,
191
+ /^GO(PROXY|FLAGS)$/,
192
+ /^CARGO_/,
193
+ /^SUDO_ASKPASS$/,
194
+ ];
195
+ /**
196
+ * Schema / local env literal names a bound `hq run --script` launch accepts
197
+ * (fifth review). Literals are caller-written, so instead of denying
198
+ * tool-config names one by one, only these app-level switches are allowed.
199
+ * Everything else must come from the vault through hq().
200
+ */
201
+ export const SAFE_LITERAL_NAMES = new Set([
202
+ "NODE_ENV",
203
+ "APP_ENV",
204
+ "ENVIRONMENT",
205
+ "LOG_LEVEL",
206
+ "LOG_FORMAT",
207
+ "CI",
208
+ "NO_COLOR",
209
+ "FORCE_COLOR",
210
+ ]);
211
+ /** Refuse schema literals that are not on SAFE_LITERAL_NAMES (or are denied). */
212
+ export function assertLiteralNamesAllowed(names) {
213
+ const refused = [...names].filter((name) => !SAFE_LITERAL_NAMES.has(name) || isDeniedInjectedName(name));
214
+ if (refused.length > 0) {
215
+ refuse(`schema literals ${refused.join(", ")} are not on the --script literal allowlist (${[...SAFE_LITERAL_NAMES].join(", ")}). Store other values in the vault and reference them with hq().`);
216
+ }
217
+ }
218
+ /**
219
+ * Design (feedback_0e6abcb8, fourth review): an injected name is accepted in a
220
+ * bound launch only when it is not on the loader/interpreter denylist, not
221
+ * one of the inherited allowlist names (HOME, USER, LANG, LC_*, TZ, TMPDIR,
222
+ * ...), and not on the explicit dangerous list above. Ordinary application
223
+ * names (FAKE_API_KEY, DATABASE_URL, NODE_ENV) pass.
224
+ */
225
+ export function isDeniedInjectedName(name) {
226
+ return (isDeniedEnvName(name) ||
227
+ INHERITED_ENV_NAMES.has(name) ||
228
+ INHERITED_ENV_PREFIXES.some((prefix) => name.startsWith(prefix)) ||
229
+ DANGEROUS_INJECTED_NAMES.has(name) ||
230
+ DANGEROUS_INJECTED_PATTERNS.some((re) => re.test(name)));
231
+ }
232
+ /** Refuse a bound launch whose injected values use a denied name. */
233
+ export function assertInjectedNamesAllowed(names) {
234
+ const denied = [...names].filter(isDeniedInjectedName);
235
+ if (denied.length > 0) {
236
+ refuse(`the injected variables include names a bound script may not receive (${denied.join(", ")}).`);
237
+ }
238
+ }
239
+ /**
240
+ * Root for private copies. Chosen at module load from fixed locations, never
241
+ * from the caller's TMPDIR: /tmp when it is root-owned and sticky, otherwise
242
+ * the platform default.
243
+ */
244
+ const PRIVATE_COPY_ROOT = (() => {
245
+ try {
246
+ const st = fs.statSync("/tmp");
247
+ if (st.isDirectory() && st.uid === 0 && (st.mode & 0o1000) !== 0)
248
+ return fs.realpathSync("/tmp");
249
+ }
250
+ catch {
251
+ // fall through
252
+ }
253
+ return os.platform() === "win32" ? os.tmpdir() : "/tmp";
254
+ })();
255
+ export function privateCopyRoot() {
256
+ return PRIVATE_COPY_ROOT;
257
+ }
258
+ function refuse(reason) {
259
+ throw new ScriptBindingError(`Refusing to inject script-locked secrets: ${reason} With --script, the command after -- must be the approved script itself (for example -- ./script.sh args) or an allowlisted system interpreter followed directly by it (for example -- bash script.sh args).`);
260
+ }
261
+ function realpathOrRefuse(target, what) {
262
+ try {
263
+ return fs.realpathSync(target);
264
+ }
265
+ catch {
266
+ refuse(`${what} '${target}' could not be resolved.`);
267
+ }
268
+ }
269
+ function isExecutableFile(candidate) {
270
+ try {
271
+ if (!fs.statSync(candidate).isFile())
272
+ return false;
273
+ fs.accessSync(candidate, fs.constants.X_OK);
274
+ return true;
275
+ }
276
+ catch {
277
+ return false;
278
+ }
279
+ }
280
+ function lookupOnFixedPath(name) {
281
+ for (const dir of FIXED_SYSTEM_PATH.split(":")) {
282
+ const candidate = nodePath.join(dir, name);
283
+ if (isExecutableFile(candidate))
284
+ return candidate;
285
+ }
286
+ return undefined;
287
+ }
288
+ /**
289
+ * True when realPath and every parent directory are owned by root and not
290
+ * group- or world-writable.
291
+ */
292
+ export function isRootOwnedChain(realPath) {
293
+ let current = realPath;
294
+ for (;;) {
295
+ let st;
296
+ try {
297
+ st = fs.lstatSync(current);
298
+ }
299
+ catch {
300
+ return false;
301
+ }
302
+ if (st.uid !== 0 || (st.mode & 0o022) !== 0)
303
+ return false;
304
+ const parent = nodePath.dirname(current);
305
+ if (parent === current)
306
+ return true;
307
+ current = parent;
308
+ }
309
+ }
310
+ function trustedInterpreter(candidate, label) {
311
+ const real = realpathOrRefuse(candidate, "interpreter");
312
+ const kind = interpreterKind(real);
313
+ if (!kind)
314
+ refuse(`interpreter '${label}' is not allowlisted.`);
315
+ if (!isRootOwnedChain(real)) {
316
+ refuse(`interpreter '${label}' resolves to a location that is not root-owned or is writable by other users.`);
317
+ }
318
+ return { path: real, kind };
319
+ }
320
+ function interpreterFromShebang(scriptRealPath) {
321
+ let head;
322
+ try {
323
+ const fd = fs.openSync(scriptRealPath, "r");
324
+ try {
325
+ const buf = Buffer.alloc(256);
326
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
327
+ head = buf.subarray(0, n).toString("utf8");
328
+ }
329
+ finally {
330
+ fs.closeSync(fd);
331
+ }
332
+ }
333
+ catch {
334
+ refuse("the approved script could not be read.");
335
+ }
336
+ if (!head.startsWith("#!"))
337
+ refuse("the approved script has no #! line; run it through an interpreter instead.");
338
+ const line = head.slice(2).split("\n")[0].trim();
339
+ const tokens = line.split(/\s+/).filter(Boolean);
340
+ if (tokens.length === 0 || !nodePath.isAbsolute(tokens[0]))
341
+ refuse("the script's #! line does not name an absolute interpreter.");
342
+ const first = realpathOrRefuse(tokens[0], "#! interpreter");
343
+ if (nodePath.basename(first) === "env") {
344
+ if (!isRootOwnedChain(first))
345
+ refuse("the script's #! env is not root-owned.");
346
+ if (tokens.length !== 2 || tokens[1].startsWith("-") || tokens[1].includes("/")) {
347
+ refuse("the script's #!/usr/bin/env line must name exactly one interpreter, with no options.");
348
+ }
349
+ const found = lookupOnFixedPath(tokens[1]);
350
+ if (!found)
351
+ refuse(`#! interpreter '${tokens[1]}' was not found on the system PATH.`);
352
+ return trustedInterpreter(found, tokens[1]);
353
+ }
354
+ if (tokens.length !== 1)
355
+ refuse("the script's #! line passes interpreter options, which are not allowed.");
356
+ return trustedInterpreter(tokens[0], tokens[0]);
357
+ }
358
+ /**
359
+ * Resolve the command after `--` against the approved script. Throws
360
+ * ScriptBindingError when the command is not exactly the script invocation.
361
+ */
362
+ export async function resolveScriptBoundCommand(scriptPath, childArgs) {
363
+ if (childArgs.length === 0)
364
+ refuse("no command was given.");
365
+ const scriptRealPath = realpathOrRefuse(nodePath.resolve(scriptPath), "script");
366
+ try {
367
+ if (!fs.statSync(scriptRealPath).isFile())
368
+ refuse(`script '${scriptPath}' is not a regular file.`);
369
+ }
370
+ catch (err) {
371
+ if (err instanceof ScriptBindingError)
372
+ throw err;
373
+ refuse(`script '${scriptPath}' could not be read.`);
374
+ }
375
+ const [first, ...rest] = childArgs;
376
+ let interpreter;
377
+ let scriptArgs;
378
+ if (first.includes("/")) {
379
+ if (realpathOrRefuse(nodePath.resolve(first), "command") !== scriptRealPath) {
380
+ refuse("the command after -- is not the approved script.");
381
+ }
382
+ interpreter = interpreterFromShebang(scriptRealPath);
383
+ scriptArgs = rest;
384
+ }
385
+ else if (INTERPRETER_NAMES.has(first)) {
386
+ const found = lookupOnFixedPath(first);
387
+ if (!found)
388
+ refuse(`interpreter '${first}' was not found on the system PATH.`);
389
+ interpreter = trustedInterpreter(found, first);
390
+ const [scriptArg, ...more] = rest;
391
+ if (scriptArg === undefined || scriptArg.startsWith("-")) {
392
+ refuse("interpreter options and inline code are not allowed; the approved script must be the interpreter's first argument.");
393
+ }
394
+ if (realpathOrRefuse(nodePath.resolve(scriptArg), "script argument") !== scriptRealPath) {
395
+ refuse("the interpreter's first argument is not the approved script.");
396
+ }
397
+ scriptArgs = more;
398
+ }
399
+ else {
400
+ refuse("the command after -- is not the approved script or an allowlisted interpreter.");
401
+ }
402
+ return {
403
+ scriptRealPath,
404
+ interpreterPath: interpreter.path,
405
+ interpreterArgs: SAFE_FLAGS[interpreter.kind],
406
+ scriptArgs,
407
+ sha256: await computeSha256(scriptRealPath),
408
+ };
409
+ }
410
+ /** Compare the script on disk with the hash sent to the vault. */
411
+ export async function assertScriptUnchanged(bound, attestedSha256) {
412
+ let current;
413
+ try {
414
+ current = await computeSha256(bound.scriptRealPath);
415
+ }
416
+ catch {
417
+ refuse("the approved script could not be re-read.");
418
+ }
419
+ if (current !== bound.sha256 || (attestedSha256 !== undefined && current !== attestedSha256)) {
420
+ refuse("the approved script changed after it was hashed.");
421
+ }
422
+ }
423
+ function sha256Hex(bytes) {
424
+ return createHash("sha256").update(bytes).digest("hex");
425
+ }
426
+ /**
427
+ * Read the script once, check its hash, copy those bytes into a fresh 0700
428
+ * directory, re-hash the copy, and return the spawn target that runs the copy.
429
+ */
430
+ export function prepareBoundSpawn(bound, attestedSha256) {
431
+ let bytes;
432
+ try {
433
+ bytes = fs.readFileSync(bound.scriptRealPath);
434
+ }
435
+ catch {
436
+ refuse("the approved script could not be re-read before it ran.");
437
+ }
438
+ const sha = sha256Hex(bytes);
439
+ if (sha !== bound.sha256 || (attestedSha256 !== undefined && sha !== attestedSha256)) {
440
+ refuse("the approved script changed after it was hashed.");
441
+ }
442
+ const dir = fs.mkdtempSync(nodePath.join(PRIVATE_COPY_ROOT, "hq-approved-script-"));
443
+ let removeSignalHandlers = () => undefined;
444
+ const cleanup = () => {
445
+ removeSignalHandlers();
446
+ try {
447
+ fs.rmSync(dir, { recursive: true, force: true });
448
+ }
449
+ catch {
450
+ // best effort
451
+ }
452
+ };
453
+ removeSignalHandlers = cleanupOnParentSignals(cleanup);
454
+ try {
455
+ fs.chmodSync(dir, 0o700);
456
+ const copyPath = nodePath.join(dir, nodePath.basename(bound.scriptRealPath));
457
+ fs.writeFileSync(copyPath, bytes, { flag: "wx", mode: 0o500 });
458
+ if (sha256Hex(fs.readFileSync(copyPath)) !== sha)
459
+ refuse("the private copy of the approved script did not match.");
460
+ return {
461
+ file: bound.interpreterPath,
462
+ args: [...bound.interpreterArgs, copyPath, ...bound.scriptArgs],
463
+ copyPath,
464
+ cleanup,
465
+ };
466
+ }
467
+ catch (err) {
468
+ cleanup();
469
+ if (err instanceof ScriptBindingError)
470
+ throw err;
471
+ refuse("a private copy of the approved script could not be written.");
472
+ }
473
+ }
474
+ /**
475
+ * Remove the private copy when the parent gets SIGINT, SIGTERM or SIGHUP, or
476
+ * exits, then re-raise the signal so the default termination still happens.
477
+ * SIGKILL cannot be handled; a copy left by it stays in the 0700 temp
478
+ * directory until the OS cleans the temp dir.
479
+ */
480
+ export function cleanupOnParentSignals(cleanup, proc = process) {
481
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
482
+ const handlers = new Map();
483
+ const onExit = () => cleanup();
484
+ const remove = () => {
485
+ for (const [sig, handler] of handlers)
486
+ proc.removeListener(sig, handler);
487
+ handlers.clear();
488
+ proc.removeListener("exit", onExit);
489
+ };
490
+ for (const sig of signals) {
491
+ const handler = () => {
492
+ cleanup();
493
+ remove();
494
+ proc.kill(proc.pid, sig);
495
+ };
496
+ handlers.set(sig, handler);
497
+ proc.once(sig, handler);
498
+ }
499
+ proc.once("exit", onExit);
500
+ return remove;
501
+ }
502
+ /**
503
+ * Child environment for a bound script, built from an allowlist: HOME, USER,
504
+ * LOGNAME, LANG, LC_*, TERM, TZ and TMPDIR from the caller, a fixed PATH, and
505
+ * the HQ_APPROVED_* variables. Injected secrets and schema values are added by
506
+ * the caller after a denylist check.
507
+ */
508
+ export function scriptBoundChildEnv(env, scriptRealPath) {
509
+ const out = {};
510
+ for (const [name, value] of Object.entries(env)) {
511
+ if (!INHERITED_ENV_NAMES.has(name) && !INHERITED_ENV_PREFIXES.some((prefix) => name.startsWith(prefix)))
512
+ continue;
513
+ out[name] = value;
514
+ }
515
+ out.PATH = FIXED_SYSTEM_PATH;
516
+ if (scriptRealPath) {
517
+ out.HQ_APPROVED_SCRIPT = scriptRealPath;
518
+ out.HQ_APPROVED_SCRIPT_DIR = nodePath.dirname(scriptRealPath);
519
+ }
520
+ return out;
521
+ }
522
+ /**
523
+ * Whether the release must be bound to the script. Only rows the vault marked
524
+ * `scriptLock.mode: "off"` are exempt; enforced or unknown fails closed.
525
+ */
526
+ export function requiresScriptBinding(keys, lockModes) {
527
+ return keys.some((key) => lockModes.get(key) !== "off");
528
+ }
529
+ /**
530
+ * Resolve the binding before any fetch. A refusal is held, not thrown, so a
531
+ * caller whose secrets turn out not to be script-locked keeps working.
532
+ */
533
+ export async function planScriptBinding(scriptPath, childArgs) {
534
+ if (!scriptPath)
535
+ return undefined;
536
+ try {
537
+ return { bound: await resolveScriptBoundCommand(scriptPath, childArgs) };
538
+ }
539
+ catch (err) {
540
+ if (err instanceof ScriptBindingError)
541
+ return { error: err };
542
+ throw err;
543
+ }
544
+ }
545
+ /**
546
+ * Pick what to spawn once the vault has answered. Unlocked secrets keep the
547
+ * caller's command unchanged; enforced or unknown lock state requires the
548
+ * bound script and throws ScriptBindingError otherwise.
549
+ */
550
+ export function selectChildLaunch(plan, keys, lockModes, attestedSha256, childArgs, env, injected) {
551
+ if (!plan || !requiresScriptBinding(keys, lockModes)) {
552
+ const [file, ...args] = childArgs;
553
+ return { file, args, env: { ...env, ...injected }, cleanup: () => undefined };
554
+ }
555
+ if (plan.error)
556
+ throw plan.error;
557
+ // Secrets and schema values are merged last, so they must not carry a
558
+ // control variable (PATH, BASH_ENV, NODE_OPTIONS, ...) into the script.
559
+ assertInjectedNamesAllowed(Object.keys(injected));
560
+ const prepared = prepareBoundSpawn(plan.bound, attestedSha256);
561
+ return {
562
+ file: prepared.file,
563
+ args: prepared.args,
564
+ env: { ...scriptBoundChildEnv(env, plan.bound.scriptRealPath), ...injected },
565
+ cleanup: prepared.cleanup,
566
+ };
567
+ }
568
+ //# sourceMappingURL=script-bound-exec.js.map
@@ -0,0 +1,92 @@
1
+ /** Default no-progress limit: off. Opt in with --max-idle / HQ_SYNC_MAX_IDLE. */
2
+ export declare const DEFAULT_SYNC_MAX_IDLE_SECONDS = 0;
3
+ /** Default total cap: off. */
4
+ export declare const DEFAULT_SYNC_MAX_RUNTIME_SECONDS = 0;
5
+ /** How long the worker backstop waits past a limit before it kills. */
6
+ export declare const SYNC_WATCHDOG_BACKSTOP_GRACE_MS = 60000;
7
+ /** Upper bound on the failed-sync report after a timeout, before exit 124. */
8
+ export declare const SYNC_WATCHDOG_REPORT_GRACE_MS = 10000;
9
+ /** Exit code for a run the in-process watchdog ended (same as coreutils `timeout`). */
10
+ export declare const SYNC_WATCHDOG_EXIT_CODE = 124;
11
+ export type SyncWatchdogReason = "idle" | "runtime";
12
+ export declare class SyncWatchdogTimeoutError extends Error {
13
+ readonly code = "SYNC_WATCHDOG_TIMEOUT";
14
+ readonly reason: SyncWatchdogReason;
15
+ readonly seconds: number;
16
+ constructor(reason: SyncWatchdogReason, seconds: number, label: string);
17
+ }
18
+ export declare function syncWatchdogMessage(reason: SyncWatchdogReason, seconds: number, label: string): string;
19
+ export interface SyncWatchdogLimits {
20
+ /** No-progress limit in seconds; 0 = off. */
21
+ idleSeconds: number;
22
+ /** Total cap in seconds; 0 = off. */
23
+ maxRuntimeSeconds: number;
24
+ }
25
+ /** Resolve both limits. A flag wins, then the environment, then the default. */
26
+ export declare function resolveSyncWatchdogLimits(raw: {
27
+ maxIdle?: string;
28
+ maxRuntime?: string;
29
+ }, env?: NodeJS.ProcessEnv): SyncWatchdogLimits;
30
+ /** Test seam: replace the monotonic clock. `null` restores process.hrtime. */
31
+ export declare function setSyncWatchdogClockForTesting(clock: (() => number) | null): void;
32
+ /** Record sync progress. Cheap; safe to call on every row or event. */
33
+ export declare function noteSyncProgress(): void;
34
+ /** Last recorded progress time, for tests and the in-process check. */
35
+ export declare function lastSyncProgressAt(): number;
36
+ /** The fields of hq-cloud's shared sync-progress snapshot the probe reads. */
37
+ export interface SyncProgressSnapshotLike {
38
+ pid?: number;
39
+ phase?: string;
40
+ company?: string | null;
41
+ filesTotal?: number;
42
+ filesDone?: number;
43
+ conflicts?: number;
44
+ startedAt?: string;
45
+ }
46
+ /**
47
+ * Build a probe that calls {@link noteSyncProgress} whenever THIS process's
48
+ * sync-progress snapshot changes. hq-cloud rewrites that snapshot when a leg
49
+ * starts, when it plans, and for every file transferred or skipped, for both
50
+ * push and pull, so a moving sync keeps resetting the idle limit without any
51
+ * change to the engine's progress recorders. Snapshots written by another
52
+ * process (the menubar runner) are ignored. A single file transfer is not
53
+ * seen until it completes; the engine's own per-body idle timeouts cover a
54
+ * transfer that stalls.
55
+ */
56
+ export declare function createSnapshotProgressProbe(read: () => SyncProgressSnapshotLike | null, pid?: number): () => void;
57
+ export interface SyncBackstop {
58
+ cancel(): void;
59
+ }
60
+ export interface SyncBackstopOptions {
61
+ /** Shared Float64 progress clock (index 0). */
62
+ clock: Float64Array;
63
+ startedAt: number;
64
+ idleMs: number;
65
+ maxRuntimeMs: number;
66
+ graceMs: number;
67
+ idleMessage: string;
68
+ runtimeMessage: string;
69
+ }
70
+ export type StartSyncBackstop = (options: SyncBackstopOptions) => SyncBackstop;
71
+ export declare const startWorkerBackstop: StartSyncBackstop;
72
+ /** Test seam: replace the worker backstop. `null` restores the real one. */
73
+ export declare function setSyncBackstopStarterForTesting(start: StartSyncBackstop | null): void;
74
+ export interface SyncWatchdogOptions {
75
+ idleMs: number;
76
+ maxRuntimeMs: number;
77
+ label: string;
78
+ startBackstop?: StartSyncBackstop;
79
+ backstopGraceMs?: number;
80
+ /** How often the in-process check runs. Defaults to a quarter of the smallest limit, at most 30s. */
81
+ checkIntervalMs?: number;
82
+ /** Called on every check before the limits are tested, e.g. {@link createSnapshotProgressProbe}. */
83
+ probeProgress?: () => void;
84
+ }
85
+ /**
86
+ * Run `run` under the watchdog. Resolves or rejects with `run`'s outcome when
87
+ * it finishes within the limits. Otherwise rejects with
88
+ * {@link SyncWatchdogTimeoutError} and leaves the backstop armed: the caller
89
+ * is expected to exit the process. With both limits 0 it just runs `run`.
90
+ */
91
+ export declare function withSyncWatchdog<T>(run: () => Promise<T>, options: SyncWatchdogOptions): Promise<T>;
92
+ //# sourceMappingURL=sync-watchdog.d.ts.map