@indigoai-us/hq-cli 5.107.0 → 5.107.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.107.1] — 2026-09-03
6
+
5
7
  ## [5.107.0] — 2026-09-03
6
8
 
7
9
  ### Added
@@ -28,6 +28,7 @@ export interface SecretMetadata {
28
28
  export interface SecretLoadSuccessRow extends SecretMetadata {
29
29
  name: string;
30
30
  value?: string;
31
+ version?: number;
31
32
  }
32
33
  export interface SecretLoadResponse {
33
34
  secrets: SecretLoadSuccessRow[];
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { writeSync } from "node:fs";
5
5
  import * as nodePath from "node:path";
6
6
  import { ensureCognitoToken } from "../utils/cognito-session.js";
7
- import { DEFAULT_SECRETS_CACHE_TTL_MS, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
7
+ import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
8
8
  import { computeSha256 } from "../utils/integrity.js";
9
9
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
10
10
  import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
@@ -545,7 +545,12 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
545
545
  }
546
546
  const resolved = new Map();
547
547
  const requested = [...new Set(keys)];
548
+ const cachedCandidates = new Set(requested.filter((name) => hasCacheEntry(companyUid, name)));
548
549
  try {
550
+ // The batch endpoint is the only non-capturing authorization path that also
551
+ // evaluates the supplied usage metadata. It covers warm and cold names in
552
+ // bounded requests, then a warm name may use its locally authenticated
553
+ // plaintext only when the returned version and current policy TTL agree.
549
554
  for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
550
555
  const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
551
556
  const chunkNames = new Set(chunk);
@@ -572,6 +577,9 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
572
577
  typeof body.code === "string") {
573
578
  throw new Error(message);
574
579
  }
580
+ if (res.status === 403) {
581
+ throw new Error(`Failed to fetch secret '${chunk[0]}': No read permission`);
582
+ }
575
583
  throw new Error(`Failed to batch-load secrets: ${message}`);
576
584
  }
577
585
  const data = (await res.json());
@@ -601,8 +609,26 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
601
609
  continue;
602
610
  }
603
611
  const cacheTtlMs = normalizeCacheTtlMs(s);
612
+ const cached = cachedCandidates.has(s.name) &&
613
+ cacheTtlMs > 0 &&
614
+ typeof s.version === "number" &&
615
+ Number.isSafeInteger(s.version) &&
616
+ s.version > 0
617
+ ? readCache(companyUid, s.name, s.version, cacheTtlMs)
618
+ : null;
619
+ if (cached !== null) {
620
+ resolved.set(s.name, cached);
621
+ continue;
622
+ }
604
623
  if (cacheTtlMs > 0) {
605
- writeCache(companyUid, s.name, s.value, cacheTtlMs);
624
+ if (typeof s.version === "number" &&
625
+ Number.isSafeInteger(s.version) &&
626
+ s.version > 0) {
627
+ writeCache(companyUid, s.name, s.value, cacheTtlMs, s.version);
628
+ }
629
+ else {
630
+ writeCache(companyUid, s.name, s.value, cacheTtlMs);
631
+ }
606
632
  }
607
633
  else {
608
634
  removeCacheEntry(companyUid, s.name);
@@ -181,7 +181,7 @@ export async function prewarmHqSecrets(graph /* EnvGraph */, opts, state) {
181
181
  state.loadedSecretsByName.set(s.name, s.value);
182
182
  const cacheTtlMs = normalizeCacheTtlMs(s);
183
183
  if (cacheTtlMs > 0) {
184
- writeCache(uid, s.name, s.value, cacheTtlMs);
184
+ writeCache(uid, s.name, s.value, cacheTtlMs, s.version);
185
185
  }
186
186
  else {
187
187
  removeCacheEntry(uid, s.name);
@@ -1,6 +1,12 @@
1
1
  export declare const DEFAULT_SECRETS_CACHE_TTL_MS: number;
2
- export declare function readCache(companyUid: string, name: string): string | null;
3
- export declare function writeCache(companyUid: string, name: string, value: string, ttlMs?: number): void;
2
+ export declare function readCache(companyUid: string, name: string, expectedVersion?: number, maxAgeMs?: number): string | null;
3
+ /**
4
+ * Returns whether an unexpired entry exists without decrypting its plaintext.
5
+ * Callers use this only to decide whether to reauthorize a possible cache hit;
6
+ * they must complete that authorization before calling {@link readCache}.
7
+ */
8
+ export declare function hasCacheEntry(companyUid: string, name: string): boolean;
9
+ export declare function writeCache(companyUid: string, name: string, value: string, ttlMs?: number, version?: number): void;
4
10
  /**
5
11
  * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
6
12
  * secrets-cache directory on disk. Install-time MCP registration may use an
@@ -6,9 +6,13 @@ const CACHE_DIR = path.join(os.homedir(), ".hq", "secrets-cache");
6
6
  const KEY_PATH = path.join(CACHE_DIR, ".key");
7
7
  const CACHE_FORMAT_MAGIC = Buffer.from("HQSC");
8
8
  const CACHE_FORMAT_MAGIC_BYTES = CACHE_FORMAT_MAGIC.length;
9
+ const CACHE_VERSION_MARKER = Buffer.from("HQSCV2\0");
10
+ const LEGACY_CACHE_VERSION_MARKER = Buffer.from("HQSCV1\0");
11
+ const CACHE_VERSION_MARKER_BYTES = CACHE_VERSION_MARKER.length;
9
12
  const LEGACY_TIMESTAMP_BYTES = 8;
10
13
  const TIMESTAMP_BYTES = 8;
11
14
  const TTL_BYTES = 8;
15
+ const CACHE_VERSION_BYTES = 8;
12
16
  const ALGORITHM = "aes-256-gcm";
13
17
  const IV_BYTES = 12;
14
18
  const AUTH_TAG_BYTES = 16;
@@ -45,7 +49,94 @@ function getOrCreateKey() {
45
49
  fs.renameSync(tmpPath, KEY_PATH);
46
50
  return fs.readFileSync(KEY_PATH);
47
51
  }
48
- export function readCache(companyUid, name) {
52
+ function isCacheVersion(value) {
53
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
54
+ }
55
+ function readCacheVersion(raw, offset) {
56
+ const value = Number(raw.readBigInt64BE(offset));
57
+ if (value === 0)
58
+ return undefined;
59
+ return isCacheVersion(value) ? value : null;
60
+ }
61
+ function cacheAad(header, companyUid, name) {
62
+ const company = Buffer.from(companyUid, "utf8");
63
+ const secretName = Buffer.from(name, "utf8");
64
+ const companyLength = Buffer.alloc(4);
65
+ const nameLength = Buffer.alloc(4);
66
+ companyLength.writeUInt32BE(company.length);
67
+ nameLength.writeUInt32BE(secretName.length);
68
+ return Buffer.concat([
69
+ Buffer.from("HQSC-AAD-V2\0"),
70
+ header,
71
+ companyLength,
72
+ company,
73
+ nameLength,
74
+ secretName,
75
+ ]);
76
+ }
77
+ function parseCacheHeader(raw) {
78
+ if (raw.subarray(0, CACHE_FORMAT_MAGIC_BYTES).equals(CACHE_FORMAT_MAGIC)) {
79
+ const baseHeaderBytes = CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES;
80
+ if (raw.length < baseHeaderBytes + IV_BYTES + AUTH_TAG_BYTES)
81
+ return null;
82
+ let ivStart = baseHeaderBytes;
83
+ let version;
84
+ let identityAuthenticated = false;
85
+ if (raw.length >=
86
+ baseHeaderBytes + CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
87
+ raw
88
+ .subarray(baseHeaderBytes, baseHeaderBytes + CACHE_VERSION_MARKER_BYTES)
89
+ .equals(CACHE_VERSION_MARKER)) {
90
+ const parsedVersion = readCacheVersion(raw, baseHeaderBytes + CACHE_VERSION_MARKER_BYTES);
91
+ if (parsedVersion === null)
92
+ return null;
93
+ version = parsedVersion;
94
+ ivStart += CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES;
95
+ identityAuthenticated = true;
96
+ }
97
+ else if (raw.length >=
98
+ baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
99
+ raw
100
+ .subarray(baseHeaderBytes, baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length)
101
+ .equals(LEGACY_CACHE_VERSION_MARKER)) {
102
+ const parsedVersion = readCacheVersion(raw, baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length);
103
+ if (parsedVersion === null)
104
+ return null;
105
+ version = parsedVersion;
106
+ ivStart += LEGACY_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES;
107
+ }
108
+ const authTagStart = ivStart + IV_BYTES;
109
+ const ciphertextStart = authTagStart + AUTH_TAG_BYTES;
110
+ if (raw.length < ciphertextStart)
111
+ return null;
112
+ return {
113
+ timestampMs: Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES)),
114
+ ttlMs: Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES)),
115
+ version,
116
+ identityAuthenticated,
117
+ ivStart,
118
+ authTagStart,
119
+ ciphertextStart,
120
+ };
121
+ }
122
+ const headerLen = LEGACY_TIMESTAMP_BYTES + IV_BYTES + AUTH_TAG_BYTES;
123
+ if (raw.length < headerLen)
124
+ return null;
125
+ return {
126
+ timestampMs: Number(raw.readBigInt64BE(0)),
127
+ ttlMs: DEFAULT_SECRETS_CACHE_TTL_MS,
128
+ identityAuthenticated: false,
129
+ ivStart: LEGACY_TIMESTAMP_BYTES,
130
+ authTagStart: LEGACY_TIMESTAMP_BYTES + IV_BYTES,
131
+ ciphertextStart: headerLen,
132
+ };
133
+ }
134
+ function isExpired(header, maxAgeMs) {
135
+ const ttlMs = maxAgeMs === undefined ? header.ttlMs : Math.min(header.ttlMs, maxAgeMs);
136
+ const ageMs = Date.now() - header.timestampMs;
137
+ return !Number.isFinite(ttlMs) || ttlMs <= 0 || ageMs < 0 || ageMs > ttlMs;
138
+ }
139
+ export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
49
140
  if (!validateInputs(companyUid, name))
50
141
  return null;
51
142
  const filePath = path.join(CACHE_DIR, companyUid, name);
@@ -56,40 +147,21 @@ export function readCache(companyUid, name) {
56
147
  catch {
57
148
  return null;
58
149
  }
59
- let timestampMs;
60
- let ttlMs;
61
- let ivStart;
62
- let authTagStart;
63
- let ciphertextStart;
64
- if (raw.length >=
65
- CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
66
- raw.subarray(0, CACHE_FORMAT_MAGIC_BYTES).equals(CACHE_FORMAT_MAGIC)) {
67
- timestampMs = Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES));
68
- ttlMs = Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES));
69
- ivStart = CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES;
70
- authTagStart = ivStart + IV_BYTES;
71
- ciphertextStart = authTagStart + AUTH_TAG_BYTES;
72
- }
73
- else {
74
- const headerLen = LEGACY_TIMESTAMP_BYTES + IV_BYTES + AUTH_TAG_BYTES;
75
- if (raw.length < headerLen)
76
- return null;
77
- timestampMs = Number(raw.readBigInt64BE(0));
78
- ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS;
79
- ivStart = LEGACY_TIMESTAMP_BYTES;
80
- authTagStart = ivStart + IV_BYTES;
81
- ciphertextStart = authTagStart + AUTH_TAG_BYTES;
82
- }
83
- if (ttlMs <= 0 || Date.now() - timestampMs > ttlMs) {
150
+ const header = parseCacheHeader(raw);
151
+ if (!header)
152
+ return null;
153
+ if (!header.identityAuthenticated ||
154
+ isExpired(header, maxAgeMs) ||
155
+ (expectedVersion !== undefined && header.version !== expectedVersion)) {
84
156
  try {
85
157
  fs.unlinkSync(filePath);
86
158
  }
87
159
  catch { /* ok */ }
88
160
  return null;
89
161
  }
90
- const iv = raw.subarray(ivStart, authTagStart);
91
- const authTag = raw.subarray(authTagStart, ciphertextStart);
92
- const ciphertext = raw.subarray(ciphertextStart);
162
+ const iv = raw.subarray(header.ivStart, header.authTagStart);
163
+ const authTag = raw.subarray(header.authTagStart, header.ciphertextStart);
164
+ const ciphertext = raw.subarray(header.ciphertextStart);
93
165
  let key;
94
166
  try {
95
167
  key = getOrCreateKey();
@@ -99,6 +171,7 @@ export function readCache(companyUid, name) {
99
171
  }
100
172
  try {
101
173
  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
174
+ decipher.setAAD(cacheAad(raw.subarray(0, header.ivStart), companyUid, name));
102
175
  decipher.setAuthTag(authTag);
103
176
  const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
104
177
  return decrypted.toString("utf8");
@@ -111,7 +184,35 @@ export function readCache(companyUid, name) {
111
184
  return null;
112
185
  }
113
186
  }
114
- export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS) {
187
+ /**
188
+ * Returns whether an unexpired entry exists without decrypting its plaintext.
189
+ * Callers use this only to decide whether to reauthorize a possible cache hit;
190
+ * they must complete that authorization before calling {@link readCache}.
191
+ */
192
+ export function hasCacheEntry(companyUid, name) {
193
+ if (!validateInputs(companyUid, name))
194
+ return false;
195
+ const filePath = path.join(CACHE_DIR, companyUid, name);
196
+ let raw;
197
+ try {
198
+ raw = fs.readFileSync(filePath);
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ const header = parseCacheHeader(raw);
204
+ if (!header)
205
+ return false;
206
+ if (!header.identityAuthenticated || isExpired(header)) {
207
+ try {
208
+ fs.unlinkSync(filePath);
209
+ }
210
+ catch { /* ok */ }
211
+ return false;
212
+ }
213
+ return true;
214
+ }
215
+ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS, version) {
115
216
  try {
116
217
  if (!validateInputs(companyUid, name))
117
218
  return;
@@ -120,14 +221,29 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
120
221
  ensureCacheDir(companyUid);
121
222
  const key = getOrCreateKey();
122
223
  const iv = crypto.randomBytes(IV_BYTES);
123
- const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
124
- const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
125
- const authTag = cipher.getAuthTag();
126
224
  const timestamp = Buffer.alloc(8);
127
225
  timestamp.writeBigInt64BE(BigInt(Date.now()));
128
226
  const ttl = Buffer.alloc(8);
129
227
  ttl.writeBigInt64BE(BigInt(ttlMs));
130
- const out = Buffer.concat([CACHE_FORMAT_MAGIC, timestamp, ttl, iv, authTag, encrypted]);
228
+ const versionBuffer = Buffer.alloc(CACHE_VERSION_BYTES);
229
+ versionBuffer.writeBigInt64BE(BigInt(isCacheVersion(version) ? version : 0));
230
+ const header = Buffer.concat([
231
+ CACHE_FORMAT_MAGIC,
232
+ timestamp,
233
+ ttl,
234
+ CACHE_VERSION_MARKER,
235
+ versionBuffer,
236
+ ]);
237
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
238
+ cipher.setAAD(cacheAad(header, companyUid, name));
239
+ const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
240
+ const authTag = cipher.getAuthTag();
241
+ const out = Buffer.concat([
242
+ header,
243
+ iv,
244
+ authTag,
245
+ encrypted,
246
+ ]);
131
247
  const filePath = path.join(CACHE_DIR, companyUid, name);
132
248
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
133
249
  const tmpPath = `${filePath}.tmp.${process.pid}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.107.0",
3
+ "version": "5.107.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {