@indigoai-us/hq-cli 5.82.0 → 5.84.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,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.84.0]
6
+
7
+ ### Added
8
+
9
+ - `hq reindex` now trusts every Codex project hook owned by the active
10
+ HQ root and refreshes Grok folder trust plus the shipped user-hook
11
+ bridge. Global, plugin, and other-repository hooks remain untouched;
12
+ absent runtimes stay non-fatal and converged runs are quiet. (#293)
13
+
14
+ ## [5.83.0]
15
+
16
+ ### Added
17
+
18
+ - Checkpoint Stop-gate eligibility now also accepts outpost machine
19
+ identities whose `custom:delegatedEmail` claim carries an eligible
20
+ domain (default `getindigo.ai`, plus `HQ_CHECKPOINT_GATE_DOMAINS`),
21
+ so outposts provisioned by eligible users enforce the gate; other
22
+ orgs' outposts remain ineligible. Output/verdict stay `1`/`0` with
23
+ no claim values ever printed. (#291)
24
+
5
25
  ## [5.82.0]
6
26
 
7
27
  ### Added
@@ -318,17 +318,21 @@ function gateEligibility() {
318
318
  const tokens = JSON.parse(fs.readFileSync(tokenPath, "utf8"));
319
319
  if (typeof tokens.idToken !== "string")
320
320
  return false;
321
- const email = peekIdToken(tokens.idToken).email;
322
- if (typeof email !== "string")
323
- return false;
324
- const domain = email.split("@").at(-1)?.toLowerCase();
325
- if (!domain)
326
- return false;
321
+ const claims = peekIdToken(tokens.idToken);
322
+ const candidateEmails = [claims.email, claims["custom:delegatedEmail"]];
327
323
  const configured = (process.env.HQ_CHECKPOINT_GATE_DOMAINS ?? "")
328
324
  .split(",")
329
325
  .map((candidate) => candidate.trim().toLowerCase())
330
326
  .filter(Boolean);
331
- return domain === "getindigo.ai" || configured.includes(domain);
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);
332
336
  }
333
337
  catch {
334
338
  return false;
@@ -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.82.0",
3
+ "version": "5.84.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {