Skip to content

Commit ca9cbab

Browse files
chrisleekrclaude
andcommitted
fix(repo-config): size-gate the fetcher, harden pr-check, trim workflow-types
Three review findings on the repo-config surface. `fetcher.ts` had no size gate before the base64 decode, unlike its sibling `pr-check.ts`. Adds the gate, sharing `MAX_CONFIG_BYTES` from `schema.ts` so the two read paths cannot drift. The empty-`content` case is folded in on purpose: over 1 MB the Contents API returns `content: ""` with `encoding: "none"`, which decoded to "", parsed to null, and surfaced as a root-level schema error blaming the owner's document for a size limit. `touchesConfigFile` was the one GitHub call in `pr-check.ts` that could reject, so `runPrConfigCheck` was not the total function its siblings are. A secondary rate limit or a revoked `pull_requests: read` now no-ops the check instead of throwing into the caller. `workflow-types.ts` carried workflow-runner scaffolding with no consumer on this branch, including a second `HandlerResultSchema` whose shape already disagreed with the registry's. Trimmed 20 export statements to the 9 that have callers; the rest land with the isolated runner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KUPpJPtxAaHWrBsjytRGyM
1 parent 650f2c6 commit ca9cbab

5 files changed

Lines changed: 96 additions & 86 deletions

File tree

src/repo-config/fetcher.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { parse as parseYaml } from "yaml";
2424
import type { z } from "zod";
2525

2626
import { redactSecrets } from "../utils/sanitize";
27-
import { type GithubAppConfig, githubAppConfigSchema } from "./schema";
27+
import { type GithubAppConfig, githubAppConfigSchema, MAX_CONFIG_BYTES } from "./schema";
2828

2929
/**
3030
* Outcome of reading one repo's config.
@@ -172,6 +172,25 @@ export async function fetchRepoConfig(input: FetchRepoConfigInput): Promise<Repo
172172
return { kind: "invalid", message: `${path} is not a file` };
173173
}
174174

175+
// Size gate BEFORE the base64 decode, mirroring `pr-check.ts`, so an
176+
// oversize blob is never materialised. This path runs per job rather than
177+
// once per scheduler tick, and every cache miss re-pays it.
178+
//
179+
// Empty `content` is folded in here on purpose: over 1 MB the Contents API
180+
// returns `content: ""` with `encoding: "none"`, which would otherwise
181+
// decode to "", parse to null, and be reported as a root-level schema error
182+
// blaming the owner's document for what is really a size limit.
183+
if (data.size > MAX_CONFIG_BYTES || data.content.length === 0) {
184+
log.warn(
185+
{ event: "repo_config.invalid", owner, repo, kind: "too-large", size: data.size },
186+
"repo-config: config file is too large to validate",
187+
);
188+
return {
189+
kind: "invalid",
190+
message: `${path} is ${String(data.size)} bytes, over the ${String(MAX_CONFIG_BYTES)} byte limit`,
191+
};
192+
}
193+
175194
const raw = Buffer.from(data.content, "base64").toString("utf-8");
176195
const value = parseAndValidate(raw, data.sha, { owner, repo, log });
177196
cacheResult(cacheKey, res.headers.etag, value);

src/repo-config/pr-check.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,7 @@ import type { z } from "zod";
3333
import { config } from "../config";
3434
import { redactSecrets, sanitizeContent } from "../utils/sanitize";
3535
import { buildScopedMarker, upsertMarkerComment } from "../workflows/ship/scoped/marker-comment";
36-
import { githubAppConfigSchema } from "./schema";
37-
38-
/** GitHub's own editor refuses far larger files; 64 KB is well past any real config. */
39-
const MAX_CONFIG_BYTES = 64 * 1024;
36+
import { githubAppConfigSchema, MAX_CONFIG_BYTES } from "./schema";
4037

4138
/** Rendered issue cap. Beyond this the comment stops being readable. */
4239
const MAX_RENDERED_ISSUES = 10;
@@ -201,12 +198,32 @@ function toIssues(issues: readonly z.core.$ZodIssue[]): ConfigCheckIssue[] {
201198
* missed edit costs the author a comment, never a wrong verdict.
202199
*/
203200
async function touchesConfigFile(input: RunPrConfigCheckInput, path: string): Promise<boolean> {
204-
const files = (await input.octokit.paginate(input.octokit.rest.pulls.listFiles, {
205-
owner: input.owner,
206-
repo: input.repo,
207-
pull_number: input.prNumber,
208-
per_page: 100,
209-
})) as { filename: string }[];
201+
let files: { filename: string }[];
202+
try {
203+
files = (await input.octokit.paginate(input.octokit.rest.pulls.listFiles, {
204+
owner: input.owner,
205+
repo: input.repo,
206+
pull_number: input.prNumber,
207+
per_page: 100,
208+
})) as { filename: string }[];
209+
} catch (err) {
210+
// Degrade like every other GitHub call on this path: a secondary rate
211+
// limit, a 5xx, or a revoked `pull_requests: read` must no-op the check,
212+
// not reject out of `runPrConfigCheck` into the caller. Same trade the
213+
// 3000-file cap already accepts: a missed edit costs the author a
214+
// comment, never a wrong verdict.
215+
input.log.info(
216+
{
217+
event: "repo_config.pr_check.list_files_failed",
218+
err,
219+
owner: input.owner,
220+
repo: input.repo,
221+
prNumber: input.prNumber,
222+
},
223+
"repo-config: could not list pull request files, skipping config check",
224+
);
225+
return false;
226+
}
210227
return files.some((file) => file.filename === path);
211228
}
212229

src/repo-config/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,13 @@ export const DEFAULT_REPO_DEFAULTS: RepoDefaults = { extra_allowed_tools: [] };
319319
* written against an earlier revision of this schema still parses. Bump
320320
* `version` only for a breaking rename or semantic change.
321321
*/
322+
/**
323+
* Byte cap on the config blob, shared by `fetcher.ts` and `pr-check.ts` so the
324+
* two read paths cannot drift. GitHub's own editor refuses far larger files;
325+
* 64 KB is well past any real config.
326+
*/
327+
export const MAX_CONFIG_BYTES = 64 * 1024;
328+
322329
export const githubAppConfigSchema = z
323330
.strictObject({
324331
version: z.literal(1),

src/shared/workflow-types.ts

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,6 @@ export const RepoMemoryCategorySchema = z.enum([
3030
"env",
3131
"gotchas",
3232
]);
33-
export const RepoMemoryEntrySchema = z.object({
34-
id: z.uuid(),
35-
category: RepoMemoryCategorySchema,
36-
content: z.string().min(1).max(1000),
37-
pinned: z.boolean(),
38-
});
39-
export type RepoMemoryEntry = z.infer<typeof RepoMemoryEntrySchema>;
4033

4134
const reviewLearningActionSaveSchema = z.object({
4235
directive: z.string().min(1).max(2000),
@@ -63,73 +56,6 @@ export const DaemonActionsSchema = z.object({
6356
});
6457
export type DaemonActions = z.infer<typeof DaemonActionsSchema>;
6558

66-
const appliedReviewLearningIdsField = z.array(z.string().max(64)).max(50).optional();
67-
const daemonActionsField = DaemonActionsSchema.optional();
68-
export const WORKFLOW_RUNNER_HUMAN_MESSAGE_MAX_CHARS = 50_000;
69-
const boundedHumanMessage = z
70-
.string()
71-
.min(1)
72-
.max(WORKFLOW_RUNNER_HUMAN_MESSAGE_MAX_CHARS)
73-
.optional();
74-
const boundedFailureReason = z.string().min(1).max(WORKFLOW_RUNNER_HUMAN_MESSAGE_MAX_CHARS);
75-
76-
/** Result returned by one workflow handler before controller-side settlement. */
77-
export const HandlerResultSchema = z.discriminatedUnion("status", [
78-
z.object({
79-
status: z.literal("succeeded"),
80-
state: z.unknown(),
81-
humanMessage: boundedHumanMessage,
82-
appliedReviewLearningIds: appliedReviewLearningIdsField,
83-
daemonActions: daemonActionsField,
84-
}),
85-
z.object({
86-
status: z.literal("failed"),
87-
reason: boundedFailureReason,
88-
state: z.unknown().optional(),
89-
humanMessage: boundedHumanMessage,
90-
daemonActions: daemonActionsField,
91-
}),
92-
z.object({
93-
status: z.literal("incomplete"),
94-
reason: boundedFailureReason,
95-
state: z.unknown().optional(),
96-
humanMessage: boundedHumanMessage,
97-
appliedReviewLearningIds: appliedReviewLearningIdsField,
98-
daemonActions: daemonActionsField,
99-
}),
100-
z.object({
101-
status: z.literal("handed-off"),
102-
state: z.unknown().optional(),
103-
humanMessage: boundedHumanMessage,
104-
childRunId: z.string().min(1),
105-
daemonActions: z.never().optional(),
106-
}),
107-
]);
108-
export type HandlerResult = z.infer<typeof HandlerResultSchema>;
109-
110-
export const PriorPlanStateSchema = z.object({
111-
plan: z.string().min(1).max(100_000),
112-
});
113-
export type PriorPlanState = z.infer<typeof PriorPlanStateSchema>;
114-
115-
const WorkflowRunSnapshotStateSchema = z.object({
116-
recommendedNext: z.enum(["plan", "stop"]).optional(),
117-
pr_number: z.number().int().positive().optional(),
118-
});
119-
120-
/** Bounded workflow history projected into a single-attempt runner payload. */
121-
export const WorkflowRunSnapshotSchema = z.object({
122-
id: z.uuid(),
123-
status: z.enum(["queued", "running", "succeeded", "failed", "incomplete"]),
124-
state: WorkflowRunSnapshotStateSchema,
125-
createdAt: z.iso.datetime(),
126-
});
127-
export type WorkflowRunSnapshot = z.infer<typeof WorkflowRunSnapshotSchema>;
128-
129-
export function workflowRunnerId(attemptId: string): string {
130-
return `workflow-runner:${attemptId}`;
131-
}
132-
13359
export type {
13460
Registry,
13561
RegistryEntry,

test/repo-config/fetcher.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ function b64(text: string): string {
2525

2626
function fileResponse(yaml: string, etag?: string): unknown {
2727
return {
28-
data: { type: "file", content: b64(yaml), sha: "abc123" },
28+
data: {
29+
type: "file",
30+
content: b64(yaml),
31+
sha: "abc123",
32+
// Real responses always carry `size`; the size gate reads it.
33+
size: Buffer.byteLength(yaml, "utf-8"),
34+
},
2935
headers: etag !== undefined ? { etag } : {},
3036
};
3137
}
@@ -67,6 +73,41 @@ describe("fetchRepoConfig", () => {
6773
expect(getContent.mock.calls[0]?.[0]).not.toHaveProperty("ref");
6874
});
6975

76+
it("rejects an oversize blob before decoding it", async () => {
77+
// Mirrors the pr-check gate: the decode must never be paid for a file
78+
// that is going to be rejected anyway. This path runs per job, not once
79+
// per scheduler tick.
80+
const getContent = mock(() =>
81+
Promise.resolve({
82+
data: { type: "file", content: b64(VALID_YAML), sha: "abc123", size: 64 * 1024 + 1 },
83+
headers: {},
84+
}),
85+
);
86+
const result = await fetchFrom(getContent);
87+
88+
expect(result.kind).toBe("invalid");
89+
expect(result.kind === "invalid" ? result.message : "").toContain("over the");
90+
});
91+
92+
it("reports a >1MB blob as a size problem, not a schema problem", async () => {
93+
// Over 1 MB the Contents API returns `content: ""` with `encoding: "none"`.
94+
// Decoding that yields "", which parses to null and would otherwise be
95+
// reported as `(root): expected object, received null`, blaming the
96+
// owner's document for what is really a size limit.
97+
const getContent = mock(() =>
98+
Promise.resolve({
99+
data: { type: "file", content: "", encoding: "none", sha: "abc123", size: 2_000_000 },
100+
headers: {},
101+
}),
102+
);
103+
const result = await fetchFrom(getContent);
104+
105+
expect(result.kind).toBe("invalid");
106+
const message = result.kind === "invalid" ? result.message : "";
107+
expect(message).toContain("over the");
108+
expect(message).not.toContain("expected object");
109+
});
110+
70111
it("returns absent on 404 and negative-caches it", async () => {
71112
const getContent = mock(() => Promise.reject(httpError(404)));
72113
expect((await fetchFrom(getContent)).kind).toBe("absent");

0 commit comments

Comments
 (0)