@indigoai-us/hq-cli 5.74.0 → 5.76.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/dist/commands/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +23 -5
- package/dist/commands/outposts.js +207 -14
- package/dist/commands/pack-install.d.ts +14 -17
- package/dist/commands/pack-install.js +53 -29
- package/dist/commands/pkg-install.js +3 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +9 -3
- package/dist/commands/secrets.js +189 -87
- package/dist/run/hq-plugin.js +94 -31
- package/dist/utils/sandbox-runner-client.d.ts +1 -0
- package/dist/utils/sandbox-runner-client.js +1 -0
- package/dist/utils/secrets-cache.d.ts +4 -5
- package/dist/utils/secrets-cache.js +5 -8
- package/package.json +3 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/commands/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +252 -24
- package/src/commands/outposts.ts +405 -50
- package/src/commands/pack-install-secret-authorization.test.ts +115 -0
- package/src/commands/pack-install.test.ts +5 -1
- package/src/commands/pack-install.ts +67 -29
- package/src/commands/pkg-install.ts +3 -1
- package/src/commands/run.test.ts +45 -0
- package/src/commands/run.ts +20 -4
- package/src/commands/secrets.test.ts +366 -25
- package/src/commands/secrets.ts +222 -96
- package/src/run/hq-plugin.test.ts +186 -10
- package/src/run/hq-plugin.ts +102 -32
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
- package/src/utils/pack-contributions.test.ts +90 -31
- package/src/utils/sandbox-runner-client.test.ts +28 -0
- package/src/utils/sandbox-runner-client.ts +2 -0
- package/src/utils/secrets-cache.ts +5 -8
- package/test/commands/signals.test.ts +2 -2
- package/test/commands/sources.test.ts +2 -2
- package/test/helpers/vault-service-mock.ts +76 -17
- package/test/sources-signals/smoke.test.ts +2 -2
|
@@ -42,10 +42,9 @@ vi.mock("../utils/secrets-cache.js", async (importOriginal) => {
|
|
|
42
42
|
const original = (await importOriginal()) as Record<string, unknown>;
|
|
43
43
|
return {
|
|
44
44
|
...original,
|
|
45
|
-
// Default: cold cache (every key is a miss → forces a batch-load request).
|
|
46
|
-
// Tests that exercise the warm path override readCache per-case.
|
|
47
45
|
readCache: vi.fn(() => null),
|
|
48
46
|
writeCache: vi.fn(() => undefined),
|
|
47
|
+
removeCacheEntry: vi.fn(() => undefined),
|
|
49
48
|
};
|
|
50
49
|
});
|
|
51
50
|
|
|
@@ -64,7 +63,11 @@ import {
|
|
|
64
63
|
import { spawn } from "node:child_process";
|
|
65
64
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
66
65
|
import { getEntityUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
67
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
readCache,
|
|
68
|
+
removeCacheEntry,
|
|
69
|
+
writeCache,
|
|
70
|
+
} from "../utils/secrets-cache.js";
|
|
68
71
|
import { SandboxRunnerClient } from "../utils/sandbox-runner-client.js";
|
|
69
72
|
|
|
70
73
|
let logSpy: MockInstance<typeof console.log>;
|
|
@@ -377,12 +380,69 @@ describe("secrets sandbox", () => {
|
|
|
377
380
|
expect(stdoutSpy).not.toHaveBeenCalled();
|
|
378
381
|
});
|
|
379
382
|
|
|
380
|
-
it("
|
|
383
|
+
it("renders the server error without inventing an exit code when sandbox execution fails", async () => {
|
|
384
|
+
pollJobSpy.mockResolvedValueOnce({
|
|
385
|
+
jobId: "job_123",
|
|
386
|
+
status: "failed",
|
|
387
|
+
error: "Cloudflare worker timed out with API_KEY=secret-value",
|
|
388
|
+
success: false,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const program = buildProgram();
|
|
392
|
+
await expect(
|
|
393
|
+
program.parseAsync([
|
|
394
|
+
"node",
|
|
395
|
+
"hq",
|
|
396
|
+
"secrets",
|
|
397
|
+
"sandbox",
|
|
398
|
+
"--only",
|
|
399
|
+
"API_KEY",
|
|
400
|
+
"--",
|
|
401
|
+
"my-skill",
|
|
402
|
+
]),
|
|
403
|
+
).rejects.toThrow("__exit__");
|
|
404
|
+
|
|
405
|
+
const renderedError = errSpy.mock.calls.flat().join(" ");
|
|
406
|
+
expect(renderedError).toContain("Cloudflare worker timed out with API_KEY=[REDACTED]");
|
|
407
|
+
// The redacted form appearing is not enough — the raw value must be gone.
|
|
408
|
+
expect(renderedError).not.toContain("secret-value");
|
|
409
|
+
expect(renderedError).not.toMatch(/exit code 1/i);
|
|
410
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("still explains a failure that carries neither an exit code nor a reason", async () => {
|
|
414
|
+
pollJobSpy.mockResolvedValueOnce({
|
|
415
|
+
jobId: "job_123",
|
|
416
|
+
status: "failed",
|
|
417
|
+
success: false,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const program = buildProgram();
|
|
421
|
+
await expect(
|
|
422
|
+
program.parseAsync([
|
|
423
|
+
"node",
|
|
424
|
+
"hq",
|
|
425
|
+
"secrets",
|
|
426
|
+
"sandbox",
|
|
427
|
+
"--only",
|
|
428
|
+
"API_KEY",
|
|
429
|
+
"--",
|
|
430
|
+
"my-skill",
|
|
431
|
+
]),
|
|
432
|
+
).rejects.toThrow("__exit__");
|
|
433
|
+
|
|
434
|
+
const renderedError = errSpy.mock.calls.flat().join(" ");
|
|
435
|
+
expect(renderedError).toMatch(/sandbox execution failed/i);
|
|
436
|
+
expect(renderedError).not.toMatch(/exit code 1/i);
|
|
437
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("preserves the real exit code when the sandbox command exits non-zero", async () => {
|
|
381
441
|
pollJobSpy.mockResolvedValueOnce({
|
|
382
442
|
jobId: "job_123",
|
|
383
443
|
status: "failed",
|
|
384
444
|
output: "boom\n",
|
|
385
|
-
exitCode:
|
|
445
|
+
exitCode: 2,
|
|
386
446
|
success: false,
|
|
387
447
|
});
|
|
388
448
|
|
|
@@ -401,8 +461,8 @@ describe("secrets sandbox", () => {
|
|
|
401
461
|
).rejects.toThrow("__exit__");
|
|
402
462
|
|
|
403
463
|
expect(stdoutSpy).toHaveBeenCalledWith("boom\n");
|
|
404
|
-
expect(errSpy.mock.calls.flat().join(" ")).toMatch(/exit code
|
|
405
|
-
expect(exitSpy).toHaveBeenCalledWith(
|
|
464
|
+
expect(errSpy.mock.calls.flat().join(" ")).toMatch(/exit code 2/i);
|
|
465
|
+
expect(exitSpy).toHaveBeenCalledWith(2);
|
|
406
466
|
expect(getJobSpy).not.toHaveBeenCalled();
|
|
407
467
|
});
|
|
408
468
|
});
|
|
@@ -1038,6 +1098,7 @@ describe("secrets exec/env batch-load (HQ-4H)", () => {
|
|
|
1038
1098
|
path: "/secrets/prs_alice/load",
|
|
1039
1099
|
method: "POST",
|
|
1040
1100
|
body: { names: ["MY_KEY", "OTHER"] },
|
|
1101
|
+
signal: expect.any(AbortSignal),
|
|
1041
1102
|
});
|
|
1042
1103
|
// The crux: the capturing GET route is never touched.
|
|
1043
1104
|
expect(vaultApiFetch).not.toHaveBeenCalledWith(CAPTURING_GET);
|
|
@@ -1045,11 +1106,17 @@ describe("secrets exec/env batch-load (HQ-4H)", () => {
|
|
|
1045
1106
|
expect(writeCache).toHaveBeenCalledWith("prs_alice", "OTHER", "v2", 300000);
|
|
1046
1107
|
});
|
|
1047
1108
|
|
|
1048
|
-
it("controlled
|
|
1109
|
+
it("controlled policy metadata overrides a malformed positive cache TTL", async () => {
|
|
1049
1110
|
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
1050
1111
|
jsonRes({
|
|
1051
1112
|
secrets: [
|
|
1052
|
-
{
|
|
1113
|
+
{
|
|
1114
|
+
name: "LOCKED",
|
|
1115
|
+
value: "v1",
|
|
1116
|
+
tier: "sensitive",
|
|
1117
|
+
scriptLock: { mode: "enforced" },
|
|
1118
|
+
cacheTtlMs: 300000,
|
|
1119
|
+
},
|
|
1053
1120
|
],
|
|
1054
1121
|
errors: [],
|
|
1055
1122
|
}),
|
|
@@ -1061,6 +1128,62 @@ describe("secrets exec/env batch-load (HQ-4H)", () => {
|
|
|
1061
1128
|
|
|
1062
1129
|
expect(out.get("LOCKED")).toBe("v1");
|
|
1063
1130
|
expect(writeCache).not.toHaveBeenCalled();
|
|
1131
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "LOCKED");
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
it("treats a malformed cache TTL as zero instead of defaulting to a warm cache", async () => {
|
|
1135
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
1136
|
+
jsonRes({
|
|
1137
|
+
secrets: [{ name: "LOCKED", value: "v1", cacheTtlMs: "300000" }],
|
|
1138
|
+
errors: [],
|
|
1139
|
+
}),
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
const out = await loadRevealedSecrets("test-token", "prs_alice", [
|
|
1143
|
+
"LOCKED",
|
|
1144
|
+
]);
|
|
1145
|
+
|
|
1146
|
+
expect(out.get("LOCKED")).toBe("v1");
|
|
1147
|
+
expect(writeCache).not.toHaveBeenCalled();
|
|
1148
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "LOCKED");
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it("evicts a stale cache entry when the server returns metadata without plaintext", async () => {
|
|
1152
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
1153
|
+
jsonRes({
|
|
1154
|
+
secrets: [{ name: "LOCKED", cacheTtlMs: 0 }],
|
|
1155
|
+
errors: [],
|
|
1156
|
+
}),
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
await expect(
|
|
1160
|
+
loadRevealedSecrets("test-token", "prs_alice", ["LOCKED"]),
|
|
1161
|
+
).rejects.toThrow("Secret 'LOCKED' has no value");
|
|
1162
|
+
|
|
1163
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "LOCKED");
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
it("evicts every unresolved cache entry before reporting the first batch failure", async () => {
|
|
1167
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
1168
|
+
jsonRes({
|
|
1169
|
+
secrets: [{ name: "VALUELESS", cacheTtlMs: 0 }],
|
|
1170
|
+
errors: [
|
|
1171
|
+
{ name: "DENIED", code: "forbidden", message: "No read permission" },
|
|
1172
|
+
],
|
|
1173
|
+
}),
|
|
1174
|
+
);
|
|
1175
|
+
|
|
1176
|
+
await expect(
|
|
1177
|
+
loadRevealedSecrets("test-token", "prs_alice", [
|
|
1178
|
+
"VALUELESS",
|
|
1179
|
+
"DENIED",
|
|
1180
|
+
"OMITTED",
|
|
1181
|
+
]),
|
|
1182
|
+
).rejects.toThrow("Secret 'VALUELESS' has no value");
|
|
1183
|
+
|
|
1184
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "VALUELESS");
|
|
1185
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "DENIED");
|
|
1186
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "OMITTED");
|
|
1064
1187
|
});
|
|
1065
1188
|
|
|
1066
1189
|
it("a missing key fails cleanly WITHOUT hitting the GET-404 capture path", async () => {
|
|
@@ -1100,15 +1223,155 @@ describe("secrets exec/env batch-load (HQ-4H)", () => {
|
|
|
1100
1223
|
expect(vaultApiFetch).not.toHaveBeenCalledWith(CAPTURING_GET);
|
|
1101
1224
|
});
|
|
1102
1225
|
|
|
1103
|
-
it("
|
|
1104
|
-
vi.mocked(
|
|
1226
|
+
it("revalidates a previously cached secret and evicts it after remote ACL revocation", async () => {
|
|
1227
|
+
vi.mocked(vaultApiFetch)
|
|
1228
|
+
.mockResolvedValueOnce(
|
|
1229
|
+
jsonRes({
|
|
1230
|
+
secrets: [
|
|
1231
|
+
{ name: "CACHED", value: "cached-value", cacheTtlMs: 300000 },
|
|
1232
|
+
],
|
|
1233
|
+
errors: [],
|
|
1234
|
+
}),
|
|
1235
|
+
)
|
|
1236
|
+
.mockResolvedValueOnce(
|
|
1237
|
+
jsonRes({
|
|
1238
|
+
secrets: [],
|
|
1239
|
+
errors: [
|
|
1240
|
+
{
|
|
1241
|
+
name: "CACHED",
|
|
1242
|
+
code: "forbidden",
|
|
1243
|
+
message: "No read permission",
|
|
1244
|
+
},
|
|
1245
|
+
],
|
|
1246
|
+
}),
|
|
1247
|
+
);
|
|
1105
1248
|
|
|
1106
|
-
const
|
|
1249
|
+
const first = await loadRevealedSecrets("test-token", "prs_alice", [
|
|
1107
1250
|
"CACHED",
|
|
1108
1251
|
]);
|
|
1252
|
+
expect(first.get("CACHED")).toBe("cached-value");
|
|
1253
|
+
expect(writeCache).toHaveBeenCalledWith(
|
|
1254
|
+
"prs_alice",
|
|
1255
|
+
"CACHED",
|
|
1256
|
+
"cached-value",
|
|
1257
|
+
300000,
|
|
1258
|
+
);
|
|
1109
1259
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1260
|
+
// Model the encrypted disk entry left by the first call. A pre-fix warm
|
|
1261
|
+
// lookup would return this value without contacting the server.
|
|
1262
|
+
vi.mocked(readCache).mockReturnValue("cached-value");
|
|
1263
|
+
|
|
1264
|
+
await expect(
|
|
1265
|
+
loadRevealedSecrets("test-token", "prs_alice", ["CACHED"]),
|
|
1266
|
+
).rejects.toThrow("Failed to fetch secret 'CACHED': No read permission");
|
|
1267
|
+
|
|
1268
|
+
expect(vaultApiFetch).toHaveBeenCalledTimes(2);
|
|
1269
|
+
expect(readCache).not.toHaveBeenCalled();
|
|
1270
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "CACHED");
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it("evicts every requested entry when a stale session is rejected", async () => {
|
|
1274
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
1275
|
+
jsonRes({ error: "Unauthorized", code: "session_expired" }, 401),
|
|
1276
|
+
);
|
|
1277
|
+
|
|
1278
|
+
await expect(
|
|
1279
|
+
loadRevealedSecrets("stale-token", "prs_alice", ["FIRST", "SECOND"]),
|
|
1280
|
+
).rejects.toThrow("Unauthorized");
|
|
1281
|
+
|
|
1282
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "FIRST");
|
|
1283
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "SECOND");
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
it("fails closed and evicts every entry on an offline network failure", async () => {
|
|
1287
|
+
vi.mocked(vaultApiFetch).mockRejectedValueOnce(
|
|
1288
|
+
new TypeError("fetch failed: ENETUNREACH"),
|
|
1289
|
+
);
|
|
1290
|
+
|
|
1291
|
+
await expect(
|
|
1292
|
+
loadRevealedSecrets("test-token", "prs_alice", ["FIRST", "SECOND"]),
|
|
1293
|
+
).rejects.toThrow("ENETUNREACH");
|
|
1294
|
+
|
|
1295
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "FIRST");
|
|
1296
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "SECOND");
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it("fails closed and evicts every entry when authorization times out", async () => {
|
|
1300
|
+
vi.mocked(vaultApiFetch).mockRejectedValueOnce(
|
|
1301
|
+
new DOMException("The operation was aborted due to timeout", "TimeoutError"),
|
|
1302
|
+
);
|
|
1303
|
+
|
|
1304
|
+
await expect(
|
|
1305
|
+
loadRevealedSecrets("test-token", "prs_alice", ["FIRST", "SECOND"]),
|
|
1306
|
+
).rejects.toThrow("timeout");
|
|
1307
|
+
|
|
1308
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "FIRST");
|
|
1309
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "SECOND");
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
it("evicts earlier successful chunks when a later authorization chunk fails", async () => {
|
|
1313
|
+
const keys = Array.from({ length: 101 }, (_, i) => `KEY_${i}`);
|
|
1314
|
+
vi.mocked(vaultApiFetch)
|
|
1315
|
+
.mockResolvedValueOnce(
|
|
1316
|
+
jsonRes({
|
|
1317
|
+
secrets: keys.slice(0, 100).map((name) => ({
|
|
1318
|
+
name,
|
|
1319
|
+
value: `value-for-${name}`,
|
|
1320
|
+
cacheTtlMs: 300000,
|
|
1321
|
+
})),
|
|
1322
|
+
errors: [],
|
|
1323
|
+
}),
|
|
1324
|
+
)
|
|
1325
|
+
.mockResolvedValueOnce(
|
|
1326
|
+
jsonRes({ error: "No read permission", code: "forbidden" }, 403),
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
await expect(
|
|
1330
|
+
loadRevealedSecrets("test-token", "prs_alice", keys),
|
|
1331
|
+
).rejects.toThrow("No read permission");
|
|
1332
|
+
|
|
1333
|
+
expect(writeCache).toHaveBeenCalledWith(
|
|
1334
|
+
"prs_alice",
|
|
1335
|
+
"KEY_0",
|
|
1336
|
+
"value-for-KEY_0",
|
|
1337
|
+
300000,
|
|
1338
|
+
);
|
|
1339
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "KEY_0");
|
|
1340
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "KEY_100");
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
it("rejects mixed success/error rows and evicts without exposing the value", async () => {
|
|
1344
|
+
const secretValue = "sentinel-secret-value";
|
|
1345
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
1346
|
+
jsonRes({
|
|
1347
|
+
secrets: [{ name: "MIXED", value: secretValue, cacheTtlMs: 300000 }],
|
|
1348
|
+
errors: [{ name: "MIXED", code: "forbidden", message: "Denied" }],
|
|
1349
|
+
}),
|
|
1350
|
+
);
|
|
1351
|
+
|
|
1352
|
+
let failure: Error | undefined;
|
|
1353
|
+
try {
|
|
1354
|
+
await loadRevealedSecrets("test-token", "prs_alice", ["MIXED"]);
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
failure = err as Error;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
expect(failure?.message).toBe("Invalid secret load response from vault");
|
|
1360
|
+
expect(failure?.message).not.toContain(secretValue);
|
|
1361
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "MIXED");
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it("rejects malformed response envelopes and evicts every requested entry", async () => {
|
|
1365
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
1366
|
+
jsonRes({ secrets: { name: "FIRST", value: "hidden" }, errors: [] }),
|
|
1367
|
+
);
|
|
1368
|
+
|
|
1369
|
+
await expect(
|
|
1370
|
+
loadRevealedSecrets("test-token", "prs_alice", ["FIRST", "SECOND"]),
|
|
1371
|
+
).rejects.toThrow("Invalid secret load response from vault");
|
|
1372
|
+
|
|
1373
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "FIRST");
|
|
1374
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "SECOND");
|
|
1112
1375
|
});
|
|
1113
1376
|
|
|
1114
1377
|
it("a non-2xx batch response throws the batch-load error", async () => {
|
|
@@ -1145,6 +1408,8 @@ describe("secrets script usage", () => {
|
|
|
1145
1408
|
"MY_KEY",
|
|
1146
1409
|
"--script",
|
|
1147
1410
|
scriptPath,
|
|
1411
|
+
"--script-id",
|
|
1412
|
+
"scripts/exec-script.sh",
|
|
1148
1413
|
"--",
|
|
1149
1414
|
"env",
|
|
1150
1415
|
]);
|
|
@@ -1158,13 +1423,14 @@ describe("secrets script usage", () => {
|
|
|
1158
1423
|
usage: {
|
|
1159
1424
|
channel: "exec",
|
|
1160
1425
|
script: {
|
|
1161
|
-
scriptId:
|
|
1426
|
+
scriptId: "scripts/exec-script.sh",
|
|
1162
1427
|
path: scriptPath,
|
|
1163
1428
|
sha256: expectedSha,
|
|
1164
1429
|
attestationLevel: "self-asserted-hash",
|
|
1165
1430
|
},
|
|
1166
1431
|
},
|
|
1167
1432
|
},
|
|
1433
|
+
signal: expect.any(AbortSignal),
|
|
1168
1434
|
});
|
|
1169
1435
|
expect(spawn).toHaveBeenCalledWith(
|
|
1170
1436
|
"env",
|
|
@@ -1220,6 +1486,7 @@ describe("secrets script usage", () => {
|
|
|
1220
1486
|
},
|
|
1221
1487
|
},
|
|
1222
1488
|
},
|
|
1489
|
+
signal: expect.any(AbortSignal),
|
|
1223
1490
|
});
|
|
1224
1491
|
expect(stdoutWriteSpy).toHaveBeenCalledWith("export MY_KEY='v1'\n");
|
|
1225
1492
|
});
|
|
@@ -1368,6 +1635,7 @@ describe("secrets reveal and policy controls", () => {
|
|
|
1368
1635
|
});
|
|
1369
1636
|
expect(logSpy).toHaveBeenCalledWith(" Tier: nuclear");
|
|
1370
1637
|
expect(logSpy).toHaveBeenCalledWith(" Script Lock: enforced");
|
|
1638
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "LOCKED");
|
|
1371
1639
|
});
|
|
1372
1640
|
|
|
1373
1641
|
it("script approve hashes the local file and hits the approval endpoint", async () => {
|
|
@@ -1403,6 +1671,7 @@ describe("secrets reveal and policy controls", () => {
|
|
|
1403
1671
|
attestationLevel: "self-asserted-hash",
|
|
1404
1672
|
},
|
|
1405
1673
|
});
|
|
1674
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "LOCKED");
|
|
1406
1675
|
});
|
|
1407
1676
|
|
|
1408
1677
|
it("script revoke hits the revoke endpoint", async () => {
|
|
@@ -1429,23 +1698,38 @@ describe("secrets reveal and policy controls", () => {
|
|
|
1429
1698
|
scriptId: "deploy-script",
|
|
1430
1699
|
},
|
|
1431
1700
|
});
|
|
1701
|
+
expect(removeCacheEntry).toHaveBeenCalledWith("prs_alice", "LOCKED");
|
|
1432
1702
|
});
|
|
1433
1703
|
|
|
1434
|
-
it("script list
|
|
1704
|
+
it("script list maps the server's scriptLock.approvedScripts response shape", async () => {
|
|
1435
1705
|
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
1436
1706
|
jsonRes({
|
|
1437
1707
|
policy: {
|
|
1438
1708
|
path: "LOCKED",
|
|
1439
1709
|
tier: "nuclear",
|
|
1440
|
-
scriptLock: {
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1710
|
+
scriptLock: {
|
|
1711
|
+
mode: "enforced",
|
|
1712
|
+
requiredAttestation: "self-asserted-hash",
|
|
1713
|
+
approvedScripts: [
|
|
1714
|
+
{
|
|
1715
|
+
scriptId: "deploy-script",
|
|
1716
|
+
path: "/tmp/deploy.sh",
|
|
1717
|
+
sha256: "abc123",
|
|
1718
|
+
attestationLevel: "self-asserted-hash",
|
|
1719
|
+
approvedAt: "2026-01-01T00:00:00.000Z",
|
|
1720
|
+
approvedBy: "prs_owner",
|
|
1721
|
+
},
|
|
1722
|
+
{
|
|
1723
|
+
scriptId: "revoked-script",
|
|
1724
|
+
path: "/tmp/old.sh",
|
|
1725
|
+
sha256: "def456",
|
|
1726
|
+
attestationLevel: "self-asserted-hash",
|
|
1727
|
+
approvedAt: "2025-12-01T00:00:00.000Z",
|
|
1728
|
+
approvedBy: "prs_owner",
|
|
1729
|
+
revokedAt: "2026-01-02T00:00:00.000Z",
|
|
1730
|
+
},
|
|
1731
|
+
],
|
|
1732
|
+
},
|
|
1449
1733
|
},
|
|
1450
1734
|
}),
|
|
1451
1735
|
);
|
|
@@ -1470,5 +1754,62 @@ describe("secrets reveal and policy controls", () => {
|
|
|
1470
1754
|
expect(logSpy).toHaveBeenCalledWith(
|
|
1471
1755
|
expect.stringContaining("deploy-script"),
|
|
1472
1756
|
);
|
|
1757
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
1758
|
+
expect.stringContaining("/tmp/deploy.sh"),
|
|
1759
|
+
);
|
|
1760
|
+
expect(logSpy).not.toHaveBeenCalledWith(
|
|
1761
|
+
expect.stringContaining("revoked-script"),
|
|
1762
|
+
);
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
it("script list ignores malformed modern rows without falling back to stale legacy rows", async () => {
|
|
1766
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
1767
|
+
jsonRes({
|
|
1768
|
+
policy: {
|
|
1769
|
+
path: "LOCKED",
|
|
1770
|
+
tier: "sensitive",
|
|
1771
|
+
scriptLock: {
|
|
1772
|
+
mode: "enforced",
|
|
1773
|
+
approvedScripts: [
|
|
1774
|
+
null,
|
|
1775
|
+
{ scriptId: "missing-path", attestationLevel: "self-asserted-hash" },
|
|
1776
|
+
{
|
|
1777
|
+
scriptId: "active-modern",
|
|
1778
|
+
path: "/tmp/active.sh",
|
|
1779
|
+
attestationLevel: "self-asserted-hash",
|
|
1780
|
+
},
|
|
1781
|
+
],
|
|
1782
|
+
},
|
|
1783
|
+
scripts: [
|
|
1784
|
+
{
|
|
1785
|
+
scriptId: "stale-legacy",
|
|
1786
|
+
scriptPath: "/tmp/stale.sh",
|
|
1787
|
+
sha256: "stale",
|
|
1788
|
+
attestationLevel: "self-asserted-hash",
|
|
1789
|
+
},
|
|
1790
|
+
],
|
|
1791
|
+
},
|
|
1792
|
+
}),
|
|
1793
|
+
);
|
|
1794
|
+
|
|
1795
|
+
const program = buildProgram();
|
|
1796
|
+
await program.parseAsync([
|
|
1797
|
+
"node",
|
|
1798
|
+
"hq",
|
|
1799
|
+
"secrets",
|
|
1800
|
+
"script",
|
|
1801
|
+
"list",
|
|
1802
|
+
"LOCKED",
|
|
1803
|
+
]);
|
|
1804
|
+
|
|
1805
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
1806
|
+
expect.stringContaining("active-modern"),
|
|
1807
|
+
);
|
|
1808
|
+
expect(logSpy).not.toHaveBeenCalledWith(
|
|
1809
|
+
expect.stringContaining("missing-path"),
|
|
1810
|
+
);
|
|
1811
|
+
expect(logSpy).not.toHaveBeenCalledWith(
|
|
1812
|
+
expect.stringContaining("stale-legacy"),
|
|
1813
|
+
);
|
|
1473
1814
|
});
|
|
1474
1815
|
});
|