@indigoai-us/hq-cli 5.83.0 → 5.85.0

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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.85.0]
6
+
7
+ ### Changed
8
+
9
+ - Checkpoint Stop-gate eligibility is now runtime-specific: Claude Code is
10
+ enabled for every HQ account, Codex is enabled only for the designated
11
+ operator account (including delegated identities), and other Codex accounts
12
+ and runtimes remain disabled. Runtime-specific verdict caches prevent one
13
+ host's eligibility from leaking into another. (#295)
14
+
15
+ ## [5.84.0]
16
+
17
+ ### Added
18
+
19
+ - `hq reindex` now trusts every Codex project hook owned by the active
20
+ HQ root and refreshes Grok folder trust plus the shipped user-hook
21
+ bridge. Global, plugin, and other-repository hooks remain untouched;
22
+ absent runtimes stay non-fatal and converged runs are quiet. (#293)
23
+
5
24
  ## [5.83.0]
6
25
 
7
26
  ### Added
@@ -307,12 +307,23 @@ function writeStamps(liveRoot, sessionId) {
307
307
  fs.writeFileSync(stampPath, timestamp);
308
308
  return stampPaths;
309
309
  }
310
- function gateEligibility() {
310
+ const CODEX_CHECKPOINT_ACCOUNT = "hassaan@getindigo.ai";
311
+ function checkpointGateRuntime() {
312
+ const runtime = (process.env.HQ_CHECKPOINT_RUNTIME ?? "claude").trim().toLowerCase();
313
+ if (runtime === "claude" || runtime === "codex")
314
+ return runtime;
315
+ return "other";
316
+ }
317
+ function gateEligibility(runtime) {
311
318
  const forced = process.env.HQ_CHECKPOINT_GATE;
312
319
  if (forced === "0")
313
320
  return false;
314
321
  if (forced === "1")
315
322
  return true;
323
+ if (runtime === "claude")
324
+ return true;
325
+ if (runtime !== "codex")
326
+ return false;
316
327
  try {
317
328
  const tokenPath = path.join(homedir(), ".hq", "cognito-tokens.json");
318
329
  const tokens = JSON.parse(fs.readFileSync(tokenPath, "utf8"));
@@ -320,19 +331,7 @@ function gateEligibility() {
320
331
  return false;
321
332
  const claims = peekIdToken(tokens.idToken);
322
333
  const candidateEmails = [claims.email, claims["custom:delegatedEmail"]];
323
- const configured = (process.env.HQ_CHECKPOINT_GATE_DOMAINS ?? "")
324
- .split(",")
325
- .map((candidate) => candidate.trim().toLowerCase())
326
- .filter(Boolean);
327
- const hasEligibleDomain = (candidate) => {
328
- if (typeof candidate !== "string")
329
- return false;
330
- const domain = candidate.split("@").at(-1)?.toLowerCase();
331
- if (!domain)
332
- return false;
333
- return domain === "getindigo.ai" || configured.includes(domain);
334
- };
335
- return candidateEmails.some(hasEligibleDomain);
334
+ return candidateEmails.some((candidate) => typeof candidate === "string" && candidate.toLowerCase() === CODEX_CHECKPOINT_ACCOUNT);
336
335
  }
337
336
  catch {
338
337
  return false;
@@ -341,8 +340,9 @@ function gateEligibility() {
341
340
  function writeGateVerdict(liveRoot) {
342
341
  const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
343
342
  fs.mkdirSync(stateDir, { recursive: true });
344
- const eligible = gateEligibility();
345
- fs.writeFileSync(path.join(stateDir, "checkpoint-gate-eligible"), eligible ? "1" : "0");
343
+ const runtime = checkpointGateRuntime();
344
+ const eligible = gateEligibility(runtime);
345
+ fs.writeFileSync(path.join(stateDir, `checkpoint-gate-eligible-${runtime}`), eligible ? "1" : "0");
346
346
  printResult(eligible ? "eligible" : "ineligible");
347
347
  }
348
348
  function backendOnPath(name) {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * hq reindex — surface namespaced skills, mirror the personal overlay into
3
- * core/, and regenerate the workers registry.
3
+ * core/, regenerate the workers registry, and converge HQ-owned hook trust for
4
+ * Codex and Grok.
4
5
  *
5
6
  * Thin wrapper over @indigoai-us/hq-cloud's reindex(), which execs the bundled
6
7
  * scripts/reindex.sh against the HQ root. This command is what the hq-core
@@ -27,6 +28,7 @@ import * as fs from 'node:fs';
27
28
  import * as path from 'node:path';
28
29
  import * as yaml from 'js-yaml';
29
30
  import { reindex, rescue } from '@indigoai-us/hq-cloud';
31
+ import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
30
32
  import { findHqRoot } from '../utils/manifest.js';
31
33
  const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
32
34
  const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
@@ -207,11 +209,11 @@ export function registerReindexCommand(program) {
207
209
  program
208
210
  .command('reindex')
209
211
  .alias('master-sync')
210
- .description('Surface namespaced skills, mirror the personal overlay into core/, and regenerate the workers registry')
212
+ .description('Surface namespaced skills, regenerate the workers registry, and trust HQ hooks for Codex and Grok')
211
213
  .option('--repo-root <path>', 'HQ root to operate on (defaults to the current directory)')
212
214
  .option('--from-hook', 'Invoked from a Claude/Codex lifecycle hook: never wait on the per-root operation lock — if a sync/rescue holds it, skip this reindex instead of blocking the session (equivalent to --lock-timeout 0)')
213
215
  .option('--lock-timeout <seconds>', 'Bound the wait for the per-root operation lock (seconds). 0 = refuse immediately; omitted = wait indefinitely (the interactive default). --from-hook implies 0; an explicit --lock-timeout wins.')
214
- .action((opts) => {
216
+ .action(async (opts) => {
215
217
  // Resolve the lock-wait bound. An explicit --lock-timeout wins; otherwise
216
218
  // --from-hook forces 0 (refuse-fast) so a hook fired while a sync/rescue
217
219
  // holds the shared per-root operation lock can never stall the agent.
@@ -233,7 +235,10 @@ export function registerReindexCommand(program) {
233
235
  process.env.HQ_OP_LOCK_TIMEOUT = String(lockTimeoutSec);
234
236
  }
235
237
  const { status } = reindex({ repoRoot: opts.repoRoot });
236
- repairExtremeHookDrift(resolveHqRoot(opts.repoRoot), status === 0);
238
+ const hqRoot = resolveHqRoot(opts.repoRoot);
239
+ repairExtremeHookDrift(hqRoot, status === 0);
240
+ if (status === 0)
241
+ await trustHqRuntimeHooks(hqRoot);
237
242
  process.exit(status);
238
243
  });
239
244
  }
@@ -0,0 +1,35 @@
1
+ export interface CodexRpcClient {
2
+ request(method: string, params: unknown): Promise<unknown>;
3
+ close(): Promise<void>;
4
+ }
5
+ type SpawnSyncLike = (command: string, args: string[], options: {
6
+ cwd: string;
7
+ encoding: 'utf8';
8
+ stdio: 'pipe';
9
+ }) => {
10
+ status: number | null;
11
+ stdout?: string | Buffer | null;
12
+ stderr?: string | Buffer | null;
13
+ error?: Error;
14
+ };
15
+ export interface HookTrustDependencies {
16
+ createCodexClient: (cwd: string) => Promise<CodexRpcClient>;
17
+ spawnSync: SpawnSyncLike;
18
+ homeDir?: () => string;
19
+ }
20
+ export interface RuntimeHookTrustResult {
21
+ runtime: 'codex' | 'grok';
22
+ status: 'trusted' | 'unchanged' | 'skipped' | 'failed';
23
+ trusted: number;
24
+ reason?: string;
25
+ }
26
+ /** Start a short-lived Codex app-server and complete its JSONL handshake. */
27
+ export declare function createCodexAppServerClient(cwd: string, executable?: string, args?: string[]): Promise<CodexRpcClient>;
28
+ /** Trust only hooks declared by this HQ root's project `.codex/` layer. */
29
+ export declare function trustCodexProjectHooks(hqRoot: string, deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
30
+ /** Grok trusts hooks at folder scope; the shipped installer also refreshes the HQ bridge. */
31
+ export declare function trustGrokProjectHooks(hqRoot: string, deps?: HookTrustDependencies): RuntimeHookTrustResult;
32
+ /** Converge hook trust without turning an absent runtime into a reindex failure. */
33
+ export declare function trustHqRuntimeHooks(hqRoot: string, deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult[]>;
34
+ export {};
35
+ //# sourceMappingURL=hook-trust.d.ts.map
@@ -0,0 +1,314 @@
1
+ import { spawn, spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import * as readline from 'node:readline';
6
+ const CODEX_REQUEST_TIMEOUT_MS = 10_000;
7
+ function errorMessage(value) {
8
+ return value instanceof Error ? value.message : String(value);
9
+ }
10
+ function asObject(value) {
11
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
12
+ ? value
13
+ : undefined;
14
+ }
15
+ function isHookMetadata(value) {
16
+ const hook = asObject(value);
17
+ return (hook !== undefined &&
18
+ typeof hook.key === 'string' &&
19
+ typeof hook.currentHash === 'string' &&
20
+ typeof hook.enabled === 'boolean' &&
21
+ typeof hook.isManaged === 'boolean' &&
22
+ typeof hook.source === 'string' &&
23
+ typeof hook.sourcePath === 'string' &&
24
+ ['managed', 'untrusted', 'trusted', 'modified'].includes(String(hook.trustStatus)));
25
+ }
26
+ function hooksFromListResponse(response) {
27
+ const data = asObject(response)?.data;
28
+ if (!Array.isArray(data))
29
+ return { hooks: [], errors: ['hooks/list returned no data array'] };
30
+ const hooks = [];
31
+ const errors = [];
32
+ for (const rawEntry of data) {
33
+ const entry = asObject(rawEntry);
34
+ if (!entry)
35
+ continue;
36
+ if (Array.isArray(entry.hooks))
37
+ hooks.push(...entry.hooks.filter(isHookMetadata));
38
+ if (Array.isArray(entry.errors)) {
39
+ for (const rawError of entry.errors) {
40
+ const parsed = asObject(rawError);
41
+ if (typeof parsed?.message === 'string')
42
+ errors.push(parsed.message);
43
+ else if (typeof rawError === 'string')
44
+ errors.push(rawError);
45
+ }
46
+ }
47
+ }
48
+ return { hooks, errors };
49
+ }
50
+ function isWithin(child, parent) {
51
+ const relative = path.relative(parent, child);
52
+ return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
53
+ }
54
+ function hqProjectHooks(hqRoot, hooks) {
55
+ const codexLayer = path.resolve(hqRoot, '.codex');
56
+ return hooks.filter((hook) => hook.source === 'project' &&
57
+ !hook.isManaged &&
58
+ isWithin(path.resolve(hook.sourcePath), codexLayer));
59
+ }
60
+ /** Start a short-lived Codex app-server and complete its JSONL handshake. */
61
+ export async function createCodexAppServerClient(cwd, executable = 'codex', args = ['app-server']) {
62
+ const child = spawn(executable, args, {
63
+ cwd,
64
+ stdio: ['pipe', 'pipe', 'pipe'],
65
+ });
66
+ const lines = readline.createInterface({ input: child.stdout });
67
+ const pending = new Map();
68
+ let nextId = 0;
69
+ let stderr = '';
70
+ let exited;
71
+ child.stderr.on('data', (chunk) => {
72
+ if (stderr.length < 4_096)
73
+ stderr += String(chunk).slice(0, 4_096 - stderr.length);
74
+ });
75
+ const rejectPending = (error) => {
76
+ exited = error;
77
+ for (const request of pending.values()) {
78
+ clearTimeout(request.timer);
79
+ request.reject(error);
80
+ }
81
+ pending.clear();
82
+ };
83
+ child.on('error', (error) => rejectPending(error));
84
+ child.on('exit', (code, signal) => {
85
+ if (pending.size === 0)
86
+ return;
87
+ const detail = stderr.trim();
88
+ rejectPending(new Error(`Codex app-server exited before replying (${signal ? `signal ${signal}` : `status ${code ?? 'unknown'}`})${detail ? `: ${detail}` : ''}`));
89
+ });
90
+ lines.on('line', (line) => {
91
+ let message;
92
+ try {
93
+ message = asObject(JSON.parse(line));
94
+ }
95
+ catch {
96
+ return;
97
+ }
98
+ if (!message || typeof message.id !== 'number')
99
+ return;
100
+ const request = pending.get(message.id);
101
+ if (!request)
102
+ return;
103
+ pending.delete(message.id);
104
+ clearTimeout(request.timer);
105
+ const rpcError = asObject(message.error);
106
+ if (rpcError) {
107
+ request.reject(new Error(String(rpcError.message ?? 'Codex app-server request failed')));
108
+ }
109
+ else {
110
+ request.resolve(message.result);
111
+ }
112
+ });
113
+ const send = (message) => {
114
+ if (exited)
115
+ throw exited;
116
+ child.stdin.write(`${JSON.stringify(message)}\n`);
117
+ };
118
+ const request = (method, params) => {
119
+ const id = nextId++;
120
+ return new Promise((resolve, reject) => {
121
+ const timer = setTimeout(() => {
122
+ pending.delete(id);
123
+ reject(new Error(`Codex app-server request timed out: ${method}`));
124
+ }, CODEX_REQUEST_TIMEOUT_MS);
125
+ pending.set(id, { resolve, reject, timer });
126
+ try {
127
+ send({ method, id, params });
128
+ }
129
+ catch (error) {
130
+ clearTimeout(timer);
131
+ pending.delete(id);
132
+ reject(error instanceof Error ? error : new Error(String(error)));
133
+ }
134
+ });
135
+ };
136
+ await request('initialize', {
137
+ clientInfo: {
138
+ name: 'hq_cli',
139
+ title: 'HQ CLI',
140
+ version: '1',
141
+ },
142
+ });
143
+ send({ method: 'initialized', params: {} });
144
+ return {
145
+ request,
146
+ async close() {
147
+ lines.close();
148
+ if (!child.killed) {
149
+ child.stdin.end();
150
+ child.kill();
151
+ }
152
+ },
153
+ };
154
+ }
155
+ const DEFAULT_DEPS = {
156
+ createCodexClient: createCodexAppServerClient,
157
+ spawnSync: nodeSpawnSync,
158
+ homeDir: () => process.env.HOME ?? os.homedir(),
159
+ };
160
+ /** Trust only hooks declared by this HQ root's project `.codex/` layer. */
161
+ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
162
+ if (!fs.existsSync(path.join(hqRoot, '.codex'))) {
163
+ return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'project .codex layer absent' };
164
+ }
165
+ let client;
166
+ try {
167
+ client = await deps.createCodexClient(hqRoot);
168
+ const discovered = hooksFromListResponse(await client.request('hooks/list', { cwds: [hqRoot] }));
169
+ if (discovered.errors.length > 0) {
170
+ return {
171
+ runtime: 'codex',
172
+ status: 'failed',
173
+ trusted: 0,
174
+ reason: discovered.errors.join('; '),
175
+ };
176
+ }
177
+ const projectHooks = hqProjectHooks(hqRoot, discovered.hooks);
178
+ if (projectHooks.length === 0) {
179
+ return {
180
+ runtime: 'codex',
181
+ status: 'skipped',
182
+ trusted: 0,
183
+ reason: 'no HQ project hooks discovered',
184
+ };
185
+ }
186
+ const pending = projectHooks.filter((hook) => hook.trustStatus === 'untrusted' || hook.trustStatus === 'modified');
187
+ if (pending.length === 0) {
188
+ return { runtime: 'codex', status: 'unchanged', trusted: 0 };
189
+ }
190
+ const state = Object.fromEntries(pending.map((hook) => [
191
+ hook.key,
192
+ {
193
+ enabled: hook.enabled,
194
+ trusted_hash: hook.currentHash,
195
+ },
196
+ ]));
197
+ await client.request('config/batchWrite', {
198
+ edits: [{ keyPath: 'hooks.state', value: state, mergeStrategy: 'upsert' }],
199
+ reloadUserConfig: true,
200
+ });
201
+ const verified = hooksFromListResponse(await client.request('hooks/list', { cwds: [hqRoot] }));
202
+ const verifiedByKey = new Map(verified.hooks.map((hook) => [hook.key, hook]));
203
+ const stillPending = pending
204
+ .filter((hook) => verifiedByKey.get(hook.key)?.trustStatus !== 'trusted')
205
+ .map((hook) => hook.key);
206
+ if (verified.errors.length > 0 || stillPending.length > 0) {
207
+ return {
208
+ runtime: 'codex',
209
+ status: 'failed',
210
+ trusted: 0,
211
+ reason: [...verified.errors, ...stillPending].join('; '),
212
+ };
213
+ }
214
+ return { runtime: 'codex', status: 'trusted', trusted: pending.length };
215
+ }
216
+ catch (error) {
217
+ return { runtime: 'codex', status: 'failed', trusted: 0, reason: errorMessage(error) };
218
+ }
219
+ finally {
220
+ if (client) {
221
+ try {
222
+ await client.close();
223
+ }
224
+ catch {
225
+ // Trust state is already persisted; shutdown is best-effort.
226
+ }
227
+ }
228
+ }
229
+ }
230
+ function regexEscape(value) {
231
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
232
+ }
233
+ function filesMatch(left, right) {
234
+ try {
235
+ return fs.readFileSync(left).equals(fs.readFileSync(right));
236
+ }
237
+ catch {
238
+ return false;
239
+ }
240
+ }
241
+ function grokTrustIsCurrent(hqRoot, home) {
242
+ const grokHome = path.join(home, '.grok');
243
+ const rootPattern = regexEscape(hqRoot);
244
+ let modernTrust;
245
+ let legacyTrust;
246
+ let config;
247
+ try {
248
+ modernTrust = fs.readFileSync(path.join(grokHome, 'trusted_folders.toml'), 'utf8');
249
+ legacyTrust = fs.readFileSync(path.join(grokHome, 'trusted-hook-projects'), 'utf8');
250
+ config = fs.readFileSync(path.join(grokHome, 'config.toml'), 'utf8');
251
+ }
252
+ catch {
253
+ return false;
254
+ }
255
+ const folderTrusted = new RegExp(`^\\[folders\\."${rootPattern}"\\]\\s*\\n(?:(?!\\[)[^\\n]*\\n)*trusted\\s*=\\s*true\\b`, 'm').test(modernTrust);
256
+ const legacyTrusted = legacyTrust.split(/\r?\n/).includes(hqRoot);
257
+ const claudeCompatQuiet = /^\[compat\.claude\]\s*\n(?:(?!\[)[^\n]*\n)*hooks\s*=\s*false\b/m.test(config);
258
+ const sourceHooks = path.join(hqRoot, '.grok', 'hooks');
259
+ const userHooks = path.join(grokHome, 'hooks');
260
+ return (folderTrusted &&
261
+ legacyTrusted &&
262
+ claudeCompatQuiet &&
263
+ filesMatch(path.join(sourceHooks, 'hq-grok-user-bridge.sh'), path.join(userHooks, 'hq-hq-bridge.sh')) &&
264
+ filesMatch(path.join(sourceHooks, 'hq-grok-user-bridge.json'), path.join(userHooks, 'hq-hq-bridge.json')));
265
+ }
266
+ /** Grok trusts hooks at folder scope; the shipped installer also refreshes the HQ bridge. */
267
+ export function trustGrokProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
268
+ const script = path.join(hqRoot, 'core', 'scripts', 'grok-trust.sh');
269
+ if (!fs.existsSync(script)) {
270
+ return { runtime: 'grok', status: 'skipped', trusted: 0, reason: 'grok-trust.sh absent' };
271
+ }
272
+ const home = deps.homeDir?.() ?? process.env.HOME ?? os.homedir();
273
+ if (grokTrustIsCurrent(hqRoot, home)) {
274
+ return { runtime: 'grok', status: 'unchanged', trusted: 0 };
275
+ }
276
+ const result = deps.spawnSync('bash', [script], {
277
+ cwd: hqRoot,
278
+ encoding: 'utf8',
279
+ stdio: 'pipe',
280
+ });
281
+ if (result.error || result.status !== 0) {
282
+ const detail = String(result.stderr ?? '').trim().split('\n')[0];
283
+ return {
284
+ runtime: 'grok',
285
+ status: 'failed',
286
+ trusted: 0,
287
+ reason: result.error?.message ??
288
+ (detail || `grok-trust.sh exited ${result.status ?? 'unknown'}`),
289
+ };
290
+ }
291
+ return { runtime: 'grok', status: 'trusted', trusted: 1 };
292
+ }
293
+ /** Converge hook trust without turning an absent runtime into a reindex failure. */
294
+ export async function trustHqRuntimeHooks(hqRoot, deps = DEFAULT_DEPS) {
295
+ const results = [
296
+ await trustCodexProjectHooks(hqRoot, deps),
297
+ trustGrokProjectHooks(hqRoot, deps),
298
+ ];
299
+ for (const result of results) {
300
+ if (result.status === 'trusted') {
301
+ if (result.runtime === 'codex') {
302
+ console.log(`reindex: trusted ${result.trusted} Codex HQ hook${result.trusted === 1 ? '' : 's'}`);
303
+ }
304
+ else {
305
+ console.log('reindex: refreshed Grok HQ hook trust');
306
+ }
307
+ }
308
+ else if (result.status === 'failed') {
309
+ console.warn(`reindex: could not trust ${result.runtime} HQ hooks: ${result.reason ?? 'unknown error'}`);
310
+ }
311
+ }
312
+ return results;
313
+ }
314
+ //# sourceMappingURL=hook-trust.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.83.0",
3
+ "version": "5.85.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {