@indigoai-us/hq-cli 5.47.16 → 5.47.17
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/.github/workflows/publish.yml +2 -2
- package/dist/commands/feedback.d.ts +2 -0
- package/dist/commands/feedback.js +24 -2
- package/dist/commands/files.d.ts +19 -0
- package/dist/commands/files.js +37 -3
- package/dist/commands/secrets.js +25 -2
- package/dist/run/hq-plugin.js +9 -2
- package/dist/sentry-dsn.generated.d.ts +1 -1
- package/dist/sentry-dsn.generated.js +1 -1
- package/dist/utils/feedback-diagnostics.d.ts +7 -0
- package/dist/utils/feedback-diagnostics.js +4 -2
- package/dist/utils/feedback-screenshots.d.ts +23 -0
- package/dist/utils/feedback-screenshots.js +98 -0
- package/dist/utils/feedback-versions.d.ts +34 -0
- package/dist/utils/feedback-versions.js +50 -0
- package/package.json +1 -1
- package/src/commands/feedback.test.ts +44 -0
- package/src/commands/feedback.ts +46 -13
- package/src/commands/files-delete.test.ts +132 -0
- package/src/commands/files.ts +42 -1
- package/src/commands/secrets.test.ts +80 -0
- package/src/commands/secrets.ts +35 -0
- package/src/run/hq-plugin.test.ts +39 -0
- package/src/run/hq-plugin.ts +7 -0
- package/src/utils/feedback-diagnostics.test.ts +11 -0
- package/src/utils/feedback-diagnostics.ts +8 -0
- package/src/utils/feedback-screenshots.test.ts +134 -0
- package/src/utils/feedback-screenshots.ts +124 -0
- package/src/utils/feedback-versions.test.ts +98 -0
- package/src/utils/feedback-versions.ts +68 -0
package/src/commands/feedback.ts
CHANGED
|
@@ -4,6 +4,7 @@ import chalk from "chalk";
|
|
|
4
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
5
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
6
6
|
import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
|
|
7
|
+
import { MAX_SCREENSHOTS, uploadScreenshots } from "../utils/feedback-screenshots.js";
|
|
7
8
|
|
|
8
9
|
export const BODY_MAX_BYTES = 64 * 1024;
|
|
9
10
|
|
|
@@ -17,6 +18,8 @@ export interface FeedbackSubmitOptions {
|
|
|
17
18
|
body: string;
|
|
18
19
|
company?: string;
|
|
19
20
|
token: string;
|
|
21
|
+
/** S3 object keys of already-uploaded screenshots (see uploadScreenshots). */
|
|
22
|
+
screenshots?: string[];
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export async function readBodyFile(
|
|
@@ -46,6 +49,19 @@ export async function readBodyFile(
|
|
|
46
49
|
export async function submitFeedback(
|
|
47
50
|
opts: FeedbackSubmitOptions,
|
|
48
51
|
): Promise<FeedbackResult> {
|
|
52
|
+
// Validate the title locally, symmetric with the body check below. Commander's
|
|
53
|
+
// `requiredOption("--title")` only requires the flag to be PRESENT — an empty
|
|
54
|
+
// or whitespace-only value (`--title ""`, or a title the /hq-bug skill derived
|
|
55
|
+
// to nothing) passes the flag check, then the server rejects it with a 400
|
|
56
|
+
// "title (non-empty string) is required" that floods Sentry as a context-free
|
|
57
|
+
// warning (HQ-AB). Catch it here so the caller gets a clear, actionable error
|
|
58
|
+
// and the bad request never reaches the server.
|
|
59
|
+
if (opts.title.trim().length === 0) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"title must not be empty. Provide a short, non-whitespace title via --title.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
if (opts.body.trim().length === 0) {
|
|
50
66
|
throw new Error(
|
|
51
67
|
"body must not be empty. Provide at least one non-whitespace character.",
|
|
@@ -70,6 +86,9 @@ export async function submitFeedback(
|
|
|
70
86
|
if (opts.company) {
|
|
71
87
|
requestBody.company = opts.company;
|
|
72
88
|
}
|
|
89
|
+
if (opts.screenshots && opts.screenshots.length > 0) {
|
|
90
|
+
requestBody.screenshots = opts.screenshots;
|
|
91
|
+
}
|
|
73
92
|
|
|
74
93
|
const res = await vaultApiFetch({
|
|
75
94
|
token: opts.token,
|
|
@@ -104,19 +123,33 @@ function registerSubcommand(feedbackCmd: Command, type: "bug" | "feature"): void
|
|
|
104
123
|
"Path to a markdown file with the body; use - to read from stdin",
|
|
105
124
|
)
|
|
106
125
|
.option("--company <slug>", "Company slug to associate with the report")
|
|
107
|
-
.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
token
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
.option(
|
|
127
|
+
"--screenshot <path>",
|
|
128
|
+
`Attach a screenshot (repeatable, up to ${MAX_SCREENSHOTS}; .png/.jpg/.jpeg/.webp/.gif)`,
|
|
129
|
+
(value: string, prev: string[]) => [...prev, value],
|
|
130
|
+
[] as string[],
|
|
131
|
+
)
|
|
132
|
+
.action(
|
|
133
|
+
async (opts: { title: string; bodyFile: string; company?: string; screenshot: string[] }) => {
|
|
134
|
+
try {
|
|
135
|
+
const token = await ensureCognitoToken({ interactive: false });
|
|
136
|
+
const body = await readBodyFile(opts.bodyFile);
|
|
137
|
+
// Validate + upload screenshots (direct-to-S3 via presigned PUT)
|
|
138
|
+
// before submitting, so the row references uploaded objects.
|
|
139
|
+
const screenshots = await uploadScreenshots({
|
|
140
|
+
paths: opts.screenshot ?? [],
|
|
141
|
+
token,
|
|
142
|
+
});
|
|
143
|
+
const result = await submitFeedback({
|
|
144
|
+
type,
|
|
145
|
+
title: opts.title,
|
|
146
|
+
body,
|
|
147
|
+
company: opts.company,
|
|
148
|
+
token,
|
|
149
|
+
screenshots,
|
|
150
|
+
});
|
|
151
|
+
console.log(`Submitted: ${result.id}`);
|
|
152
|
+
} catch (err) {
|
|
120
153
|
console.error(
|
|
121
154
|
chalk.red("Error:"),
|
|
122
155
|
err instanceof Error ? err.message : String(err),
|
|
@@ -38,6 +38,7 @@ import { Command } from "commander";
|
|
|
38
38
|
import {
|
|
39
39
|
registerFilesCommand,
|
|
40
40
|
runFilesDelete,
|
|
41
|
+
stripRedundantCompanyScope,
|
|
41
42
|
FilesDeleteHttpError,
|
|
42
43
|
formatFilesDeleteError,
|
|
43
44
|
type FilesDeleteResponse,
|
|
@@ -242,6 +243,137 @@ describe("hq files delete — root/empty prefix rejected client-side", () => {
|
|
|
242
243
|
}
|
|
243
244
|
});
|
|
244
245
|
|
|
246
|
+
// HQ-8F: a caller who pastes an HQ *local* tree path (`companies/<slug>/…`)
|
|
247
|
+
// over-prefixes the bucket-relative vault key; the server rejects it with a 400
|
|
248
|
+
// (INVALID_PREFIX_COMPANIES_SCOPED), the source of the recurring warning. The
|
|
249
|
+
// CLI now strips the redundant scope BEFORE sending, so the local-looking path
|
|
250
|
+
// is gracefully normalized to bucket-relative.
|
|
251
|
+
describe("stripRedundantCompanyScope (HQ-8F, pure)", () => {
|
|
252
|
+
it("strips a leading companies/<slug>/ to the bucket-relative remainder", () => {
|
|
253
|
+
expect(stripRedundantCompanyScope("companies/acme/projects/foo")).toEqual({
|
|
254
|
+
prefix: "projects/foo",
|
|
255
|
+
strippedSlug: "acme",
|
|
256
|
+
});
|
|
257
|
+
expect(stripRedundantCompanyScope("companies/acme/projects/foo/*")).toEqual({
|
|
258
|
+
prefix: "projects/foo/*",
|
|
259
|
+
strippedSlug: "acme",
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("returns an EMPTY remainder for the company-root spellings (→ root-reject)", () => {
|
|
264
|
+
expect(stripRedundantCompanyScope("companies/acme")).toEqual({
|
|
265
|
+
prefix: "",
|
|
266
|
+
strippedSlug: "acme",
|
|
267
|
+
});
|
|
268
|
+
expect(stripRedundantCompanyScope("companies/acme/")).toEqual({
|
|
269
|
+
prefix: "",
|
|
270
|
+
strippedSlug: "acme",
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("leaves an already bucket-relative prefix untouched (null)", () => {
|
|
275
|
+
expect(stripRedundantCompanyScope("projects/foo/*")).toBeNull();
|
|
276
|
+
expect(stripRedundantCompanyScope("reports/q3/")).toBeNull();
|
|
277
|
+
// A non-scope path that merely starts with the word "companies" is NOT a
|
|
278
|
+
// scope prefix and must be left alone.
|
|
279
|
+
expect(stripRedundantCompanyScope("companiesreport/x")).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("hq files delete — HQ-8F company-scope normalization", () => {
|
|
284
|
+
it("strips companies/<slug>/ and sends the BUCKET-RELATIVE prefix (no 400)", async () => {
|
|
285
|
+
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
|
286
|
+
fetchSpy.mockResolvedValueOnce(
|
|
287
|
+
deleteResponse({ dryRun: true, matched: 1, keys: ["projects/foo/a.md"] }),
|
|
288
|
+
);
|
|
289
|
+
fetchSpy.mockResolvedValueOnce(
|
|
290
|
+
deleteResponse({ matched: 1, deleted: 1, tombstoned: 1, keys: ["projects/foo/a.md"] }),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
await runFilesDelete(
|
|
294
|
+
{
|
|
295
|
+
// Trailing slash → also exercises the `/` → `/*` glob normalization.
|
|
296
|
+
prefix: "companies/acme/projects/foo/",
|
|
297
|
+
dryRun: false,
|
|
298
|
+
yes: true,
|
|
299
|
+
companySlug: undefined,
|
|
300
|
+
},
|
|
301
|
+
{ confirm: async () => true },
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const bodies = deleteCallBodies();
|
|
305
|
+
// Both the preview and the delete carry the stripped, bucket-relative prefix.
|
|
306
|
+
expect(bodies.map((b) => b.prefix)).toEqual(["projects/foo/*", "projects/foo/*"]);
|
|
307
|
+
expect(printedErr()).toContain("stripped redundant 'companies/acme/'");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("a bare companies/<slug> strips to empty → root-reject, no network call", async () => {
|
|
311
|
+
const program = buildProgram();
|
|
312
|
+
await expect(
|
|
313
|
+
program.parseAsync(["files", "delete", "companies/acme", "--yes"], {
|
|
314
|
+
from: "user",
|
|
315
|
+
}),
|
|
316
|
+
).rejects.toThrow(/__EXIT__:1/);
|
|
317
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
318
|
+
expect(printedErr()).toContain("Refusing to delete the vault root");
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// HQ-CA: the EXACT-KEY sibling of HQ-8F. When the over-prefixed path has no
|
|
323
|
+
// trailing slash and no wildcard (a literal key like
|
|
324
|
+
// `companies/<slug>/notes/foo.md`), the server takes the exact-key branch where
|
|
325
|
+
// `validateObjectKey` — not `validatePrefix` — rejects it with a 400 ("Invalid
|
|
326
|
+
// key: … do not prefix with 'companies/<slug>/'."). The same client-side strip
|
|
327
|
+
// runs UPSTREAM of the server's exact-vs-glob split, so it normalizes the exact
|
|
328
|
+
// key to bucket-relative and the 400 is never emitted. This locks that path,
|
|
329
|
+
// which #117 only exercised via the trailing-slash (glob) spelling.
|
|
330
|
+
describe("hq files delete — HQ-CA exact-key company-scope normalization", () => {
|
|
331
|
+
it("strips companies/<slug>/ from an EXACT key and sends the bucket-relative key (no glob, no 400)", async () => {
|
|
332
|
+
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
|
333
|
+
fetchSpy.mockResolvedValueOnce(
|
|
334
|
+
deleteResponse({
|
|
335
|
+
dryRun: true,
|
|
336
|
+
mode: "exact",
|
|
337
|
+
prefix: "notes/foo.md",
|
|
338
|
+
matched: 1,
|
|
339
|
+
keys: ["notes/foo.md"],
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
fetchSpy.mockResolvedValueOnce(
|
|
343
|
+
deleteResponse({
|
|
344
|
+
mode: "exact",
|
|
345
|
+
prefix: "notes/foo.md",
|
|
346
|
+
matched: 1,
|
|
347
|
+
deleted: 1,
|
|
348
|
+
tombstoned: 1,
|
|
349
|
+
keys: ["notes/foo.md"],
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await runFilesDelete(
|
|
354
|
+
{
|
|
355
|
+
// No trailing slash, no wildcard → server takes the EXACT-key branch.
|
|
356
|
+
prefix: "companies/acme/notes/foo.md",
|
|
357
|
+
dryRun: false,
|
|
358
|
+
yes: true,
|
|
359
|
+
companySlug: undefined,
|
|
360
|
+
},
|
|
361
|
+
{ confirm: async () => true },
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const bodies = deleteCallBodies();
|
|
365
|
+
// Preview + delete both carry the stripped, bucket-relative EXACT key —
|
|
366
|
+
// never the `companies/acme/...` over-prefix that 400s, and never glob-ified
|
|
367
|
+
// into a prefix delete.
|
|
368
|
+
expect(bodies.map((b) => b.prefix)).toEqual(["notes/foo.md", "notes/foo.md"]);
|
|
369
|
+
for (const b of bodies) {
|
|
370
|
+
expect(b.prefix.startsWith("companies/")).toBe(false);
|
|
371
|
+
expect(b.prefix).not.toContain("*");
|
|
372
|
+
}
|
|
373
|
+
expect(printedErr()).toContain("stripped redundant 'companies/acme/'");
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
245
377
|
describe("hq files delete — server error mapping", () => {
|
|
246
378
|
it("403 surfaces a clear not-authorized message and exits 1", async () => {
|
|
247
379
|
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
package/src/commands/files.ts
CHANGED
|
@@ -782,17 +782,58 @@ function printKeyPreview(resp: FilesDeleteResponse): void {
|
|
|
782
782
|
}
|
|
783
783
|
}
|
|
784
784
|
|
|
785
|
+
/**
|
|
786
|
+
* The vault bucket is already company-scoped, so a delete prefix must be
|
|
787
|
+
* BUCKET-RELATIVE (e.g. `projects/foo/*`). A caller who pastes an HQ *local*
|
|
788
|
+
* tree path (`companies/<slug>/projects/foo`) over-prefixes it; the server then
|
|
789
|
+
* rejects it with INVALID_PREFIX_COMPANIES_SCOPED (HTTP 400), the source of the
|
|
790
|
+
* recurring Sentry warning HQ-8F. Strip a redundant leading `companies/<slug>/`
|
|
791
|
+
* so the local-looking path is normalized to the bucket-relative key the vault
|
|
792
|
+
* actually stores. Returns the stripped slug for a one-line notice, or null when
|
|
793
|
+
* there was nothing to strip. Pure → unit-testable.
|
|
794
|
+
*
|
|
795
|
+
* This runs UPSTREAM of the server's exact-vs-glob branch, so it covers both
|
|
796
|
+
* the glob spelling (`companies/<slug>/projects/foo/*` → `validatePrefix`,
|
|
797
|
+
* HQ-8F) and the EXACT-key spelling (`companies/<slug>/notes/foo.md` →
|
|
798
|
+
* `validateObjectKey`, HQ-CA) with the same normalization.
|
|
799
|
+
*/
|
|
800
|
+
export function stripRedundantCompanyScope(
|
|
801
|
+
prefix: string,
|
|
802
|
+
): { prefix: string; strippedSlug: string } | null {
|
|
803
|
+
const m = /^companies\/([^/]+)(?:\/(.*))?$/.exec(prefix);
|
|
804
|
+
if (!m) return null;
|
|
805
|
+
return { prefix: m[2] ?? "", strippedSlug: m[1] };
|
|
806
|
+
}
|
|
807
|
+
|
|
785
808
|
export async function runFilesDelete(
|
|
786
809
|
params: RunFilesDeleteParams,
|
|
787
810
|
deps: { confirm?: ConfirmFn } = {},
|
|
788
811
|
): Promise<void> {
|
|
789
812
|
const confirm = deps.confirm ?? realConfirm;
|
|
790
813
|
|
|
814
|
+
// The vault is already company-scoped — a `companies/<slug>/` prefix is the HQ
|
|
815
|
+
// LOCAL tree layout, not a vault key, and the server 400s it (HQ-8F). Strip it
|
|
816
|
+
// here so a pasted local path is gracefully normalized to bucket-relative
|
|
817
|
+
// BEFORE the dry-run/preview (so the operator still sees the exact keys and
|
|
818
|
+
// confirms the right target). If stripping empties the prefix, the root-reject
|
|
819
|
+
// below catches it with a clear message.
|
|
820
|
+
const scope = stripRedundantCompanyScope(params.prefix);
|
|
821
|
+
if (scope) {
|
|
822
|
+
console.error(
|
|
823
|
+
chalk.yellow(
|
|
824
|
+
`Note: stripped redundant 'companies/${scope.strippedSlug}/' — the vault ` +
|
|
825
|
+
`is already company-scoped; using bucket-relative ` +
|
|
826
|
+
`'${scope.prefix || "(root)"}'.`,
|
|
827
|
+
),
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
const rawPrefix = scope ? scope.prefix : params.prefix;
|
|
831
|
+
|
|
791
832
|
// Normalize exactly as the share/unshare/acl paths do (trailing `/` → `/*`),
|
|
792
833
|
// then reject the root/empty prefix CLIENT-side so a typo never reaches the
|
|
793
834
|
// server as a vault-wide delete. The server enforces this too (defense in
|
|
794
835
|
// depth), but failing fast here is clearer and avoids a wasted round-trip.
|
|
795
|
-
const normalized = normalizeFilePrefix(
|
|
836
|
+
const normalized = normalizeFilePrefix(rawPrefix);
|
|
796
837
|
if (normalized === "" || normalized === "*" || normalized === "/*") {
|
|
797
838
|
console.error(
|
|
798
839
|
chalk.red(
|
|
@@ -160,6 +160,86 @@ describe("secrets exists (HQ-4H HEAD probe)", () => {
|
|
|
160
160
|
});
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
// US-003 (secrets-server-proxy): the CLI surfaces the SERVER's refusal of a
|
|
164
|
+
// high-security ("nuclear") secret on the local-injection path as a clear,
|
|
165
|
+
// actionable error pointing at the proxy — and never prints the value. The
|
|
166
|
+
// server-side deny is the real control (it returns 403 + highSecurity:true and
|
|
167
|
+
// NO plaintext); these tests assert the CLI's surfacing behavior.
|
|
168
|
+
describe("US-003 — CLI refuses high-security secrets on local injection", () => {
|
|
169
|
+
// The server's 403 refusal shape for a high-security secret.
|
|
170
|
+
function highSecurityDenied(): Response {
|
|
171
|
+
return new Response(
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
error:
|
|
174
|
+
"Secret 'ANTHROPIC_API_KEY' is high-security and cannot be retrieved via local injection. Use the server-side proxy.",
|
|
175
|
+
highSecurity: true,
|
|
176
|
+
}),
|
|
177
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let exitSpy: MockInstance<typeof process.exit>;
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
184
|
+
throw new Error("__exit__");
|
|
185
|
+
}) as never);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("E2E: `secrets get --reveal` is denied — clear proxy-pointing error, no value printed", async () => {
|
|
189
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecurityDenied());
|
|
190
|
+
|
|
191
|
+
const program = buildProgram();
|
|
192
|
+
try {
|
|
193
|
+
await program.parseAsync([
|
|
194
|
+
"node",
|
|
195
|
+
"hq",
|
|
196
|
+
"secrets",
|
|
197
|
+
"get",
|
|
198
|
+
"ANTHROPIC_API_KEY",
|
|
199
|
+
"--reveal",
|
|
200
|
+
]);
|
|
201
|
+
} catch {
|
|
202
|
+
// exit sentinel
|
|
203
|
+
}
|
|
204
|
+
const exitCode = exitSpy.mock.calls[0]?.[0] as number | undefined;
|
|
205
|
+
|
|
206
|
+
expect(exitCode).toBe(1);
|
|
207
|
+
// A clear, actionable error mentioning high-security + the proxy.
|
|
208
|
+
const errText = errSpy.mock.calls.flat().join(" ");
|
|
209
|
+
expect(errText).toMatch(/high-security/i);
|
|
210
|
+
expect(errText).toMatch(/proxy/i);
|
|
211
|
+
// The value is NEVER printed — no "Value:" line carrying plaintext.
|
|
212
|
+
const logText = logSpy.mock.calls.flat().join(" ");
|
|
213
|
+
expect(logText).not.toMatch(/sk-ant/i);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("E2E: `secrets exec --only <name> -- env` is denied — proxy-pointing error, command not run", async () => {
|
|
217
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecurityDenied());
|
|
218
|
+
|
|
219
|
+
const program = buildProgram();
|
|
220
|
+
try {
|
|
221
|
+
await program.parseAsync([
|
|
222
|
+
"node",
|
|
223
|
+
"hq",
|
|
224
|
+
"secrets",
|
|
225
|
+
"exec",
|
|
226
|
+
"--only",
|
|
227
|
+
"ANTHROPIC_API_KEY",
|
|
228
|
+
"--",
|
|
229
|
+
"env",
|
|
230
|
+
]);
|
|
231
|
+
} catch {
|
|
232
|
+
// exit sentinel
|
|
233
|
+
}
|
|
234
|
+
const exitCode = exitSpy.mock.calls[0]?.[0] as number | undefined;
|
|
235
|
+
|
|
236
|
+
expect(exitCode).toBe(1);
|
|
237
|
+
const errText = errSpy.mock.calls.flat().join(" ");
|
|
238
|
+
expect(errText).toMatch(/high-security/i);
|
|
239
|
+
expect(errText).toMatch(/proxy/i);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
163
243
|
describe("secrets generate-link", () => {
|
|
164
244
|
it("mints one-time submission links for personal secrets", async () => {
|
|
165
245
|
const program = buildProgram();
|
package/src/commands/secrets.ts
CHANGED
|
@@ -392,6 +392,13 @@ export async function loadRevealedSecrets(
|
|
|
392
392
|
if (!res.ok) {
|
|
393
393
|
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
394
394
|
const message = extractApiMessage(body, res.statusText);
|
|
395
|
+
// High-security ("nuclear") refusal surfaced at the batch level (rather
|
|
396
|
+
// than per-name): point the caller at the proxy and never leak plaintext.
|
|
397
|
+
if (body.code === "high_security_denied" || body.highSecurity === true) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
"A requested secret is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
395
402
|
if (
|
|
396
403
|
res.status >= 400 &&
|
|
397
404
|
res.status < 500 &&
|
|
@@ -428,6 +435,17 @@ export async function loadRevealedSecrets(
|
|
|
428
435
|
for (const key of chunk) {
|
|
429
436
|
if (resolved.has(key)) continue;
|
|
430
437
|
const err = errorsByName.get(key);
|
|
438
|
+
// High-security ("nuclear") secret: the server refuses to vend it on the
|
|
439
|
+
// local-injection (batch-load) path — per-name code `high_security_denied`,
|
|
440
|
+
// no plaintext returned. Every caller of loadRevealedSecrets injects or
|
|
441
|
+
// prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
|
|
442
|
+
// `secrets env`), so a high-security secret can NEVER be used here. Surface
|
|
443
|
+
// a clear, actionable error pointing at the proxy instead of a raw failure.
|
|
444
|
+
if (err?.code === "high_security_denied") {
|
|
445
|
+
throw new Error(
|
|
446
|
+
`Secret '${key}' is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
431
449
|
const reason =
|
|
432
450
|
err?.code === "not_found"
|
|
433
451
|
? "Secret not found"
|
|
@@ -538,6 +556,23 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
538
556
|
|
|
539
557
|
if (!res.ok) {
|
|
540
558
|
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
559
|
+
// High-security ("nuclear") secret: the server refuses to reveal it on
|
|
560
|
+
// the local-injection path (403, no plaintext). Surface a clear,
|
|
561
|
+
// actionable error pointing the user at the proxy rather than a raw
|
|
562
|
+
// 4xx — the value can ONLY be used through the server-side proxy.
|
|
563
|
+
if (res.status === 403 && body.highSecurity === true) {
|
|
564
|
+
console.error(
|
|
565
|
+
chalk.red(
|
|
566
|
+
`Secret '${name}' is high-security and cannot be revealed locally.`,
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
console.error(
|
|
570
|
+
chalk.dim(
|
|
571
|
+
" It can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
|
|
572
|
+
),
|
|
573
|
+
);
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
541
576
|
console.error(
|
|
542
577
|
chalk.red(`Failed to get secret: ${extractApiMessage(body, res.statusText)}`),
|
|
543
578
|
);
|
|
@@ -153,4 +153,43 @@ describe('hq-plugin', () => {
|
|
|
153
153
|
const fooErrors = (graph as any).configSchema['FOO'].errors as Array<{ message: string }>;
|
|
154
154
|
expect(fooErrors.some((e) => e.message.includes('No read permission for secret "FOO"'))).toBe(true);
|
|
155
155
|
});
|
|
156
|
+
|
|
157
|
+
// US-003 (secrets-server-proxy): the server refuses to batch-load a
|
|
158
|
+
// high-security ("nuclear") secret (code: high_security_denied, no plaintext).
|
|
159
|
+
// `hq run` injects plaintext into the child env, so it must surface this as a
|
|
160
|
+
// clear, actionable error pointing at the proxy — and never resolve a value.
|
|
161
|
+
it('high-security-denied: nuclear secret is surfaced as a proxy-pointing ResolutionError, no value', async () => {
|
|
162
|
+
const schemaPath = path.join(tmpDir, '.env.schema');
|
|
163
|
+
fs.writeFileSync(schemaPath, `# @hqCompany("test")\n\nANTHROPIC_API_KEY=hq()\n`);
|
|
164
|
+
|
|
165
|
+
const uid = `test-uid-${Math.random().toString(36).slice(2)}`;
|
|
166
|
+
const mocks = makeMocks({
|
|
167
|
+
resolveCompanyUid: async () => uid,
|
|
168
|
+
fetchBatch: async () => ({
|
|
169
|
+
secrets: [],
|
|
170
|
+
errors: [
|
|
171
|
+
{
|
|
172
|
+
name: 'ANTHROPIC_API_KEY',
|
|
173
|
+
code: 'high_security_denied',
|
|
174
|
+
message: 'high-security; use the proxy',
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
let state!: PluginState;
|
|
181
|
+
const graph = await internal.loadEnvGraph({
|
|
182
|
+
entryFilePaths: [schemaPath],
|
|
183
|
+
afterInit: async (g) => {
|
|
184
|
+
state = installHqPlugin(g, mocks);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
await prewarmHqSecrets(graph, mocks, state);
|
|
188
|
+
await graph.resolveEnvValues();
|
|
189
|
+
|
|
190
|
+
const errs = (graph as any).configSchema['ANTHROPIC_API_KEY'].errors as Array<{ message: string }>;
|
|
191
|
+
expect(errs.some((e) => /high-security/i.test(e.message) && /proxy/i.test(e.message))).toBe(true);
|
|
192
|
+
// No plaintext value was resolved for the nuclear secret.
|
|
193
|
+
expect((graph.getResolvedEnvObject() as Record<string, unknown>).ANTHROPIC_API_KEY).toBeUndefined();
|
|
194
|
+
});
|
|
156
195
|
});
|
package/src/run/hq-plugin.ts
CHANGED
|
@@ -85,6 +85,13 @@ export function installHqPlugin(graph: any /* EnvGraph */, opts: InstallHqPlugin
|
|
|
85
85
|
if (err.code === 'not_found') {
|
|
86
86
|
throw new ResolutionError(`Secret "${secretName}" does not exist in company`);
|
|
87
87
|
}
|
|
88
|
+
// High-security ("nuclear") secret: the server refuses to vend it on
|
|
89
|
+
// the local-injection path. It can ONLY be used through the
|
|
90
|
+
// server-side proxy, so `hq run` (which injects plaintext into the
|
|
91
|
+
// child env) can never load it. Surface a clear, actionable error.
|
|
92
|
+
if (err.code === 'high_security_denied') {
|
|
93
|
+
throw new ResolutionError(`Secret "${secretName}" is high-security and cannot be injected locally — it can only be used via the HQ secret proxy (POST /secrets/{companyUid}/proxy/{path}), which keeps the plaintext server-side. Remove it from this schema's locally-injected vars.`);
|
|
94
|
+
}
|
|
88
95
|
throw new ResolutionError(`Failed to load secret "${secretName}": ${err.message ?? err.code}`);
|
|
89
96
|
}
|
|
90
97
|
// Sentinel-check style throughout: `readCache` returns `string | null`
|
|
@@ -91,6 +91,17 @@ describe("collectDiagnostics", () => {
|
|
|
91
91
|
if (saved !== undefined) process.env.npm_package_version = saved;
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it("attaches a versions block carrying the cli version (core/sync best-effort)", () => {
|
|
95
|
+
const blob = collectDiagnostics();
|
|
96
|
+
expect(blob.versions.cli).toBe(CLI_VERSION);
|
|
97
|
+
// core + sync are environment-dependent; they must be present as
|
|
98
|
+
// string | null, never undefined, so triage always gets the shape.
|
|
99
|
+
expect(blob.versions).toHaveProperty("core");
|
|
100
|
+
expect(blob.versions).toHaveProperty("sync");
|
|
101
|
+
expect(["string", "object"]).toContain(typeof blob.versions.core); // string | null
|
|
102
|
+
expect(["string", "object"]).toContain(typeof blob.versions.sync); // string | null
|
|
103
|
+
});
|
|
104
|
+
|
|
94
105
|
it("cliVersion is unaffected by npm_package_version env var", () => {
|
|
95
106
|
process.env.npm_package_version = "99.99.99";
|
|
96
107
|
const blob = collectDiagnostics();
|
|
@@ -2,6 +2,7 @@ import * as os from "os";
|
|
|
2
2
|
import { execFileSync } from "child_process";
|
|
3
3
|
import { getRecentBreadcrumbs } from "./breadcrumb-buffer.js";
|
|
4
4
|
import { CLI_VERSION } from "../cli-version.js";
|
|
5
|
+
import { collectVersions, type VersionInfo } from "./feedback-versions.js";
|
|
5
6
|
|
|
6
7
|
export interface GitContext {
|
|
7
8
|
branch: string | null;
|
|
@@ -12,6 +13,12 @@ export interface GitContext {
|
|
|
12
13
|
|
|
13
14
|
export interface DiagnosticsBlob {
|
|
14
15
|
cliVersion: string;
|
|
16
|
+
/**
|
|
17
|
+
* The hq-cli, hq-core, and hq-sync versions from the submitter's
|
|
18
|
+
* environment. `cliVersion` above is retained for back-compat; new
|
|
19
|
+
* consumers should read `versions` (which carries core + sync too).
|
|
20
|
+
*/
|
|
21
|
+
versions: VersionInfo;
|
|
15
22
|
nodeVersion: string;
|
|
16
23
|
os: { platform: string; release: string; arch: string };
|
|
17
24
|
command: string[];
|
|
@@ -101,6 +108,7 @@ function collectGitContext(): GitContext {
|
|
|
101
108
|
export function collectDiagnostics(): DiagnosticsBlob {
|
|
102
109
|
return {
|
|
103
110
|
cliVersion: CLI_VERSION,
|
|
111
|
+
versions: collectVersions(),
|
|
104
112
|
nodeVersion: process.version,
|
|
105
113
|
os: {
|
|
106
114
|
platform: os.platform(),
|