@vellumai/assistant 0.8.12-staging.1 → 0.8.12
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/openapi.yaml +507 -0
- package/package.json +1 -1
- package/src/__tests__/llm-catalog-parity.test.ts +16 -0
- package/src/__tests__/log-export-workspace.test.ts +468 -3
- package/src/__tests__/secret-fixtures.ts +20 -0
- package/src/__tests__/tool-approval-handler.test.ts +85 -0
- package/src/__tests__/tool-audit-listener.test.ts +86 -0
- package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
- package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
- package/src/acp/__tests__/agent-process.test.ts +315 -2
- package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
- package/src/acp/agent-process.ts +163 -34
- package/src/acp/prepare-agent-env.ts +55 -15
- package/src/bundler/app-compiler.ts +8 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +10 -4
- package/src/cli/lib/upgrade-plugin.ts +13 -7
- package/src/config/seed-inference-profiles.ts +4 -8
- package/src/events/tool-audit-listener.ts +40 -9
- package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
- package/src/providers/model-catalog.ts +28 -0
- package/src/providers/model-intents.ts +1 -1
- package/src/providers/openai/chat-completions-provider.ts +2 -1
- package/src/providers/openai/responses-provider.ts +2 -1
- package/src/providers/unparseable-tool-args.ts +56 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +347 -0
- package/src/runtime/routes/conversation-query-routes.ts +79 -4
- package/src/runtime/routes/log-export-routes.ts +143 -96
- package/src/runtime/routes/plugins-routes.ts +359 -0
- package/src/runtime/routes/redact-staged-export.ts +259 -0
- package/src/security/redact-json.ts +61 -0
- package/src/tools/tool-approval-handler.ts +31 -0
- package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
- package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
- package/src/workspace/migrations/registry.ts +4 -0
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import {
|
|
12
|
+
chmodSync,
|
|
12
13
|
mkdirSync,
|
|
13
14
|
mkdtempSync,
|
|
14
15
|
readdirSync,
|
|
15
16
|
readFileSync,
|
|
16
17
|
rmSync,
|
|
18
|
+
statSync,
|
|
17
19
|
writeFileSync,
|
|
18
20
|
} from "node:fs";
|
|
19
21
|
import { tmpdir } from "node:os";
|
|
@@ -36,9 +38,27 @@ mock.module("../util/secure-keys.js", () => ({
|
|
|
36
38
|
getSecureKeyAsync: async () => undefined,
|
|
37
39
|
}));
|
|
38
40
|
|
|
41
|
+
import { getDb } from "../memory/db-connection.js";
|
|
39
42
|
import { initializeDb } from "../memory/db-init.js";
|
|
43
|
+
import {
|
|
44
|
+
conversations,
|
|
45
|
+
llmRequestLogs,
|
|
46
|
+
toolInvocations,
|
|
47
|
+
} from "../memory/schema.js";
|
|
40
48
|
import { RouteError } from "../runtime/routes/errors.js";
|
|
41
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
MAX_EXPORT_LLM_REQUEST_LOG_ROWS,
|
|
51
|
+
ROUTES,
|
|
52
|
+
} from "../runtime/routes/log-export-routes.js";
|
|
53
|
+
import {
|
|
54
|
+
MAX_SWEEP_FILE_BYTES,
|
|
55
|
+
OVERSIZED_FILE_NOTE,
|
|
56
|
+
redactStagedExportFiles,
|
|
57
|
+
} from "../runtime/routes/redact-staged-export.js";
|
|
58
|
+
import {
|
|
59
|
+
OPENAI_PROJECT_KEY_REDACTION_MARKER,
|
|
60
|
+
SYNTHETIC_OPENAI_PROJECT_KEY,
|
|
61
|
+
} from "./secret-fixtures.js";
|
|
42
62
|
|
|
43
63
|
initializeDb();
|
|
44
64
|
|
|
@@ -98,10 +118,29 @@ async function extractArchive(res: Response): Promise<string> {
|
|
|
98
118
|
// Seed test data
|
|
99
119
|
// ---------------------------------------------------------------------------
|
|
100
120
|
|
|
101
|
-
// config.json at workspace root — needed for config-snapshot
|
|
121
|
+
// config.json at workspace root — needed for config-snapshot tests. The
|
|
122
|
+
// acp.agents env value is a synthetic secret that must be redacted from the
|
|
123
|
+
// exported snapshot. Unlike the shared `SYNTHETIC_OPENAI_PROJECT_KEY`, it is
|
|
124
|
+
// deliberately below the secret scanner's 40-char minimum, so the export-time
|
|
125
|
+
// sweep cannot see it — only the config snapshot's structural env redaction
|
|
126
|
+
// can keep it out of the archive, meaning a sanitizer regression cannot be
|
|
127
|
+
// masked by the sweep.
|
|
128
|
+
const SWEEP_INVISIBLE_SYNTHETIC_KEY = "sk-proj-synthetic-test-key-000000";
|
|
102
129
|
writeFileSync(
|
|
103
130
|
join(testWorkspaceDir, "config.json"),
|
|
104
|
-
JSON.stringify({
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
provider: "anthropic",
|
|
133
|
+
acp: {
|
|
134
|
+
agents: {
|
|
135
|
+
codex: {
|
|
136
|
+
env: {
|
|
137
|
+
OPENAI_API_KEY: SWEEP_INVISIBLE_SYNTHETIC_KEY,
|
|
138
|
+
PATH: "/data/.bun/bin",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
105
144
|
);
|
|
106
145
|
|
|
107
146
|
// Conversation directories — used for workspace allowlist tests
|
|
@@ -201,6 +240,26 @@ describe("POST /v1/export — tar.gz archive", () => {
|
|
|
201
240
|
rmSync(dir, { recursive: true, force: true });
|
|
202
241
|
}
|
|
203
242
|
});
|
|
243
|
+
|
|
244
|
+
test("config-snapshot.json redacts acp.agents env values", async () => {
|
|
245
|
+
const res = await callExport();
|
|
246
|
+
const dir = await extractArchive(res);
|
|
247
|
+
try {
|
|
248
|
+
const configContent = readFileSync(
|
|
249
|
+
join(dir, "config-snapshot.json"),
|
|
250
|
+
"utf-8",
|
|
251
|
+
);
|
|
252
|
+
const parsed = JSON.parse(configContent);
|
|
253
|
+
const env = parsed.acp.agents.codex.env as Record<string, string>;
|
|
254
|
+
expect(Object.keys(env).sort()).toEqual(["OPENAI_API_KEY", "PATH"]);
|
|
255
|
+
for (const value of Object.values(env)) {
|
|
256
|
+
expect(value).toBe("(set)");
|
|
257
|
+
}
|
|
258
|
+
expect(configContent).not.toContain(SWEEP_INVISIBLE_SYNTHETIC_KEY);
|
|
259
|
+
} finally {
|
|
260
|
+
rmSync(dir, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
204
263
|
});
|
|
205
264
|
|
|
206
265
|
describe("POST /v1/export — daemon log date filtering", () => {
|
|
@@ -403,6 +462,13 @@ describe("POST /v1/export — workspace allowlist", () => {
|
|
|
403
462
|
);
|
|
404
463
|
const content = readFileSync(messagesPath, "utf-8");
|
|
405
464
|
expect(content).toBe('{"role":"user","content":"jan 15"}\n');
|
|
465
|
+
// Clean daemon log files must also ship byte-identical — the secret
|
|
466
|
+
// sweep does no gratuitous rewrites.
|
|
467
|
+
for (const logFile of ["assistant-2025-01-10.log", "vellum.log"]) {
|
|
468
|
+
expect(readFileSync(join(dir, "daemon-logs", logFile), "utf-8")).toBe(
|
|
469
|
+
readFileSync(join(logsDir, logFile), "utf-8"),
|
|
470
|
+
);
|
|
471
|
+
}
|
|
406
472
|
} finally {
|
|
407
473
|
rmSync(dir, { recursive: true, force: true });
|
|
408
474
|
}
|
|
@@ -442,3 +508,402 @@ describe("POST /v1/export — workspace allowlist", () => {
|
|
|
442
508
|
}
|
|
443
509
|
});
|
|
444
510
|
});
|
|
511
|
+
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// Export manifest row-cap truncation
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
describe("POST /v1/export — manifest truncatedSections", () => {
|
|
517
|
+
async function readManifest(
|
|
518
|
+
body: Record<string, unknown>,
|
|
519
|
+
): Promise<Record<string, unknown>> {
|
|
520
|
+
const res = await callExport(body);
|
|
521
|
+
const dir = await extractArchive(res);
|
|
522
|
+
try {
|
|
523
|
+
return JSON.parse(
|
|
524
|
+
readFileSync(join(dir, "export-manifest.json"), "utf-8"),
|
|
525
|
+
) as Record<string, unknown>;
|
|
526
|
+
} finally {
|
|
527
|
+
rmSync(dir, { recursive: true, force: true });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
test("omits truncatedSections when no section hits its row cap", async () => {
|
|
532
|
+
const manifest = await readManifest({ full: true });
|
|
533
|
+
expect(manifest.type).toBe("full-export");
|
|
534
|
+
expect(manifest).not.toHaveProperty("truncatedSections");
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("surfaces truncatedSections when a full-export dump exceeds its row cap", async () => {
|
|
538
|
+
// Seed limit + 1 rows for the cheapest capped section (llm-request-logs)
|
|
539
|
+
// so capRows detects truncation without a COUNT query.
|
|
540
|
+
const db = getDb();
|
|
541
|
+
const now = Date.now();
|
|
542
|
+
db.insert(conversations)
|
|
543
|
+
.values({ id: "conv-cap-test", createdAt: now, updatedAt: now })
|
|
544
|
+
.run();
|
|
545
|
+
const rows = Array.from(
|
|
546
|
+
{ length: MAX_EXPORT_LLM_REQUEST_LOG_ROWS + 1 },
|
|
547
|
+
(_, i) => ({
|
|
548
|
+
id: `llm-log-cap-${i}`,
|
|
549
|
+
conversationId: "conv-cap-test",
|
|
550
|
+
requestPayload: "{}",
|
|
551
|
+
responsePayload: "{}",
|
|
552
|
+
createdAt: now + i,
|
|
553
|
+
}),
|
|
554
|
+
);
|
|
555
|
+
db.insert(llmRequestLogs).values(rows).run();
|
|
556
|
+
|
|
557
|
+
const manifest = await readManifest({ full: true });
|
|
558
|
+
expect(manifest.truncatedSections).toEqual(["llm-request-logs"]);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Export-time secret sweep
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
// NOTE: these tests intentionally run after every other describe block in
|
|
567
|
+
// this file — they seed a workspace conversation and an audit DB row that
|
|
568
|
+
// contain a raw secret, which would otherwise leak into the exports made by
|
|
569
|
+
// the earlier (clean-state) tests.
|
|
570
|
+
describe("POST /v1/export — staged-file secret sweep", () => {
|
|
571
|
+
test("clean staged files are left byte-identical and report filesRedacted: 0", () => {
|
|
572
|
+
const staging = mkdtempSync(join(tmpdir(), "redact-staged-clean-"));
|
|
573
|
+
try {
|
|
574
|
+
mkdirSync(join(staging, "daemon-logs"), { recursive: true });
|
|
575
|
+
const seeded: Record<string, string> = {
|
|
576
|
+
"audit-data.json": JSON.stringify(
|
|
577
|
+
[{ id: "ti-1", toolName: "bash", input: "{}" }],
|
|
578
|
+
null,
|
|
579
|
+
2,
|
|
580
|
+
),
|
|
581
|
+
"daemon-logs/assistant-2025-01-10.log": "log entry from Jan 10\n",
|
|
582
|
+
"notes.md": "# clean notes\n",
|
|
583
|
+
};
|
|
584
|
+
for (const [rel, content] of Object.entries(seeded)) {
|
|
585
|
+
writeFileSync(join(staging, rel), content, "utf-8");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const result = redactStagedExportFiles(staging);
|
|
589
|
+
|
|
590
|
+
expect(result).toEqual({
|
|
591
|
+
filesScanned: 3,
|
|
592
|
+
filesRedacted: 0,
|
|
593
|
+
filesOmitted: 0,
|
|
594
|
+
});
|
|
595
|
+
for (const [rel, content] of Object.entries(seeded)) {
|
|
596
|
+
expect(readFileSync(join(staging, rel), "utf-8")).toBe(content);
|
|
597
|
+
}
|
|
598
|
+
} finally {
|
|
599
|
+
rmSync(staging, { recursive: true, force: true });
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("redacts .jsonl files line-wise, keeping each valid line parseable", () => {
|
|
604
|
+
const staging = mkdtempSync(join(tmpdir(), "redact-staged-jsonl-"));
|
|
605
|
+
try {
|
|
606
|
+
const filePath = join(staging, "conversation-filtered.jsonl");
|
|
607
|
+
const validLine = JSON.stringify({
|
|
608
|
+
msg: `token ${SYNTHETIC_OPENAI_PROJECT_KEY}`,
|
|
609
|
+
});
|
|
610
|
+
const malformedLine = `not json but carries ${SYNTHETIC_OPENAI_PROJECT_KEY}`;
|
|
611
|
+
const cleanLine = JSON.stringify({ msg: "clean" });
|
|
612
|
+
writeFileSync(
|
|
613
|
+
filePath,
|
|
614
|
+
`${validLine}\n${malformedLine}\n${cleanLine}\n`,
|
|
615
|
+
"utf-8",
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
const result = redactStagedExportFiles(staging);
|
|
619
|
+
expect(result).toEqual({
|
|
620
|
+
filesScanned: 1,
|
|
621
|
+
filesRedacted: 1,
|
|
622
|
+
filesOmitted: 0,
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const lines = readFileSync(filePath, "utf-8").split("\n");
|
|
626
|
+
expect(lines[0]).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
627
|
+
// The valid line stays valid JSON, marker stored as a string value.
|
|
628
|
+
const parsed = JSON.parse(lines[0]) as { msg: string };
|
|
629
|
+
expect(parsed.msg).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
|
|
630
|
+
// The unparseable line falls back to plain-text redaction.
|
|
631
|
+
expect(lines[1]).toBe(
|
|
632
|
+
`not json but carries ${OPENAI_PROJECT_KEY_REDACTION_MARKER}`,
|
|
633
|
+
);
|
|
634
|
+
// The clean line is untouched.
|
|
635
|
+
expect(lines[2]).toBe(cleanLine);
|
|
636
|
+
} finally {
|
|
637
|
+
rmSync(staging, { recursive: true, force: true });
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("sweeps arbitrary-extension staged files that sniff as text; leaves binary files untouched", () => {
|
|
642
|
+
const staging = mkdtempSync(join(tmpdir(), "redact-staged-sniff-"));
|
|
643
|
+
try {
|
|
644
|
+
// Conversation attachments are staged wholesale with arbitrary user
|
|
645
|
+
// extensions — sweep eligibility is decided by content sniffing, not
|
|
646
|
+
// file extension, so they must still be swept when they sniff as text.
|
|
647
|
+
const attachmentsDir = join(
|
|
648
|
+
staging,
|
|
649
|
+
"workspace",
|
|
650
|
+
"conversations",
|
|
651
|
+
"2025-01-10T00-00-00.000Z_conv-jan10",
|
|
652
|
+
"attachments",
|
|
653
|
+
);
|
|
654
|
+
mkdirSync(attachmentsDir, { recursive: true });
|
|
655
|
+
writeFileSync(
|
|
656
|
+
join(attachmentsDir, "creds.env"),
|
|
657
|
+
`OPENAI_API_KEY=${SYNTHETIC_OPENAI_PROJECT_KEY}\n`,
|
|
658
|
+
"utf-8",
|
|
659
|
+
);
|
|
660
|
+
writeFileSync(
|
|
661
|
+
join(attachmentsDir, "no-extension"),
|
|
662
|
+
`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}\n`,
|
|
663
|
+
"utf-8",
|
|
664
|
+
);
|
|
665
|
+
// Binary sniff: a NUL byte in the head exempts the file from the
|
|
666
|
+
// sweep even though scanner-matching bytes appear later in it.
|
|
667
|
+
const binary = Buffer.concat([
|
|
668
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01]),
|
|
669
|
+
Buffer.from(SYNTHETIC_OPENAI_PROJECT_KEY, "utf-8"),
|
|
670
|
+
]);
|
|
671
|
+
writeFileSync(join(attachmentsDir, "image.png"), binary);
|
|
672
|
+
|
|
673
|
+
const result = redactStagedExportFiles(staging);
|
|
674
|
+
|
|
675
|
+
expect(result).toEqual({
|
|
676
|
+
filesScanned: 2,
|
|
677
|
+
filesRedacted: 2,
|
|
678
|
+
filesOmitted: 0,
|
|
679
|
+
});
|
|
680
|
+
for (const file of ["creds.env", "no-extension"]) {
|
|
681
|
+
const content = readFileSync(join(attachmentsDir, file), "utf-8");
|
|
682
|
+
expect(content).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
|
|
683
|
+
expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
684
|
+
}
|
|
685
|
+
expect(readFileSync(join(attachmentsDir, "image.png"))).toEqual(binary);
|
|
686
|
+
} finally {
|
|
687
|
+
rmSync(staging, { recursive: true, force: true });
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("replaces oversized sweep-eligible files with an omission note (fail closed)", () => {
|
|
692
|
+
const staging = mkdtempSync(join(tmpdir(), "redact-staged-oversize-"));
|
|
693
|
+
try {
|
|
694
|
+
// One byte over the cap. The content must never ship unswept — it is
|
|
695
|
+
// replaced wholesale with the omission note. An omitted file was never
|
|
696
|
+
// actually scanned or redacted, so it only counts as omitted.
|
|
697
|
+
writeFileSync(
|
|
698
|
+
join(staging, "messages.json"),
|
|
699
|
+
Buffer.alloc(MAX_SWEEP_FILE_BYTES + 1, 0x61),
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
const result = redactStagedExportFiles(staging);
|
|
703
|
+
|
|
704
|
+
expect(result).toEqual({
|
|
705
|
+
filesScanned: 0,
|
|
706
|
+
filesRedacted: 0,
|
|
707
|
+
filesOmitted: 1,
|
|
708
|
+
});
|
|
709
|
+
expect(readFileSync(join(staging, "messages.json"), "utf-8")).toBe(
|
|
710
|
+
OVERSIZED_FILE_NOTE,
|
|
711
|
+
);
|
|
712
|
+
} finally {
|
|
713
|
+
rmSync(staging, { recursive: true, force: true });
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("redacts BOM-prefixed .json files via the JSON-aware path, preserving the BOM", () => {
|
|
718
|
+
const staging = mkdtempSync(join(tmpdir(), "redact-staged-bom-"));
|
|
719
|
+
try {
|
|
720
|
+
// A UTF-8 BOM (common in user attachments) makes JSON.parse throw, so
|
|
721
|
+
// without BOM handling the file would take the plain-text fallback and
|
|
722
|
+
// the quoted marker would corrupt the JSON string it lands in.
|
|
723
|
+
const filePath = join(staging, "attachment.json");
|
|
724
|
+
writeFileSync(
|
|
725
|
+
filePath,
|
|
726
|
+
"\uFEFF" +
|
|
727
|
+
JSON.stringify({ key: SYNTHETIC_OPENAI_PROJECT_KEY }, null, 2),
|
|
728
|
+
"utf-8",
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
const result = redactStagedExportFiles(staging);
|
|
732
|
+
expect(result).toEqual({
|
|
733
|
+
filesScanned: 1,
|
|
734
|
+
filesRedacted: 1,
|
|
735
|
+
filesOmitted: 0,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
const content = readFileSync(filePath, "utf-8");
|
|
739
|
+
expect(content.startsWith("\uFEFF")).toBe(true);
|
|
740
|
+
expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
741
|
+
// Stripping the preserved BOM yields valid JSON with the marker stored
|
|
742
|
+
// as a proper string value.
|
|
743
|
+
const parsed = JSON.parse(content.slice(1)) as { key: string };
|
|
744
|
+
expect(parsed.key).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
|
|
745
|
+
} finally {
|
|
746
|
+
rmSync(staging, { recursive: true, force: true });
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// Root bypasses file permission checks, so chmod 0o444 cannot make the
|
|
751
|
+
// file read-only — skip rather than assert a fail-closed path that cannot
|
|
752
|
+
// be exercised.
|
|
753
|
+
test.skipIf(process.getuid?.() === 0)(
|
|
754
|
+
"redacts a read-only staged file in place instead of shipping the raw secret",
|
|
755
|
+
() => {
|
|
756
|
+
const staging = mkdtempSync(join(tmpdir(), "redact-staged-readonly-"));
|
|
757
|
+
try {
|
|
758
|
+
// A read-only workspace attachment is staged with its mode bits
|
|
759
|
+
// preserved (cpSync). The sweep must still rewrite it — the chmod
|
|
760
|
+
// path — so the raw secret never reaches the tar step.
|
|
761
|
+
const readonlyPath = join(staging, "creds.env");
|
|
762
|
+
writeFileSync(
|
|
763
|
+
readonlyPath,
|
|
764
|
+
`OPENAI_API_KEY=${SYNTHETIC_OPENAI_PROJECT_KEY}\n`,
|
|
765
|
+
"utf-8",
|
|
766
|
+
);
|
|
767
|
+
chmodSync(readonlyPath, 0o444);
|
|
768
|
+
// A clean read-only file needs no rewrite, so it must keep its
|
|
769
|
+
// content AND its mode bits (no gratuitous chmod).
|
|
770
|
+
const cleanPath = join(staging, "clean.txt");
|
|
771
|
+
writeFileSync(cleanPath, "nothing secret here\n", "utf-8");
|
|
772
|
+
chmodSync(cleanPath, 0o444);
|
|
773
|
+
|
|
774
|
+
const result = redactStagedExportFiles(staging);
|
|
775
|
+
|
|
776
|
+
expect(result).toEqual({
|
|
777
|
+
filesScanned: 2,
|
|
778
|
+
filesRedacted: 1,
|
|
779
|
+
filesOmitted: 0,
|
|
780
|
+
});
|
|
781
|
+
const content = readFileSync(readonlyPath, "utf-8");
|
|
782
|
+
expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
783
|
+
expect(content).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
|
|
784
|
+
expect(readFileSync(cleanPath, "utf-8")).toBe("nothing secret here\n");
|
|
785
|
+
expect(statSync(cleanPath).mode & 0o777).toBe(0o444);
|
|
786
|
+
} finally {
|
|
787
|
+
rmSync(staging, { recursive: true, force: true });
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
// Root bypasses file permission checks, so chmod 0o000 cannot make the
|
|
793
|
+
// file unreadable — skip rather than assert a degraded path that cannot
|
|
794
|
+
// be exercised.
|
|
795
|
+
test.skipIf(process.getuid?.() === 0)(
|
|
796
|
+
"sweep continues past an unreadable staged file (degraded, not failed)",
|
|
797
|
+
() => {
|
|
798
|
+
const staging = mkdtempSync(join(tmpdir(), "redact-staged-unreadable-"));
|
|
799
|
+
const unreadablePath = join(staging, "unreadable.log");
|
|
800
|
+
try {
|
|
801
|
+
writeFileSync(
|
|
802
|
+
join(staging, "readable.log"),
|
|
803
|
+
`key ${SYNTHETIC_OPENAI_PROJECT_KEY}\n`,
|
|
804
|
+
"utf-8",
|
|
805
|
+
);
|
|
806
|
+
writeFileSync(unreadablePath, "any content\n", "utf-8");
|
|
807
|
+
chmodSync(unreadablePath, 0o000);
|
|
808
|
+
|
|
809
|
+
// Must return normally: the unreadable file is logged and skipped,
|
|
810
|
+
// and every other staged file is still swept.
|
|
811
|
+
const result = redactStagedExportFiles(staging);
|
|
812
|
+
|
|
813
|
+
expect(result).toEqual({
|
|
814
|
+
filesScanned: 1,
|
|
815
|
+
filesRedacted: 1,
|
|
816
|
+
filesOmitted: 0,
|
|
817
|
+
});
|
|
818
|
+
const content = readFileSync(join(staging, "readable.log"), "utf-8");
|
|
819
|
+
expect(content).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
|
|
820
|
+
expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
821
|
+
} finally {
|
|
822
|
+
chmodSync(unreadablePath, 0o600);
|
|
823
|
+
rmSync(staging, { recursive: true, force: true });
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
test("redacts raw keys from workspace conversation files in the archive", async () => {
|
|
829
|
+
seedConversation(
|
|
830
|
+
"2025-01-30T00-00-00.000Z_conv-secret",
|
|
831
|
+
JSON.stringify({
|
|
832
|
+
role: "user",
|
|
833
|
+
content: `export OPENAI_API_KEY="${SYNTHETIC_OPENAI_PROJECT_KEY}"`,
|
|
834
|
+
}) + "\n",
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
const res = await callExport();
|
|
838
|
+
const dir = await extractArchive(res);
|
|
839
|
+
try {
|
|
840
|
+
const content = readFileSync(
|
|
841
|
+
join(
|
|
842
|
+
dir,
|
|
843
|
+
"workspace",
|
|
844
|
+
"conversations",
|
|
845
|
+
"2025-01-30T00-00-00.000Z_conv-secret",
|
|
846
|
+
"messages.jsonl",
|
|
847
|
+
),
|
|
848
|
+
"utf-8",
|
|
849
|
+
);
|
|
850
|
+
expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
851
|
+
// Line-wise JSON-aware redaction: every redacted line must still
|
|
852
|
+
// JSON.parse, with the marker stored as a proper JSON string value
|
|
853
|
+
// (its quotes are escaped on serialization).
|
|
854
|
+
const lines = content.split("\n").filter((line) => line.trim() !== "");
|
|
855
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
856
|
+
const parsedLines = lines.map(
|
|
857
|
+
(line) => JSON.parse(line) as { content?: string },
|
|
858
|
+
);
|
|
859
|
+
expect(
|
|
860
|
+
parsedLines.some((record) =>
|
|
861
|
+
record.content?.includes(OPENAI_PROJECT_KEY_REDACTION_MARKER),
|
|
862
|
+
),
|
|
863
|
+
).toBe(true);
|
|
864
|
+
} finally {
|
|
865
|
+
rmSync(dir, { recursive: true, force: true });
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test("redacts legacy audit rows and keeps audit-data.json valid JSON", async () => {
|
|
870
|
+
// Simulate a row written before write-time input redaction shipped: the
|
|
871
|
+
// raw key sits in the persisted `input` column, where no structural
|
|
872
|
+
// sanitizer can retroactively fix it.
|
|
873
|
+
const db = getDb();
|
|
874
|
+
const now = Date.now();
|
|
875
|
+
db.insert(conversations)
|
|
876
|
+
.values({ id: "conv-legacy-audit", createdAt: now, updatedAt: now })
|
|
877
|
+
.run();
|
|
878
|
+
db.insert(toolInvocations)
|
|
879
|
+
.values({
|
|
880
|
+
id: "ti-legacy-audit",
|
|
881
|
+
conversationId: "conv-legacy-audit",
|
|
882
|
+
toolName: "bash",
|
|
883
|
+
input: JSON.stringify({
|
|
884
|
+
command: `export OPENAI_API_KEY="${SYNTHETIC_OPENAI_PROJECT_KEY}"`,
|
|
885
|
+
}),
|
|
886
|
+
result: "{}",
|
|
887
|
+
decision: "allow",
|
|
888
|
+
riskLevel: "low",
|
|
889
|
+
durationMs: 5,
|
|
890
|
+
createdAt: now,
|
|
891
|
+
})
|
|
892
|
+
.run();
|
|
893
|
+
|
|
894
|
+
const res = await callExport();
|
|
895
|
+
const dir = await extractArchive(res);
|
|
896
|
+
try {
|
|
897
|
+
const content = readFileSync(join(dir, "audit-data.json"), "utf-8");
|
|
898
|
+
expect(content).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
|
|
899
|
+
// The sweep must keep the file parseable — redaction goes through a
|
|
900
|
+
// JSON-aware path rather than splicing quoted markers into raw JSON.
|
|
901
|
+
const rows = JSON.parse(content) as Array<{ id: string; input: string }>;
|
|
902
|
+
const row = rows.find((r) => r.id === "ti-legacy-audit");
|
|
903
|
+
expect(row).toBeDefined();
|
|
904
|
+
expect(row!.input).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
|
|
905
|
+
} finally {
|
|
906
|
+
rmSync(dir, { recursive: true, force: true });
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared synthetic-secret fixtures for redaction tests.
|
|
3
|
+
*
|
|
4
|
+
* Constants only — per the test machinery isolation rule, shared helpers in
|
|
5
|
+
* `src/__tests__/` must not import from `src/`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A synthetic OpenAI project key that matches the scanner's
|
|
10
|
+
* `sk-proj-[A-Za-z0-9\-_]{40,}` pattern while deliberately dodging its
|
|
11
|
+
* placeholder filtering: no "test"/"example"/"xxxx"-style segments, not a
|
|
12
|
+
* repeated character, and it ends with an alphanumeric so the trailing `\b`
|
|
13
|
+
* boundary holds.
|
|
14
|
+
*/
|
|
15
|
+
export const SYNTHETIC_OPENAI_PROJECT_KEY =
|
|
16
|
+
"sk-proj-Ab1Cd2Ef3Gh4Ij5Kl6Mn7Op8Qr9St0Uv1Wx2Yz3Ab4Cd5Ef6Gh";
|
|
17
|
+
|
|
18
|
+
/** The marker `redactSecrets()` substitutes for the key above. */
|
|
19
|
+
export const OPENAI_PROJECT_KEY_REDACTION_MARKER =
|
|
20
|
+
'<redacted type="OpenAI Project Key" />';
|
|
@@ -532,6 +532,91 @@ describe("ToolApprovalHandler / pre-exec gate grant check", () => {
|
|
|
532
532
|
});
|
|
533
533
|
});
|
|
534
534
|
|
|
535
|
+
describe("ToolApprovalHandler / unparseable tool args gate", () => {
|
|
536
|
+
const handler = new ToolApprovalHandler();
|
|
537
|
+
const events: ToolLifecycleEvent[] = [];
|
|
538
|
+
const emitLifecycleEvent = (event: ToolLifecycleEvent) => {
|
|
539
|
+
events.push(event);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
beforeEach(() => {
|
|
543
|
+
clearTables();
|
|
544
|
+
events.length = 0;
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("input wrapped as { _raw } is rejected without executing", async () => {
|
|
548
|
+
const result = await handler.checkPreExecutionGates(
|
|
549
|
+
"bash",
|
|
550
|
+
{ _raw: '{"command": "ls -' },
|
|
551
|
+
makeContext({ trustClass: "guardian" }),
|
|
552
|
+
"sandbox",
|
|
553
|
+
"low",
|
|
554
|
+
Date.now(),
|
|
555
|
+
emitLifecycleEvent,
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
expect(result.allowed).toBe(false);
|
|
559
|
+
if (result.allowed) return;
|
|
560
|
+
expect(result.result.isError).toBe(true);
|
|
561
|
+
expect(result.result.content).toContain("were not valid JSON");
|
|
562
|
+
expect(result.result.content).toContain('{"command": "ls -');
|
|
563
|
+
expect(result.result.content).toContain("Retry");
|
|
564
|
+
|
|
565
|
+
const errorEvents = events.filter((e) => e.type === "error");
|
|
566
|
+
expect(errorEvents).toHaveLength(1);
|
|
567
|
+
if (errorEvents[0].type === "error") {
|
|
568
|
+
expect(errorEvents[0].isExpected).toBe(true);
|
|
569
|
+
expect(errorEvents[0].errorCategory).toBe("tool_failure");
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("long raw args are truncated in the error message", async () => {
|
|
574
|
+
const raw = `{"data": "${"x".repeat(500)}`;
|
|
575
|
+
const result = await handler.checkPreExecutionGates(
|
|
576
|
+
"bash",
|
|
577
|
+
{ _raw: raw },
|
|
578
|
+
makeContext({ trustClass: "guardian" }),
|
|
579
|
+
"sandbox",
|
|
580
|
+
"low",
|
|
581
|
+
Date.now(),
|
|
582
|
+
emitLifecycleEvent,
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
expect(result.allowed).toBe(false);
|
|
586
|
+
if (result.allowed) return;
|
|
587
|
+
expect(result.result.content).not.toContain(raw);
|
|
588
|
+
expect(result.result.content).toContain("…");
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("legitimate input containing a _raw field among others is not rejected", async () => {
|
|
592
|
+
const result = await handler.checkPreExecutionGates(
|
|
593
|
+
"bash",
|
|
594
|
+
{ _raw: "something", command: "ls" },
|
|
595
|
+
makeContext({ trustClass: "guardian" }),
|
|
596
|
+
"sandbox",
|
|
597
|
+
"low",
|
|
598
|
+
Date.now(),
|
|
599
|
+
emitLifecycleEvent,
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
expect(result.allowed).toBe(true);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("non-string _raw value is not treated as the marker", async () => {
|
|
606
|
+
const result = await handler.checkPreExecutionGates(
|
|
607
|
+
"bash",
|
|
608
|
+
{ _raw: 42 },
|
|
609
|
+
makeContext({ trustClass: "guardian" }),
|
|
610
|
+
"sandbox",
|
|
611
|
+
"low",
|
|
612
|
+
Date.now(),
|
|
613
|
+
emitLifecycleEvent,
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
expect(result.allowed).toBe(true);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
535
620
|
afterAll(() => {
|
|
536
621
|
mock.restore();
|
|
537
622
|
});
|