Skip to content

Commit 331d923

Browse files
committed
feat(artifacts): file approval_package with source pins and secret scan
1 parent 4cb9b3a commit 331d923

15 files changed

Lines changed: 1678 additions & 63 deletions

‎apps/hub/src/artifacts/approval-package.test.ts‎

Lines changed: 406 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { type } from "arktype";
2+
import {
3+
getArtifact,
4+
getArtifactVersion,
5+
type ArtifactDb,
6+
} from "@corbits/artifacts";
7+
import {
8+
ApprovalPackageBundle,
9+
type ApprovalPackageSource,
10+
} from "@corbits-builder/schemas/artifact";
11+
import { parseJson } from "../lib/json.ts";
12+
import { ProjectError, type ProjectErrorIssue } from "../projects/errors.ts";
13+
14+
// An approval package summarizes planning and design documents for stakeholders; the built app and
15+
// earlier approval packages are never pinned as package sources.
16+
export const EXCLUDED_PACKAGE_SOURCE_KINDS = new Set([
17+
"app",
18+
"approval_package",
19+
]);
20+
21+
const SECRET_PATTERNS: readonly { label: string; pattern: RegExp }[] = [
22+
{
23+
label: "api_key",
24+
pattern:
25+
/(?:sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|xai-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35})/,
26+
},
27+
{
28+
label: "private_key",
29+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
30+
},
31+
{
32+
label: "demo_login",
33+
pattern: /demoLogin/,
34+
},
35+
{
36+
label: "live_app_url",
37+
pattern: /http:\/\/(?:127\.0\.0\.1|localhost):/,
38+
},
39+
];
40+
41+
const UUID_RE =
42+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43+
44+
export type StoredPackageSource = {
45+
kind: string;
46+
version: number;
47+
sha256: string;
48+
};
49+
50+
export type StoredSourceReader = (
51+
artifactId: string,
52+
version: number,
53+
) => Promise<StoredPackageSource | undefined>;
54+
55+
function scanSectionForSecrets(
56+
path: string,
57+
text: string,
58+
): ProjectErrorIssue[] {
59+
const issues: ProjectErrorIssue[] = [];
60+
for (const { label, pattern } of SECRET_PATTERNS) {
61+
if (pattern.test(text)) issues.push({ path, message: label });
62+
}
63+
return issues;
64+
}
65+
66+
export function scanApprovalPackageForSecrets(
67+
bundle: ApprovalPackageBundle,
68+
): void {
69+
const manifestText = [
70+
bundle.manifest.audience,
71+
bundle.manifest.decisionNeeded,
72+
bundle.manifest.tone,
73+
bundle.manifest.length,
74+
...bundle.manifest.sources.map((source) => source.title),
75+
].join("\n");
76+
const issues: ProjectErrorIssue[] = [
77+
...scanSectionForSecrets("manifest", manifestText),
78+
...scanSectionForSecrets("deck", JSON.stringify(bundle.deck)),
79+
];
80+
if (bundle.documents !== undefined) {
81+
for (const [name, content] of Object.entries(bundle.documents)) {
82+
issues.push(...scanSectionForSecrets(`documents/${name}`, content));
83+
}
84+
}
85+
if (issues.length > 0)
86+
throw new ProjectError(422, "package_contains_secret", issues);
87+
}
88+
89+
export function parseApprovalPackageBundle(
90+
content: string,
91+
): ApprovalPackageBundle {
92+
const bundle = ApprovalPackageBundle(parseJson(content));
93+
if (bundle instanceof type.errors)
94+
throw new ProjectError(400, "invalid_approval_package", {
95+
issues: bundle.summary,
96+
});
97+
return bundle;
98+
}
99+
100+
async function verifySourcePin(
101+
source: ApprovalPackageSource,
102+
readSource: StoredSourceReader,
103+
): Promise<void> {
104+
if (EXCLUDED_PACKAGE_SOURCE_KINDS.has(source.kind))
105+
throw new ProjectError(422, "excluded_package_source_kind", [
106+
{ path: "manifest.sources", message: source.kind },
107+
]);
108+
const stored = await readSource(source.artifactId, source.version);
109+
if (
110+
stored === undefined ||
111+
stored.kind !== source.kind ||
112+
stored.version !== source.version ||
113+
stored.sha256 !== source.sha256
114+
)
115+
throw new ProjectError(422, "invalid_package_source", [
116+
{
117+
path: "manifest.sources",
118+
message: `${source.artifactId}@${String(source.version)}`,
119+
},
120+
]);
121+
}
122+
123+
export async function validateApprovalPackage(
124+
input: string | ApprovalPackageBundle,
125+
readSource: StoredSourceReader,
126+
): Promise<ApprovalPackageBundle> {
127+
const bundle =
128+
typeof input === "string" ? parseApprovalPackageBundle(input) : input;
129+
for (const source of bundle.manifest.sources) {
130+
if (EXCLUDED_PACKAGE_SOURCE_KINDS.has(source.kind))
131+
throw new ProjectError(422, "excluded_package_source_kind", [
132+
{ path: "manifest.sources", message: source.kind },
133+
]);
134+
}
135+
scanApprovalPackageForSecrets(bundle);
136+
for (const source of bundle.manifest.sources)
137+
await verifySourcePin(source, readSource);
138+
return bundle;
139+
}
140+
141+
export function readProjectPackageSource(
142+
db: ArtifactDb,
143+
tenantId: string,
144+
): StoredSourceReader {
145+
return async (artifactId, version) => {
146+
if (!UUID_RE.test(artifactId)) return undefined;
147+
const row = await getArtifact(db, artifactId);
148+
if (!row || row.tenantId !== tenantId) return undefined;
149+
const stored = await getArtifactVersion(db, artifactId, version);
150+
if (!stored?.contentSha256) return undefined;
151+
return {
152+
kind: row.kind,
153+
version: stored.version,
154+
sha256: stored.contentSha256,
155+
};
156+
};
157+
}
158+
159+
function base64PaddingBytes(encoded: string): number {
160+
if (encoded.endsWith("==")) return 2;
161+
if (encoded.endsWith("=")) return 1;
162+
return 0;
163+
}
164+
165+
function base64ByteLength(encoded: string): number {
166+
return Math.max(
167+
0,
168+
Math.floor((encoded.length * 3) / 4) - base64PaddingBytes(encoded),
169+
);
170+
}
171+
172+
// Agents reading an approval_package need its manifest, deck structure and markdown documents, not
173+
// megabytes of base64 binary that would trip the tool-output size cap.
174+
export function stripApprovalPackageBinaries(content: string): string {
175+
const bundle = ApprovalPackageBundle(parseJson(content));
176+
if (bundle instanceof type.errors) return content;
177+
const { pptxBase64, pdfBase64, ...rest } = bundle;
178+
return JSON.stringify({
179+
...rest,
180+
pptxBytes: base64ByteLength(pptxBase64),
181+
pdfBytes: base64ByteLength(pdfBase64),
182+
});
183+
}

‎apps/hub/src/artifacts/routes.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
DesignTweaks,
2525
DesignTweaksReceipt,
2626
LiveApp,
27+
MAX_APPROVAL_PACKAGE_BYTES,
2728
MaterialReceipt,
2829
} from "@corbits-builder/schemas/artifact";
2930
import { ProjectActivity } from "@corbits-builder/schemas/activity";
@@ -123,6 +124,10 @@ export function mountArtifactRoutes(
123124
);
124125
app.post(
125126
"/:projectId/artifacts",
127+
bodyLimit({
128+
maxSize: MAX_APPROVAL_PACKAGE_BYTES + 1024 * 1024,
129+
onError: (c) => c.json({ code: "artifact_too_large" }, 413),
130+
}),
126131
describeRoute({
127132
tags: ["Artifacts"],
128133
operationId: "writeProjectArtifact",

‎apps/hub/src/artifacts/service.ts‎

Lines changed: 76 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
AppVersionMetadata,
4242
DocumentContent,
4343
MAX_APP_BYTES,
44+
MAX_APPROVAL_PACKAGE_BYTES,
4445
MATERIAL_KIND,
4546
ArtifactKindWrite,
4647
DesignBundle,
@@ -138,6 +139,11 @@ import {
138139
materialContent,
139140
materialMimeType,
140141
} from "./materials.ts";
142+
import {
143+
parseApprovalPackageBundle,
144+
readProjectPackageSource,
145+
validateApprovalPackage,
146+
} from "./approval-package.ts";
141147
import { requestErrorMetadata } from "../request-error.ts";
142148

143149
const log = getLogger(["corbits-builder", "hub", "artifacts"]);
@@ -151,8 +157,8 @@ const APPROVAL_COVERS: Record<string, (kind: string) => boolean> = {
151157
kind !== "app" && kind !== "design" && kind !== "design_option",
152158
instruct_coder: (kind) => kind !== "app" && kind !== "design_option",
153159
};
154-
// Whole documents, the filed app among them (up to 1 MiB of source).
155-
const MAX_READ_CHARS = 1024 * 1024;
160+
// Whole documents, the filed app and approval package among them.
161+
const MAX_READ_CHARS = MAX_APPROVAL_PACKAGE_BYTES;
156162
const RECENT_FAILURES = 5;
157163
const RunError = type({ "+": "delete", message: "string", "code?": "string" });
158164
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
@@ -184,6 +190,68 @@ async function isDirectory(path: string) {
184190

185191
const artifactResource = (kind: string) => "sb-artifact:" + kind;
186192

193+
function validateArtifactWriteContent(
194+
input: typeof ArtifactKindWrite.infer,
195+
): boolean {
196+
const bundled =
197+
input.kind === "design" ||
198+
input.kind === "design_option" ||
199+
input.kind === "app" ||
200+
input.kind === "approval_package";
201+
if (bundled && input.openQuestions)
202+
throw new ProjectError(400, "invalid_artifact_write", {
203+
issues: "openQuestions belong to documents, not a " + input.kind,
204+
});
205+
if (input.approaches) {
206+
if (input.kind !== "approaches")
207+
throw new ProjectError(400, "invalid_artifact_write", {
208+
issues:
209+
"approaches belong to the approaches document, not a " + input.kind,
210+
});
211+
const issue = approachesIssue(input.approaches);
212+
if (issue)
213+
throw new ProjectError(400, "invalid_artifact_write", {
214+
issues: issue,
215+
});
216+
}
217+
if (
218+
input.kind === "design" ||
219+
input.kind === "design_option" ||
220+
input.kind === "app"
221+
) {
222+
const bundle = (input.kind === "app" ? AppBundle : DesignBundle)(
223+
parseJson(input.content),
224+
);
225+
if (bundle instanceof type.errors)
226+
throw new ProjectError(400, "invalid_" + input.kind, {
227+
issues: bundle.summary,
228+
});
229+
}
230+
if (input.kind === "app") {
231+
const bytes = Buffer.byteLength(input.content);
232+
if (bytes > MAX_APP_BYTES)
233+
throw new ProjectError(413, "app_too_large", {
234+
bytes,
235+
limit: MAX_APP_BYTES,
236+
});
237+
} else if (input.kind === "approval_package") {
238+
const bytes = Buffer.byteLength(input.content);
239+
if (bytes > MAX_APPROVAL_PACKAGE_BYTES)
240+
throw new ProjectError(413, "approval_package_too_large", {
241+
bytes,
242+
limit: MAX_APPROVAL_PACKAGE_BYTES,
243+
});
244+
parseApprovalPackageBundle(input.content);
245+
} else {
246+
const document = DocumentContent(input.content);
247+
if (document instanceof type.errors)
248+
throw new ProjectError(400, "invalid_artifact_write", {
249+
issues: document.summary,
250+
});
251+
}
252+
return bundled;
253+
}
254+
187255
// A document version records the open questions it leaves for the person; a version filed without
188256
// them, before they were recorded or by an import, leaves none. An approaches version may also
189257
// record its approaches for a side-by-side comparison.
@@ -1254,55 +1322,17 @@ export function createArtifactService(
12541322
throw new ProjectError(400, "invalid_artifact_write", {
12551323
issues: input.summary,
12561324
});
1257-
const bundled =
1258-
input.kind === "design" ||
1259-
input.kind === "design_option" ||
1260-
input.kind === "app";
1261-
if (bundled && input.openQuestions)
1262-
throw new ProjectError(400, "invalid_artifact_write", {
1263-
issues: "openQuestions belong to documents, not a " + input.kind,
1264-
});
1265-
if (input.approaches) {
1266-
if (input.kind !== "approaches")
1267-
throw new ProjectError(400, "invalid_artifact_write", {
1268-
issues:
1269-
"approaches belong to the approaches document, not a " +
1270-
input.kind,
1271-
});
1272-
const issue = approachesIssue(input.approaches);
1273-
if (issue)
1274-
throw new ProjectError(400, "invalid_artifact_write", {
1275-
issues: issue,
1276-
});
1277-
}
1278-
if (bundled) {
1279-
const bundle = (input.kind === "app" ? AppBundle : DesignBundle)(
1280-
parseJson(input.content),
1281-
);
1282-
if (bundle instanceof type.errors)
1283-
throw new ProjectError(400, "invalid_" + input.kind, {
1284-
issues: bundle.summary,
1285-
});
1286-
}
1287-
if (input.kind === "app") {
1288-
const bytes = Buffer.byteLength(input.content);
1289-
if (bytes > MAX_APP_BYTES)
1290-
throw new ProjectError(413, "app_too_large", {
1291-
bytes,
1292-
limit: MAX_APP_BYTES,
1293-
});
1294-
} else {
1295-
const document = DocumentContent(input.content);
1296-
if (document instanceof type.errors)
1297-
throw new ProjectError(400, "invalid_artifact_write", {
1298-
issues: document.summary,
1299-
});
1300-
}
1325+
const bundled = validateArtifactWriteContent(input);
13011326
const allowed = await requireProject(actor, projectId, "read");
13021327
if (!(await allowed(artifactResource(input.kind), "write")))
13031328
throw new ProjectError(403, "artifact_write_refused", {
13041329
kind: input.kind,
13051330
});
1331+
if (input.kind === "approval_package")
1332+
await validateApprovalPackage(
1333+
input.content,
1334+
readProjectPackageSource(db, actor.tenantId),
1335+
);
13061336
const [active] = await rawDB
13071337
.select({ kind: principal.kind, refId: principal.refId })
13081338
.from(principal)

0 commit comments

Comments
 (0)