Skip to content

Commit f760981

Browse files
committed
feat(write): enforce path policy, secret scan, and rate limiting
1 parent c25ca1d commit f760981

4 files changed

Lines changed: 412 additions & 6 deletions

File tree

docs/runbooks/mentions.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,22 @@ write:
123123
enabled: true
124124
```
125125
126+
### Write policy (allow/deny paths, secret scan, rate limit)
127+
128+
When write-mode is enabled, the server enforces policy before committing/pushing:
129+
130+
- `write.denyPaths`: blocks changes to matching paths (deny wins)
131+
- `write.allowPaths`: if set, every changed path must match an allow pattern
132+
- `write.secretScan.enabled`: blocks if staged diffs look like secrets (keys/tokens)
133+
- `write.minIntervalSeconds`: basic write request rate limiting
134+
135+
Common refusal reasons:
136+
137+
- `write-policy-denied-path`: staged change matches denyPaths
138+
- `write-policy-not-allowed`: staged change did not match allowPaths
139+
- `write-policy-secret-detected`: suspected secret present in staged diff
140+
- `rate-limited`: write requests too frequent
141+
126142
## 4) Verify Eyes Reaction Attempt (Non-Blocking)
127143

128144
The handler tries to add an ":eyes:" reaction to the trigger comment.

src/handlers/mention.test.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,4 +447,237 @@ describe("createMentionHandler write intent gating", () => {
447447

448448
await workspaceFixture.cleanup();
449449
});
450+
451+
test("write intent is refused when a staged path is denied", async () => {
452+
const handlers = new Map<string, (event: WebhookEvent) => Promise<void>>();
453+
const workspaceFixture = await createWorkspaceFixture(
454+
"mention:\n enabled: true\nwrite:\n enabled: true\n denyPaths:\n - 'README.md'\n",
455+
);
456+
457+
const prNumber = 101;
458+
const featureSha = (await $`git -C ${workspaceFixture.dir} rev-parse feature`.quiet())
459+
.text()
460+
.trim();
461+
await $`git --git-dir ${workspaceFixture.remoteDir} update-ref refs/pull/${prNumber}/head ${featureSha}`.quiet();
462+
463+
let createdPr = false;
464+
let replyBody: string | undefined;
465+
466+
const eventRouter: EventRouter = {
467+
register: (eventKey, handler) => {
468+
handlers.set(eventKey, handler);
469+
},
470+
dispatch: async () => undefined,
471+
};
472+
473+
const jobQueue: JobQueue = {
474+
enqueue: async <T>(_installationId: number, fn: () => Promise<T>) => fn(),
475+
getQueueSize: () => 0,
476+
getPendingCount: () => 0,
477+
};
478+
479+
const workspaceManager: WorkspaceManager = {
480+
create: async (_installationId: number, options: CloneOptions) => {
481+
await $`git -C ${workspaceFixture.dir} checkout ${options.ref}`.quiet();
482+
return { dir: workspaceFixture.dir, cleanup: async () => undefined };
483+
},
484+
cleanupStale: async () => 0,
485+
};
486+
487+
const octokit = {
488+
rest: {
489+
reactions: {
490+
createForPullRequestReviewComment: async () => ({ data: {} }),
491+
createForIssueComment: async () => ({ data: {} }),
492+
},
493+
issues: {
494+
listComments: async () => ({ data: [] }),
495+
createComment: async () => ({ data: {} }),
496+
},
497+
pulls: {
498+
get: async () => ({
499+
data: {
500+
title: "Test PR",
501+
body: "",
502+
user: { login: "octocat" },
503+
head: { ref: "feature" },
504+
base: { ref: "main" },
505+
},
506+
}),
507+
create: async () => {
508+
createdPr = true;
509+
return { data: { html_url: "https://example.com/pr/123" } };
510+
},
511+
createReplyForReviewComment: async (params: { body: string }) => {
512+
replyBody = params.body;
513+
return { data: {} };
514+
},
515+
},
516+
},
517+
};
518+
519+
createMentionHandler({
520+
eventRouter,
521+
jobQueue,
522+
workspaceManager,
523+
githubApp: {
524+
getAppSlug: () => "kodiai",
525+
getInstallationOctokit: async () => octokit as never,
526+
} as unknown as GitHubApp,
527+
executor: {
528+
execute: async (ctx: { workspace: { dir: string } }) => {
529+
await Bun.write(join(ctx.workspace.dir, "README.md"), "base\nfeature\nchanged\n");
530+
return {
531+
conclusion: "success",
532+
published: false,
533+
costUsd: 0,
534+
numTurns: 1,
535+
durationMs: 1,
536+
sessionId: "session-mention",
537+
};
538+
},
539+
} as never,
540+
logger: createNoopLogger(),
541+
});
542+
543+
const handler = handlers.get("pull_request_review_comment.created");
544+
expect(handler).toBeDefined();
545+
546+
await handler!(
547+
buildReviewCommentMentionEvent({
548+
prNumber,
549+
baseRef: "main",
550+
headRef: "feature",
551+
headRepoOwner: "forker",
552+
headRepoName: "repo",
553+
commentBody: "@kodiai apply: update the README",
554+
}),
555+
);
556+
557+
expect(createdPr).toBe(false);
558+
expect(replyBody).toBeDefined();
559+
expect(replyBody!).toContain("Write request refused");
560+
expect(replyBody!).toContain("denied path");
561+
562+
await workspaceFixture.cleanup();
563+
});
564+
565+
test("write intent requests are rate-limited when configured", async () => {
566+
const handlers = new Map<string, (event: WebhookEvent) => Promise<void>>();
567+
const workspaceFixture = await createWorkspaceFixture(
568+
"mention:\n enabled: true\nwrite:\n enabled: true\n minIntervalSeconds: 60\n",
569+
);
570+
571+
const prNumber = 101;
572+
const featureSha = (await $`git -C ${workspaceFixture.dir} rev-parse feature`.quiet())
573+
.text()
574+
.trim();
575+
await $`git --git-dir ${workspaceFixture.remoteDir} update-ref refs/pull/${prNumber}/head ${featureSha}`.quiet();
576+
577+
const replies: string[] = [];
578+
let prCreates = 0;
579+
580+
const eventRouter: EventRouter = {
581+
register: (eventKey, handler) => {
582+
handlers.set(eventKey, handler);
583+
},
584+
dispatch: async () => undefined,
585+
};
586+
587+
const jobQueue: JobQueue = {
588+
enqueue: async <T>(_installationId: number, fn: () => Promise<T>) => fn(),
589+
getQueueSize: () => 0,
590+
getPendingCount: () => 0,
591+
};
592+
593+
const workspaceManager: WorkspaceManager = {
594+
create: async (_installationId: number, options: CloneOptions) => {
595+
await $`git -C ${workspaceFixture.dir} checkout ${options.ref}`.quiet();
596+
return { dir: workspaceFixture.dir, cleanup: async () => undefined };
597+
},
598+
cleanupStale: async () => 0,
599+
};
600+
601+
const octokit = {
602+
rest: {
603+
reactions: {
604+
createForPullRequestReviewComment: async () => ({ data: {} }),
605+
createForIssueComment: async () => ({ data: {} }),
606+
},
607+
issues: {
608+
listComments: async () => ({ data: [] }),
609+
createComment: async () => ({ data: {} }),
610+
},
611+
pulls: {
612+
get: async () => ({
613+
data: {
614+
title: "Test PR",
615+
body: "",
616+
user: { login: "octocat" },
617+
head: { ref: "feature" },
618+
base: { ref: "main" },
619+
},
620+
}),
621+
create: async () => {
622+
prCreates++;
623+
return { data: { html_url: "https://example.com/pr/123" } };
624+
},
625+
createReplyForReviewComment: async (params: { body: string }) => {
626+
replies.push(params.body);
627+
return { data: {} };
628+
},
629+
},
630+
},
631+
};
632+
633+
let writeCount = 0;
634+
createMentionHandler({
635+
eventRouter,
636+
jobQueue,
637+
workspaceManager,
638+
githubApp: {
639+
getAppSlug: () => "kodiai",
640+
getInstallationOctokit: async () => octokit as never,
641+
} as unknown as GitHubApp,
642+
executor: {
643+
execute: async (ctx: { workspace: { dir: string } }) => {
644+
writeCount++;
645+
await Bun.write(
646+
join(ctx.workspace.dir, "README.md"),
647+
`base\nfeature\nchanged-${writeCount}\n`,
648+
);
649+
return {
650+
conclusion: "success",
651+
published: false,
652+
costUsd: 0,
653+
numTurns: 1,
654+
durationMs: 1,
655+
sessionId: "session-mention",
656+
};
657+
},
658+
} as never,
659+
logger: createNoopLogger(),
660+
});
661+
662+
const handler = handlers.get("pull_request_review_comment.created");
663+
expect(handler).toBeDefined();
664+
665+
const event = buildReviewCommentMentionEvent({
666+
prNumber,
667+
baseRef: "main",
668+
headRef: "feature",
669+
headRepoOwner: "forker",
670+
headRepoName: "repo",
671+
commentBody: "@kodiai apply: update the README",
672+
});
673+
674+
await handler!(event);
675+
await handler!(event);
676+
677+
expect(prCreates).toBe(1);
678+
expect(replies).toHaveLength(2);
679+
expect(replies[1]!).toContain("rate-limited");
680+
681+
await workspaceFixture.cleanup();
682+
});
450683
});

src/handlers/mention.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
fetchAndCheckoutPullRequestHeadRef,
1515
getGitStatusPorcelain,
1616
createBranchCommitAndPush,
17+
WritePolicyError,
1718
} from "../jobs/workspace.ts";
1819
import {
1920
type MentionEvent,
@@ -46,6 +47,10 @@ export function createMentionHandler(deps: {
4647
}): void {
4748
const { eventRouter, jobQueue, workspaceManager, githubApp, executor, logger } = deps;
4849

50+
// Basic in-memory rate limiter for write-mode requests.
51+
// Keyed by installation+repo; resets on process restart.
52+
const lastWriteAt = new Map<string, number>();
53+
4954
function parseWriteIntent(userQuestion: string): {
5055
writeIntent: boolean;
5156
keyword: "apply" | "change" | undefined;
@@ -313,6 +318,26 @@ export function createMentionHandler(deps: {
313318
const isWriteRequest = writeIntent.writeIntent;
314319
const writeEnabled = isWriteRequest && config.write.enabled;
315320

321+
if (writeEnabled && config.write.minIntervalSeconds > 0) {
322+
const key = `${event.installationId}:${mention.owner}/${mention.repo}`;
323+
const now = Date.now();
324+
const last = lastWriteAt.get(key);
325+
const minMs = config.write.minIntervalSeconds * 1000;
326+
327+
if (last !== undefined && now - last < minMs) {
328+
const replyBody = wrapInDetails(
329+
[
330+
"Write request rate-limited.",
331+
"",
332+
`Try again in ${Math.ceil((minMs - (now - last)) / 1000)}s.`,
333+
].join("\n"),
334+
"kodiai response",
335+
);
336+
await postMentionReply(replyBody);
337+
return;
338+
}
339+
}
340+
316341
if (isWriteRequest && mention.prNumber === undefined) {
317342
const replyBody = wrapInDetails(
318343
[
@@ -496,11 +521,33 @@ export function createMentionHandler(deps: {
496521
const branchName = `kodiai/apply/pr-${mention.prNumber}-${shortDelivery}`;
497522
const commitMessage = `kodiai: apply requested changes (pr #${mention.prNumber})`;
498523

499-
const pushed = await createBranchCommitAndPush({
500-
dir: workspace.dir,
501-
branchName,
502-
commitMessage,
503-
});
524+
let pushed: { branchName: string; headSha: string };
525+
try {
526+
pushed = await createBranchCommitAndPush({
527+
dir: workspace.dir,
528+
branchName,
529+
commitMessage,
530+
policy: {
531+
allowPaths: config.write.allowPaths,
532+
denyPaths: config.write.denyPaths,
533+
secretScanEnabled: config.write.secretScan.enabled,
534+
},
535+
});
536+
} catch (err) {
537+
if (err instanceof WritePolicyError) {
538+
const replyBody = wrapInDetails(
539+
[
540+
"Write request refused.",
541+
"",
542+
err.message,
543+
].join("\n"),
544+
"kodiai response",
545+
);
546+
await postMentionReply(replyBody);
547+
return;
548+
}
549+
throw err;
550+
}
504551

505552
const prTitle = `kodiai: apply changes for PR #${mention.prNumber}`;
506553
const prBody = [
@@ -529,6 +576,12 @@ export function createMentionHandler(deps: {
529576
"kodiai response",
530577
);
531578
await postMentionReply(replyBody);
579+
580+
// Record successful publish time for rate limiting.
581+
if (config.write.minIntervalSeconds > 0) {
582+
const key = `${event.installationId}:${mention.owner}/${mention.repo}`;
583+
lastWriteAt.set(key, Date.now());
584+
}
532585
return;
533586
}
534587

0 commit comments

Comments
 (0)