@indigoai-us/hq-cli 5.99.0 → 5.99.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.99.1] — 2026-08-12
6
+
5
7
  ## [5.99.0] — 2026-08-11
6
8
 
7
9
  ### Added
@@ -552,7 +552,7 @@ export function siblingArgs(backend, prompt) {
552
552
  // journal helper, the queue-draining `mv` — hits an approval gate with
553
553
  // no human attached; headless `claude -p` then denies it and the run
554
554
  // stalls having written no report. `bypassPermissions` is the same
555
- // unattended posture codex gets from `-s workspace-write` and grok from
555
+ // unattended posture codex gets from `-s danger-full-access` and grok from
556
556
  // `--always-approve --sandbox workspace`. Hooks still fire natively —
557
557
  // this only removes the interactive approval gate, not the hook chain.
558
558
  "--permission-mode",
@@ -570,7 +570,7 @@ export function siblingArgs(backend, prompt) {
570
570
  // scan, the journal helper — then hits an approval gate with no human
571
571
  // attached, and grok cancels the whole run: `stopReason: cancelled,
572
572
  // cancellationCategory: PermissionCancelled`. Unattended approval plus
573
- // a sandbox is the same posture codex gets from `-s workspace-write`.
573
+ // a sandbox is the same posture codex gets from `-s danger-full-access`.
574
574
  "--always-approve",
575
575
  "--sandbox",
576
576
  "workspace",
@@ -581,8 +581,16 @@ export function siblingArgs(backend, prompt) {
581
581
  return [
582
582
  "exec",
583
583
  "--skip-git-repo-check",
584
+ // danger-full-access, NOT workspace-write. `-s workspace-write` force-
585
+ // enables Codex's restrictive sandbox, which denies the temp/cache/socket
586
+ // writes the maintenance sibling needs on macOS (git/Xcode cache, zsh/asdf
587
+ // heredocs) and cannot even initialize bubblewrap on some Linux hosts
588
+ // (`bwrap: loopback: Failed RTM_NEWADDR`) — HQ harness finding 2.3, which
589
+ // wedged the sibling before it wrote any report. HQ's safety boundary is
590
+ // its hooks, which still fire here via --dangerously-bypass-hook-trust;
591
+ // the sandbox is not what guards the HQ root.
584
592
  "-s",
585
- "workspace-write",
593
+ "danger-full-access",
586
594
  "--dangerously-bypass-hook-trust",
587
595
  "-m",
588
596
  CODEX_SIBLING_MODEL,
@@ -44,6 +44,8 @@ import { createWorktree } from "../lib/core-utils/worktree.js";
44
44
  import { runCodexSkillBridgeCommand } from "../lib/core-utils/codex-skill-bridge-entry.js";
45
45
  import { archiveOldThreads } from "../lib/core-utils/archive-old-threads.js";
46
46
  import { qmdReindexAfterSync } from "../lib/core-utils/qmd-reindex-after-sync.js";
47
+ import { softTimeoutCommand } from "../lib/core-utils/soft-timeout.js";
48
+ import { timeoutGuardCommand } from "../lib/core-utils/timeout-guard.js";
47
49
  /**
48
50
  * These commands are now native implementations. Their retained shell assets
49
51
  * are deliberately claimed in SCAFFOLD_ONLY_ASSETS: loose HQ trees still ship
@@ -421,6 +423,39 @@ export function registerCoreCommands(program) {
421
423
  runCommand(entry, args, cmd);
422
424
  });
423
425
  }
426
+ // `hq core soft-timeout <interval> [--hard-cap <spec>] [--label <name>] -- <cmd…>`
427
+ // Async and bespoke (the NATIVE_UTILITY_COMMANDS table is synchronous), since
428
+ // it spawns a child and warns over time. Warn-don't-kill primitive for
429
+ // finding 2.1 — the CLI-hosted twin of core/scripts/lib/soft-timeout.sh.
430
+ core
431
+ .command("soft-timeout")
432
+ .description("Run a command under a soft (warn, don't kill) timeout")
433
+ // Passthrough: the wrapped command owns everything after `--`.
434
+ .allowUnknownOption()
435
+ .allowExcessArguments()
436
+ .helpOption(false)
437
+ .argument("[args...]", "<interval> [--hard-cap <spec>] [--label <name>] -- <cmd...>")
438
+ .action(async (args = [], _opts, cmd) => {
439
+ const operands = cmd.args.length > 0 ? cmd.args : args;
440
+ const code = await softTimeoutCommand(operands);
441
+ if (code !== 0)
442
+ process.exit(code);
443
+ });
444
+ // `hq core timeout-guard` — PreToolUse decision for the foreground-timeout
445
+ // guard (finding 2.1). Reads the hook JSON on stdin and exits 2 to block, 0
446
+ // to allow. The shipped .claude hook is a thin shim over this.
447
+ core
448
+ .command("timeout-guard")
449
+ .description("PreToolUse guard: block over-ceiling foreground timeouts (reads hook JSON on stdin)")
450
+ .helpOption(false)
451
+ .action(async () => {
452
+ const chunks = [];
453
+ for await (const chunk of process.stdin)
454
+ chunks.push(chunk);
455
+ const code = timeoutGuardCommand(Buffer.concat(chunks).toString("utf8"));
456
+ if (code !== 0)
457
+ process.exit(code);
458
+ });
424
459
  return core;
425
460
  }
426
461
  //# sourceMappingURL=core.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `hq core soft-timeout <interval> [--hard-cap <spec>] [--label <name>] -- <cmd…>`
3
+ *
4
+ * Run a command under a SOFT timeout: WARN, don't kill. This is the CLI-hosted
5
+ * form of the shell primitive `core/scripts/lib/soft-timeout.sh`, so HQ scripts
6
+ * and hooks can call `hq core soft-timeout …` directly instead of shelling out
7
+ * to a bundled script.
8
+ *
9
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events):
10
+ * a fixed deadline SIGTERMs an operation still making progress and loses the
11
+ * in-flight work. The fix is warn-and-continue — on each interval the command
12
+ * keeps running and a SOFT-TIMEOUT notice is written to stderr so whoever is
13
+ * watching decides (wait / background / kill), instead of the runner deciding
14
+ * by killing progressing work.
15
+ *
16
+ * Contract (identical to the shell primitive):
17
+ * - `<interval>` — warn every interval. Bare number = seconds; 90s / 2m / 1h.
18
+ * - `--hard-cap <spec>` — OPTIONAL safety ceiling for unattended callers.
19
+ * Pure soft has no cap and never terminates. At the cap the command is
20
+ * SIGTERM'd, then SIGKILL'd after a short grace, and the exit code is 124.
21
+ * - `--label <name>` — name shown in the notice (default: the command basename).
22
+ * - Exit status: the command's own, verbatim — except 124 on a hard-cap.
23
+ * A soft warning alone never changes the exit status.
24
+ */
25
+ import { spawn } from "node:child_process";
26
+ /** Parse a duration spec (Ns / Nm / Nh / bare N=seconds) to integer seconds. */
27
+ export declare function parseDurationSecs(spec: string): number | null;
28
+ export interface ParsedSoftTimeoutArgs {
29
+ intervalSecs: number;
30
+ hardCapSecs: number | null;
31
+ label: string | null;
32
+ command: string[];
33
+ }
34
+ /** Parse the `hq core soft-timeout` argv. Throws a plain Error on misuse. */
35
+ export declare function parseSoftTimeoutArgs(argv: string[]): ParsedSoftTimeoutArgs;
36
+ export interface RunSoftTimeoutDeps {
37
+ spawnFn?: typeof spawn;
38
+ stderr?: NodeJS.WritableStream;
39
+ now?: () => Date;
40
+ /** Signal a whole process group by (leader) pid. Defaults to process.kill(-pid). */
41
+ killGroup?: (pid: number, sig: NodeJS.Signals) => void;
42
+ }
43
+ /**
44
+ * Execute the command under the soft timeout. Resolves to the exit code the
45
+ * process should exit with. Never rejects on a timeout — a warning is only a
46
+ * stderr line. Rejects only on spawn failure.
47
+ */
48
+ export declare function runSoftTimeout(parsed: ParsedSoftTimeoutArgs, deps?: RunSoftTimeoutDeps): Promise<number>;
49
+ /**
50
+ * Entry point for the `hq core soft-timeout` command. Parses argv, runs, and
51
+ * returns the exit code (never throws for a timeout; throws only on misuse or
52
+ * spawn failure, which the caller maps to a non-zero exit).
53
+ */
54
+ export declare function softTimeoutCommand(argv: string[]): Promise<number>;
55
+ //# sourceMappingURL=soft-timeout.d.ts.map
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `hq core soft-timeout <interval> [--hard-cap <spec>] [--label <name>] -- <cmd…>`
3
+ *
4
+ * Run a command under a SOFT timeout: WARN, don't kill. This is the CLI-hosted
5
+ * form of the shell primitive `core/scripts/lib/soft-timeout.sh`, so HQ scripts
6
+ * and hooks can call `hq core soft-timeout …` directly instead of shelling out
7
+ * to a bundled script.
8
+ *
9
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events):
10
+ * a fixed deadline SIGTERMs an operation still making progress and loses the
11
+ * in-flight work. The fix is warn-and-continue — on each interval the command
12
+ * keeps running and a SOFT-TIMEOUT notice is written to stderr so whoever is
13
+ * watching decides (wait / background / kill), instead of the runner deciding
14
+ * by killing progressing work.
15
+ *
16
+ * Contract (identical to the shell primitive):
17
+ * - `<interval>` — warn every interval. Bare number = seconds; 90s / 2m / 1h.
18
+ * - `--hard-cap <spec>` — OPTIONAL safety ceiling for unattended callers.
19
+ * Pure soft has no cap and never terminates. At the cap the command is
20
+ * SIGTERM'd, then SIGKILL'd after a short grace, and the exit code is 124.
21
+ * - `--label <name>` — name shown in the notice (default: the command basename).
22
+ * - Exit status: the command's own, verbatim — except 124 on a hard-cap.
23
+ * A soft warning alone never changes the exit status.
24
+ */
25
+ import { spawn } from "node:child_process";
26
+ import { basename } from "node:path";
27
+ import { constants as osConstants } from "node:os";
28
+ /** Parse a duration spec (Ns / Nm / Nh / bare N=seconds) to integer seconds. */
29
+ export function parseDurationSecs(spec) {
30
+ const m = /^([0-9]+)([smh]?)$/.exec(spec);
31
+ if (!m)
32
+ return null;
33
+ const n = Number(m[1]);
34
+ switch (m[2]) {
35
+ case "":
36
+ case "s":
37
+ return n;
38
+ case "m":
39
+ return n * 60;
40
+ case "h":
41
+ return n * 3600;
42
+ default:
43
+ return null;
44
+ }
45
+ }
46
+ /** Parse the `hq core soft-timeout` argv. Throws a plain Error on misuse. */
47
+ export function parseSoftTimeoutArgs(argv) {
48
+ const args = [...argv];
49
+ if (args.length === 0 || args[0].startsWith("--")) {
50
+ throw new Error("soft-timeout: first argument must be the warn interval");
51
+ }
52
+ const intervalSecs = parseDurationSecs(args.shift());
53
+ if (intervalSecs === null || intervalSecs <= 0) {
54
+ throw new Error("soft-timeout: interval must be a positive duration (e.g. 120, 2m)");
55
+ }
56
+ let hardCapSecs = null;
57
+ let label = null;
58
+ // Consume our own options; the FIRST non-option token (or an explicit `--`)
59
+ // begins the wrapped command. `--` is optional because Commander strips a
60
+ // single leading `--` before these args reach us — so `hq core soft-timeout
61
+ // 30 -- sleep 60` arrives here as `30 sleep 60`, and treating the first
62
+ // non-option as the command start makes both forms parse identically.
63
+ while (args.length > 0) {
64
+ const tok = args[0];
65
+ if (tok === "--") {
66
+ args.shift();
67
+ break;
68
+ }
69
+ if (tok === "--hard-cap") {
70
+ args.shift();
71
+ const spec = args.shift();
72
+ const secs = spec ? parseDurationSecs(spec) : null;
73
+ if (secs === null || secs <= 0)
74
+ throw new Error(`soft-timeout: invalid --hard-cap '${spec ?? ""}'`);
75
+ hardCapSecs = secs;
76
+ }
77
+ else if (tok === "--label") {
78
+ args.shift();
79
+ label = args.shift() ?? null;
80
+ if (label === null)
81
+ throw new Error("soft-timeout: --label needs a value");
82
+ }
83
+ else {
84
+ break; // first non-option token: the wrapped command starts here
85
+ }
86
+ }
87
+ if (args.length === 0) {
88
+ throw new Error("soft-timeout: no command given (expected: soft-timeout <interval> [...] -- cmd)");
89
+ }
90
+ return { intervalSecs, hardCapSecs, label: label ?? null, command: args };
91
+ }
92
+ function hhmmss(now) {
93
+ return now.toISOString().slice(11, 19);
94
+ }
95
+ /**
96
+ * Execute the command under the soft timeout. Resolves to the exit code the
97
+ * process should exit with. Never rejects on a timeout — a warning is only a
98
+ * stderr line. Rejects only on spawn failure.
99
+ */
100
+ export function runSoftTimeout(parsed, deps = {}) {
101
+ const spawnFn = deps.spawnFn ?? spawn;
102
+ const err = deps.stderr ?? process.stderr;
103
+ const now = deps.now ?? (() => new Date());
104
+ const killGroup = deps.killGroup ?? ((pid, sig) => process.kill(-pid, sig));
105
+ const { intervalSecs, hardCapSecs, command } = parsed;
106
+ const label = parsed.label ?? basename(command[0]);
107
+ return new Promise((resolve, reject) => {
108
+ // detached: the child leads its own process group so a hard cap can signal
109
+ // the WHOLE group (child + descendants), not just the child — otherwise an
110
+ // orphaned grandchild (e.g. run-project's builder) keeps running after the
111
+ // wrapper returns. stdio inherited so the command's stdin/stdout/stderr —
112
+ // including piped or interactive input — pass through unchanged.
113
+ const child = spawnFn(command[0], command.slice(1), { stdio: "inherit", detached: true });
114
+ let capped = false;
115
+ let ticks = 0;
116
+ // Signal the child's process group when we can (detached leader), else the
117
+ // child alone. Guarded: the group may already be gone.
118
+ const signalTree = (sig) => {
119
+ try {
120
+ if (typeof child.pid === "number")
121
+ killGroup(child.pid, sig);
122
+ else
123
+ child.kill(sig);
124
+ }
125
+ catch {
126
+ try {
127
+ child.kill(sig);
128
+ }
129
+ catch {
130
+ /* already gone */
131
+ }
132
+ }
133
+ };
134
+ const warner = setInterval(() => {
135
+ ticks += 1;
136
+ const elapsed = ticks * intervalSecs;
137
+ err.write(`[${hhmmss(now())}] SOFT-TIMEOUT: ${label} still running after ${elapsed}s ` +
138
+ `(${ticks}× ${intervalSecs}s window) — exceeded its window; NOT killed. ` +
139
+ `Decide: wait / background / kill (kill ${child.pid}).\n`);
140
+ }, intervalSecs * 1000);
141
+ // The hard cap runs on its OWN timer, independent of the warning interval,
142
+ // so a cap shorter than (or not a multiple of) the interval still fires on
143
+ // time rather than at the next interval boundary.
144
+ let capTimer = null;
145
+ let killTimer = null;
146
+ if (hardCapSecs !== null) {
147
+ capTimer = setTimeout(() => {
148
+ capped = true;
149
+ err.write(`[${hhmmss(now())}] SOFT-TIMEOUT: ${label} hit hard cap ${hardCapSecs}s — sending SIGTERM.\n`);
150
+ signalTree("SIGTERM");
151
+ killTimer = setTimeout(() => signalTree("SIGKILL"), 5000);
152
+ }, hardCapSecs * 1000);
153
+ }
154
+ const cleanup = () => {
155
+ clearInterval(warner);
156
+ if (capTimer)
157
+ clearTimeout(capTimer);
158
+ if (killTimer)
159
+ clearTimeout(killTimer);
160
+ };
161
+ child.on("error", (e) => {
162
+ cleanup();
163
+ reject(e);
164
+ });
165
+ child.on("exit", (code, signal) => {
166
+ cleanup();
167
+ if (capped) {
168
+ resolve(124);
169
+ }
170
+ else if (code !== null) {
171
+ resolve(code);
172
+ }
173
+ else {
174
+ // Killed by a signal (external SIGINT/SIGHUP/SIGTERM/…): mirror the
175
+ // shell convention of 128 + signal number for whichever signal it was,
176
+ // so callers keep the real termination status (e.g. 130 for SIGINT).
177
+ const n = signal ? osConstants.signals[signal] : undefined;
178
+ resolve(typeof n === "number" ? 128 + n : 1);
179
+ }
180
+ });
181
+ });
182
+ }
183
+ /**
184
+ * Entry point for the `hq core soft-timeout` command. Parses argv, runs, and
185
+ * returns the exit code (never throws for a timeout; throws only on misuse or
186
+ * spawn failure, which the caller maps to a non-zero exit).
187
+ */
188
+ export async function softTimeoutCommand(argv) {
189
+ let parsed;
190
+ try {
191
+ parsed = parseSoftTimeoutArgs(argv);
192
+ }
193
+ catch (e) {
194
+ process.stderr.write(`${e.message}\n`);
195
+ return 2;
196
+ }
197
+ try {
198
+ return await runSoftTimeout(parsed);
199
+ }
200
+ catch (e) {
201
+ process.stderr.write(`soft-timeout: failed to run command: ${e.message}\n`);
202
+ return 127;
203
+ }
204
+ }
205
+ //# sourceMappingURL=soft-timeout.js.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `hq core timeout-guard` — PreToolUse decision for the foreground-timeout guard.
3
+ *
4
+ * The hook logic that used to live in
5
+ * `.claude/hooks/block-foreground-timeout-over-harness-ceiling.sh` now lives
6
+ * here so it can be maintained (and gated) in one place; the shipped hook is a
7
+ * thin shim that pipes the PreToolUse JSON to this command and blocks iff it
8
+ * exits 2.
9
+ *
10
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events): a
11
+ * FOREGROUND shell tool call is SIGTERM'd at the harness's outer deadline
12
+ * (~2 min default, 10 min max) regardless of any longer timeout it declares, so
13
+ * the in-flight work is lost (exit 143). We can't change the harness, so the
14
+ * guard blocks a long foreground declaration and steers it to a background run
15
+ * (no outer deadline, auto-notifies). It fires for Claude, Codex, and Grok —
16
+ * their shell tool calls all reach the hook normalized to `tool_input.command`.
17
+ *
18
+ * Rollout gate: for now the guard only acts for `@getindigo.ai` HQ users; every
19
+ * other identity (and machine identities / logged-out) is allowed through.
20
+ *
21
+ * Exit codes: 0 = allow, 2 = block.
22
+ */
23
+ /**
24
+ * Worst-case inner deadline (seconds) declared in a command: a `timeout` /
25
+ * `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
26
+ * counts) or a `perl -e 'alarm(N)…'` deadline. Segment on shell separators and
27
+ * only treat a segment's LEADING command word as an invocation, so the word
28
+ * `timeout` inside a quoted argument (e.g. `printf 'uses timeout 601s'`) does
29
+ * not trip it.
30
+ */
31
+ export declare function parseInnerDeadlineSecs(command: string): number;
32
+ export interface TimeoutGuardInput {
33
+ command: string;
34
+ toolTimeoutMs?: number;
35
+ runInBackground?: boolean;
36
+ }
37
+ export interface GuardVerdict {
38
+ block: boolean;
39
+ reason?: string;
40
+ }
41
+ /**
42
+ * Pure decision: does this foreground declaration exceed the deadline it can
43
+ * actually secure? Compares the inner deadline against the EFFECTIVE ceiling —
44
+ * the declared tool timeout (capped at the harness max) or the 2-minute default
45
+ * when none is declared — rather than always against the 10-minute max.
46
+ */
47
+ export declare function evaluateTimeoutGuard(input: TimeoutGuardInput): GuardVerdict;
48
+ export interface TimeoutGuardDeps {
49
+ /** Resolve the current HQ user email for the rollout gate. Default: cached id token. */
50
+ getEmail?: () => string | undefined;
51
+ env?: NodeJS.ProcessEnv;
52
+ stderr?: NodeJS.WritableStream;
53
+ }
54
+ /** Whether the guard is active for this identity (rollout gate). */
55
+ export declare function isGatedUser(email: string | undefined): boolean;
56
+ /**
57
+ * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
58
+ * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
59
+ * missing identity fails OPEN (allow), because a hook must not break tool calls.
60
+ */
61
+ export declare function timeoutGuardCommand(stdin: string, deps?: TimeoutGuardDeps): number;
62
+ //# sourceMappingURL=timeout-guard.d.ts.map
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `hq core timeout-guard` — PreToolUse decision for the foreground-timeout guard.
3
+ *
4
+ * The hook logic that used to live in
5
+ * `.claude/hooks/block-foreground-timeout-over-harness-ceiling.sh` now lives
6
+ * here so it can be maintained (and gated) in one place; the shipped hook is a
7
+ * thin shim that pipes the PreToolUse JSON to this command and blocks iff it
8
+ * exits 2.
9
+ *
10
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events): a
11
+ * FOREGROUND shell tool call is SIGTERM'd at the harness's outer deadline
12
+ * (~2 min default, 10 min max) regardless of any longer timeout it declares, so
13
+ * the in-flight work is lost (exit 143). We can't change the harness, so the
14
+ * guard blocks a long foreground declaration and steers it to a background run
15
+ * (no outer deadline, auto-notifies). It fires for Claude, Codex, and Grok —
16
+ * their shell tool calls all reach the hook normalized to `tool_input.command`.
17
+ *
18
+ * Rollout gate: for now the guard only acts for `@getindigo.ai` HQ users; every
19
+ * other identity (and machine identities / logged-out) is allowed through.
20
+ *
21
+ * Exit codes: 0 = allow, 2 = block.
22
+ */
23
+ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
24
+ import { peekIdToken } from "../../utils/id-token.js";
25
+ // Observed Claude Code Bash-tool bounds. The tool `timeout` (ms) raises the
26
+ // deadline up to the max; with none declared the default applies.
27
+ const HARNESS_DEFAULT_MS = 120_000; // 2 min
28
+ const HARNESS_MAX_MS = 600_000; // 10 min
29
+ const GATE_DOMAIN = "@getindigo.ai";
30
+ function basename(token) {
31
+ const i = token.lastIndexOf("/");
32
+ return i >= 0 ? token.slice(i + 1) : token;
33
+ }
34
+ /** Duration spec (Ns / Nm / Nh / Nd / bare N=seconds) → seconds, or 0. */
35
+ function durationToSecs(tok) {
36
+ const m = /^([0-9]+)([smhd]?)$/.exec(tok);
37
+ if (!m)
38
+ return 0;
39
+ const n = Number(m[1]);
40
+ switch (m[2]) {
41
+ case "":
42
+ case "s":
43
+ return n;
44
+ case "m":
45
+ return n * 60;
46
+ case "h":
47
+ return n * 3600;
48
+ case "d":
49
+ return n * 86400;
50
+ default:
51
+ return 0;
52
+ }
53
+ }
54
+ /**
55
+ * Worst-case inner deadline (seconds) declared in a command: a `timeout` /
56
+ * `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
57
+ * counts) or a `perl -e 'alarm(N)…'` deadline. Segment on shell separators and
58
+ * only treat a segment's LEADING command word as an invocation, so the word
59
+ * `timeout` inside a quoted argument (e.g. `printf 'uses timeout 601s'`) does
60
+ * not trip it.
61
+ */
62
+ export function parseInnerDeadlineSecs(command) {
63
+ let worst = 0;
64
+ for (const rawSeg of command.split(/[;|&()]/)) {
65
+ const seg = rawSeg.trim();
66
+ if (!seg)
67
+ continue;
68
+ const words = seg.split(/\s+/);
69
+ let i = 0;
70
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))
71
+ i++; // skip env assigns
72
+ if (i >= words.length)
73
+ continue;
74
+ const cmd = basename(words[i]);
75
+ if (cmd !== "timeout" && cmd !== "gtimeout")
76
+ continue;
77
+ i++;
78
+ let dur = "";
79
+ while (i < words.length) {
80
+ const t = words[i];
81
+ if (t === "--foreground" || t === "--preserve-status" || t === "-v" || t === "--verbose") {
82
+ i++;
83
+ continue;
84
+ }
85
+ if (t === "-s" || t === "--signal" || t === "-k" || t === "--kill-after") {
86
+ i += 2;
87
+ continue;
88
+ }
89
+ if (t.startsWith("-")) {
90
+ i++;
91
+ continue;
92
+ }
93
+ dur = t;
94
+ break;
95
+ }
96
+ if (dur)
97
+ worst = Math.max(worst, durationToSecs(dur));
98
+ }
99
+ // perl alarm() — spans separators, so match on the raw command.
100
+ const mMin = /alarm\(\s*([0-9]+)\s*\*\s*60/.exec(command);
101
+ if (mMin)
102
+ worst = Math.max(worst, Number(mMin[1]) * 60);
103
+ else {
104
+ const mSec = /alarm\(?\s*([0-9]+)/.exec(command);
105
+ if (mSec)
106
+ worst = Math.max(worst, Number(mSec[1]));
107
+ }
108
+ return worst;
109
+ }
110
+ /**
111
+ * Pure decision: does this foreground declaration exceed the deadline it can
112
+ * actually secure? Compares the inner deadline against the EFFECTIVE ceiling —
113
+ * the declared tool timeout (capped at the harness max) or the 2-minute default
114
+ * when none is declared — rather than always against the 10-minute max.
115
+ */
116
+ export function evaluateTimeoutGuard(input) {
117
+ const { command, toolTimeoutMs } = input;
118
+ if (input.runInBackground)
119
+ return { block: false };
120
+ if (toolTimeoutMs !== undefined && toolTimeoutMs > HARNESS_MAX_MS) {
121
+ return {
122
+ block: true,
123
+ reason: `the Bash tool 'timeout' parameter is ${toolTimeoutMs}ms (> the ${HARNESS_MAX_MS}ms / 10-minute harness ceiling)`,
124
+ };
125
+ }
126
+ const effectiveMs = toolTimeoutMs !== undefined ? Math.min(toolTimeoutMs, HARNESS_MAX_MS) : HARNESS_DEFAULT_MS;
127
+ const innerSecs = parseInnerDeadlineSecs(command);
128
+ if (innerSecs > 0 && innerSecs * 1000 > effectiveMs) {
129
+ const ceilingDesc = toolTimeoutMs !== undefined
130
+ ? `the declared ${Math.round(effectiveMs / 1000)}s tool deadline`
131
+ : `the ~${HARNESS_DEFAULT_MS / 1000}s default foreground deadline`;
132
+ return {
133
+ block: true,
134
+ reason: `an inner ${innerSecs}s deadline (timeout/gtimeout/alarm) exceeds ${ceilingDesc}`,
135
+ };
136
+ }
137
+ return { block: false };
138
+ }
139
+ const BLOCK_MESSAGE = (reason) => `BLOCKED: this FOREGROUND command declares a timeout past the harness ceiling —
140
+ ${reason}.
141
+
142
+ A foreground shell tool call is SIGTERM'd at the harness's outer deadline
143
+ (~2 min default, 10 min max) regardless of any longer timeout you declared. It
144
+ will die with exit 143 ("timed out after 10m 0s") and lose whatever it was doing
145
+ — the largest harness-friction cluster in the 2026-08-10 team analysis
146
+ (finding 2.1, 1,393 events).
147
+
148
+ Do this instead: launch it as a background task (run_in_background: true).
149
+ Background tasks survive turn boundaries, have no outer deadline, and auto-notify
150
+ on completion. (\`hq core soft-timeout\` warns without killing for work you
151
+ supervise yourself.)
152
+
153
+ Policy: hq-foreground-timeout-killed-by-harness-deadline.
154
+ Bypass for one sanctioned foreground run: prefix HQ_ALLOW_LONG_FOREGROUND=1.`;
155
+ function defaultEmail() {
156
+ try {
157
+ const cached = loadCachedTokens();
158
+ if (!cached)
159
+ return undefined;
160
+ const email = peekIdToken(cached.idToken).email;
161
+ return typeof email === "string" ? email : undefined;
162
+ }
163
+ catch {
164
+ return undefined;
165
+ }
166
+ }
167
+ /** Whether the guard is active for this identity (rollout gate). */
168
+ export function isGatedUser(email) {
169
+ return typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
170
+ }
171
+ /**
172
+ * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
173
+ * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
174
+ * missing identity fails OPEN (allow), because a hook must not break tool calls.
175
+ */
176
+ export function timeoutGuardCommand(stdin, deps = {}) {
177
+ const env = deps.env ?? process.env;
178
+ const err = deps.stderr ?? process.stderr;
179
+ let payload;
180
+ try {
181
+ payload = JSON.parse(stdin);
182
+ }
183
+ catch {
184
+ return 0;
185
+ }
186
+ const ti = payload?.tool_input ?? {};
187
+ const command = typeof ti.command === "string" ? ti.command : "";
188
+ if (!command)
189
+ return 0;
190
+ // The inline escape hatch and the env escape hatch both bypass.
191
+ if (env.HQ_ALLOW_LONG_FOREGROUND === "1")
192
+ return 0;
193
+ if (/(^|\s)HQ_ALLOW_LONG_FOREGROUND=1(\s|$)/.test(command))
194
+ return 0;
195
+ // Rollout gate: only act for @getindigo.ai identities.
196
+ const getEmail = deps.getEmail ?? defaultEmail;
197
+ if (!isGatedUser(getEmail()))
198
+ return 0;
199
+ const runInBackground = ti.run_in_background === true;
200
+ const toolTimeoutMs = typeof ti.timeout === "number" ? ti.timeout : undefined;
201
+ const verdict = evaluateTimeoutGuard({ command, toolTimeoutMs, runInBackground });
202
+ if (!verdict.block)
203
+ return 0;
204
+ err.write(`${BLOCK_MESSAGE(verdict.reason ?? "declared timeout exceeds the harness ceiling")}\n`);
205
+ return 2;
206
+ }
207
+ //# sourceMappingURL=timeout-guard.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.99.0",
3
+ "version": "5.99.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {