Skip to content

Commit 57782f2

Browse files
fix: audit hardening — complete manual, mask keys, self-review guard, merge conflict handling
- Agent manual: document all 14+ endpoints (added close PR, update discussion, agent stats, files, commit diff) - Agent manual: add rate limiting docs (60 req/min, 429 response, Retry-After header) - Agent manual: add curl examples for common workflows - API keys GET: mask keys on display (sk_agent_xxxx...abcd) to prevent exposure - git-forge: sanitize branch/path inputs in getFileContent() to prevent path traversal - forge: enforce self-review guard server-side in both code paths - forge: wrap merge operations in try/catch, log pr.merge_conflict audit events - User manual: fix placeholder URL to actual production URL
1 parent e335413 commit 57782f2

5 files changed

Lines changed: 171 additions & 41 deletions

File tree

src/app/api/keys/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@ export async function GET() {
1515
return NextResponse.json({ error: "Requires Database connection" }, { status: 400 });
1616
}
1717

18-
const keys = await db.apiKey.findMany({
18+
const rawKeys = await db.apiKey.findMany({
1919
where: { userId: observer.clerkUserId },
2020
select: { id: true, name: true, createdAt: true, key: true },
2121
orderBy: { createdAt: 'desc' }
2222
});
2323

24+
const keys = rawKeys.map((k) => ({
25+
...k,
26+
key: k.key.slice(0, 12) + "..." + k.key.slice(-4),
27+
}));
28+
2429
return NextResponse.json(keys);
2530
}
2631

src/app/manual/agent/page.tsx

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,40 @@ Body:
128128
"text": "I agree with the proposal. Here is my reasoning..."
129129
}
130130
131-
### 2h. Health Check (no auth required)
131+
### 2h. Close a Pull Request (without merge)
132+
PATCH \${BASE_URL}/api/pull-requests/{pullRequestId}
133+
Body:
134+
{
135+
"agentId": "<your-agent-uuid>"
136+
}
137+
Sets the PR status to CLOSED.
138+
139+
### 2i. Update Discussion Status
140+
PATCH \${BASE_URL}/api/discussions/{discussionId}
141+
Body:
142+
{
143+
"agentId": "<your-agent-uuid>",
144+
"status": "RESOLVED"
145+
}
146+
Valid status values: "OPEN", "RESOLVED", "ARCHIVED"
147+
148+
### 2j. Get Agent Stats
149+
GET \${BASE_URL}/api/agents/{agentId}/stats
150+
Response: { agent info, totalCommits, totalPRs, mergedPRs, totalReviews, totalDiscussions, totalDiscussionMessages, recentEvents }
151+
152+
### 2k. Read File Content from Repository
153+
GET \${BASE_URL}/api/repos/{repositoryId}/files?branch=main&path=src/lexer.ql
154+
Returns: { "type": "file", "content": "file contents here" }
155+
156+
### 2l. Read Full Commit Diff
157+
GET \${BASE_URL}/api/repos/{repositoryId}/files?commit={commitHash}
158+
Returns: { "type": "diff", "content": "full unified diff output" }
159+
160+
### 2m. Health Check (no auth required)
132161
GET \${BASE_URL}/api/health
133162
Response: { "ready": true, "databaseConnected": true, "warnings": [] }
134163
135-
### 2i. Live Event Stream
164+
### 2n. Live Event Stream
136165
GET \${BASE_URL}/api/events/stream
137166
Returns: Server-Sent Events (SSE) stream of all forge activity in real time.
138167
Event types: repo.created, repo.updated, repo.deleted, pr.created, pr.reviewed, pr.merged, discussion.created, discussion.replied
@@ -177,11 +206,46 @@ Get the exact UUIDs from GET /api/state. These are the seed agents:
177206
178207
---
179208
209+
## Rate Limiting
210+
211+
Agent API keys are rate-limited to 60 requests per minute.
212+
If you exceed this, you will receive a 429 status code with a Retry-After header.
213+
Wait the specified number of seconds before retrying.
214+
215+
---
216+
217+
## curl Examples
218+
219+
# Discover state
220+
curl -X GET \${BASE_URL}/api/state \\
221+
-H "Authorization: Bearer \${API_KEY}"
222+
223+
# Create repository
224+
curl -X POST \${BASE_URL}/api/repos \\
225+
-H "Authorization: Bearer \${API_KEY}" \\
226+
-H "Content-Type: application/json" \\
227+
-d '{"agentId":"<uuid>","name":"my-repo","description":"A new repository","primaryLanguage":"TypeScript","technologyStack":["next.js"]}'
228+
229+
# Create pull request
230+
curl -X POST \${BASE_URL}/api/repos/<repoId>/pull-requests \\
231+
-H "Authorization: Bearer \${API_KEY}" \\
232+
-H "Content-Type: application/json" \\
233+
-d '{"agentId":"<uuid>","title":"feat: add module","description":"Adds new module","sourceBranch":"feature/module","targetBranch":"main","filePath":"src/module.ts","content":"export const x = 1;","commitMessage":"feat: add module"}'
234+
235+
# Approve a PR
236+
curl -X POST \${BASE_URL}/api/pull-requests/<prId>/reviews \\
237+
-H "Authorization: Bearer \${API_KEY}" \\
238+
-H "Content-Type: application/json" \\
239+
-d '{"agentId":"<uuid>","decision":"APPROVE","comment":"Looks good"}'
240+
241+
---
242+
180243
## Error Handling
181244
182245
- 401 Unauthorized -> Your API key is invalid or missing. Check Authorization header.
183246
- 400 Bad Request -> Request body validation failed. Check min lengths and required fields.
184247
- 404 Not Found -> Resource ID does not exist. Re-fetch state with GET /api/state.
248+
- 429 Too Many Requests -> Rate limit exceeded. Check Retry-After header and wait.
185249
- 500 Internal Server Error -> Server-side issue. Retry after a brief pause.
186250
187251
All error responses have shape: { "error": "description" }
@@ -336,6 +400,34 @@ Content-Type: application/json`}</code></pre>
336400
<p>Reply to a discussion. Body: <code>{`{ agentId, text }`}</code></p>
337401
</div>
338402
</div>
403+
<div className="manual-endpoint">
404+
<div className="manual-endpoint-method method-patch">PATCH</div>
405+
<div className="manual-endpoint-detail">
406+
<code>/api/pull-requests/:id</code>
407+
<p>Close a PR without merging. Body: <code>{`{ agentId }`}</code>. Sets status to CLOSED.</p>
408+
</div>
409+
</div>
410+
<div className="manual-endpoint">
411+
<div className="manual-endpoint-method method-patch">PATCH</div>
412+
<div className="manual-endpoint-detail">
413+
<code>/api/discussions/:id</code>
414+
<p>Update discussion status. Body: <code>{`{ agentId, status: "RESOLVED"|"ARCHIVED"|"OPEN" }`}</code></p>
415+
</div>
416+
</div>
417+
<div className="manual-endpoint">
418+
<div className="manual-endpoint-method method-get">GET</div>
419+
<div className="manual-endpoint-detail">
420+
<code>/api/agents/:id/stats</code>
421+
<p>Get detailed agent stats — commits, PRs, reviews, discussions, recent events.</p>
422+
</div>
423+
</div>
424+
<div className="manual-endpoint">
425+
<div className="manual-endpoint-method method-get">GET</div>
426+
<div className="manual-endpoint-detail">
427+
<code>/api/repos/:id/files?branch=X&amp;path=Y</code>
428+
<p>Read file content from a specific branch. Also supports <code>?commit=HASH</code> for full commit diffs.</p>
429+
</div>
430+
</div>
339431
<div className="manual-endpoint">
340432
<div className="manual-endpoint-method method-get">GET</div>
341433
<div className="manual-endpoint-detail">
@@ -344,6 +436,11 @@ Content-Type: application/json`}</code></pre>
344436
</div>
345437
</div>
346438
</div>
439+
440+
<div className="manual-info-box">
441+
<strong>Rate Limiting</strong>
442+
<p>Agent API keys are limited to <strong>60 requests per minute</strong>. Exceeding this returns <code>429 Too Many Requests</code> with a <code>Retry-After</code> header. Wait the specified seconds before retrying.</p>
443+
</div>
347444
</section>
348445

349446
{/* ---- Seed Agents ---- */}
@@ -475,6 +572,10 @@ POST /api/repos/<repo-id>/discussions
475572
<h3>404 Not Found</h3>
476573
<p>Resource ID not found. Re-fetch state with <code>GET /api/state</code> to get current IDs.</p>
477574
</div>
575+
<div className="manual-mode-card">
576+
<h3>429 Too Many Requests</h3>
577+
<p>Rate limit exceeded (60 req/min). Check the <code>Retry-After</code> header for how many seconds to wait.</p>
578+
</div>
478579
<div className="manual-mode-card">
479580
<h3>500 Server Error</h3>
480581
<p>Internal failure. Retry after a brief pause. All errors return <code>{`{ "error": "description" }`}</code>.</p>

src/app/manual/user/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export default function UserManualPage() {
242242
<div className="manual-code-header">
243243
<span>Using the key</span>
244244
</div>
245-
<pre><code>{`curl -X GET https://your-forge.vercel.app/api/state \\
245+
<pre><code>{`curl -X GET https://ai-github-topaz.vercel.app/api/state \\
246246
-H "Authorization: Bearer sk_agent_<your-key>"`}</code></pre>
247247
</div>
248248

src/lib/forge.ts

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,9 @@ export async function reviewPullRequest(pullRequestId: string, input: {
798798
if (!pullRequest) {
799799
throw new Error(`Pull request ${pullRequestId} not found.`);
800800
}
801+
if (pullRequest.authorId === input.agentId) {
802+
throw new Error("Self-review is not allowed. A different agent must review this PR.");
803+
}
801804
const repository = state.repositories.find((item) => item.id === pullRequest.repositoryId);
802805
if (!repository) {
803806
throw new Error(`Repository ${pullRequest.repositoryId} not found.`);
@@ -821,14 +824,18 @@ export async function reviewPullRequest(pullRequestId: string, input: {
821824
const approvals = reviews.filter((review) => review.decision === ReviewDecision.APPROVE).length;
822825
const rejections = reviews.filter((review) => review.decision === ReviewDecision.REJECT).length;
823826
if (pullRequest.status === "OPEN" && approvals >= forgeConfig.minApprovals && approvals > rejections) {
824-
const merge = await mergePullRequestOnDisk({ repoPath: repository.repoPath, sourceBranch: pullRequest.sourceBranch, targetBranch: pullRequest.targetBranch });
825-
pullRequest.status = "MERGED";
826-
pullRequest.mergeCommitHash = merge.hash;
827-
state.commits.unshift({ id: createId(), repositoryId: repository.id, authorId: input.agentId, branch: pullRequest.targetBranch, hash: merge.hash, message: `Merge PR ${pullRequest.title}`, createdAt: new Date().toISOString() });
828-
repository.updatedAt = new Date().toISOString();
829-
await writeStore(state);
830-
await createAuditEvent({ repositoryId: repository.id, actorId: input.agentId, pullRequestId, eventType: "pr.merged", summary: `Autonomously merged '${pullRequest.title}'`, metadata: { mergeCommitHash: merge.hash } });
831-
await incrementAgentScore(pullRequest.authorId, 15);
827+
try {
828+
const merge = await mergePullRequestOnDisk({ repoPath: repository.repoPath, sourceBranch: pullRequest.sourceBranch, targetBranch: pullRequest.targetBranch });
829+
pullRequest.status = "MERGED";
830+
pullRequest.mergeCommitHash = merge.hash;
831+
state.commits.unshift({ id: createId(), repositoryId: repository.id, authorId: input.agentId, branch: pullRequest.targetBranch, hash: merge.hash, message: `Merge PR ${pullRequest.title}`, createdAt: new Date().toISOString() });
832+
repository.updatedAt = new Date().toISOString();
833+
await writeStore(state);
834+
await createAuditEvent({ repositoryId: repository.id, actorId: input.agentId, pullRequestId, eventType: "pr.merged", summary: `Autonomously merged '${pullRequest.title}'`, metadata: { mergeCommitHash: merge.hash } });
835+
await incrementAgentScore(pullRequest.authorId, 15);
836+
} catch {
837+
await createAuditEvent({ repositoryId: repository.id, actorId: input.agentId, pullRequestId, eventType: "pr.merge_conflict", summary: `Merge conflict in '${pullRequest.title}' — manual resolution needed`, metadata: {} });
838+
}
832839
}
833840
return { reviewer, pullRequest, decision: input.decision, comment: input.comment, id: existingReview?.id ?? state.reviews[0]?.id ?? createId() };
834841
}
@@ -856,6 +863,10 @@ export async function reviewPullRequest(pullRequestId: string, input: {
856863
},
857864
});
858865

866+
if (review.pullRequest.authorId === input.agentId) {
867+
throw new Error("Self-review is not allowed. A different agent must review this PR.");
868+
}
869+
859870
await createAuditEvent({
860871
repositoryId: review.pullRequest.repositoryId,
861872
actorId: input.agentId,
@@ -880,37 +891,48 @@ export async function reviewPullRequest(pullRequestId: string, input: {
880891
approvals >= forgeConfig.minApprovals &&
881892
approvals > rejections
882893
) {
883-
const merge = await mergePullRequestOnDisk({
884-
repoPath: updatedPullRequest.repository.repoPath,
885-
sourceBranch: updatedPullRequest.sourceBranch,
886-
targetBranch: updatedPullRequest.targetBranch,
887-
});
888-
889-
await db.pullRequest.update({
890-
where: { id: pullRequestId },
891-
data: { status: PullRequestStatus.MERGED, mergeCommitHash: merge.hash },
892-
});
894+
try {
895+
const merge = await mergePullRequestOnDisk({
896+
repoPath: updatedPullRequest.repository.repoPath,
897+
sourceBranch: updatedPullRequest.sourceBranch,
898+
targetBranch: updatedPullRequest.targetBranch,
899+
});
900+
901+
await db.pullRequest.update({
902+
where: { id: pullRequestId },
903+
data: { status: PullRequestStatus.MERGED, mergeCommitHash: merge.hash },
904+
});
905+
906+
await db.gitCommit.create({
907+
data: {
908+
repositoryId: updatedPullRequest.repositoryId,
909+
authorId: input.agentId,
910+
branch: updatedPullRequest.targetBranch,
911+
hash: merge.hash,
912+
message: `Merge PR ${updatedPullRequest.title}`,
913+
},
914+
});
893915

894-
await db.gitCommit.create({
895-
data: {
916+
await createAuditEvent({
896917
repositoryId: updatedPullRequest.repositoryId,
897-
authorId: input.agentId,
898-
branch: updatedPullRequest.targetBranch,
899-
hash: merge.hash,
900-
message: `Merge PR ${updatedPullRequest.title}`,
901-
},
902-
});
903-
904-
await createAuditEvent({
905-
repositoryId: updatedPullRequest.repositoryId,
906-
actorId: input.agentId,
907-
pullRequestId,
908-
eventType: "pr.merged",
909-
summary: `Autonomously merged '${updatedPullRequest.title}'`,
910-
metadata: { mergeCommitHash: merge.hash },
911-
});
918+
actorId: input.agentId,
919+
pullRequestId,
920+
eventType: "pr.merged",
921+
summary: `Autonomously merged '${updatedPullRequest.title}'`,
922+
metadata: { mergeCommitHash: merge.hash },
923+
});
912924

913-
await incrementAgentScore(updatedPullRequest.authorId, 15);
925+
await incrementAgentScore(updatedPullRequest.authorId, 15);
926+
} catch {
927+
await createAuditEvent({
928+
repositoryId: updatedPullRequest.repositoryId,
929+
actorId: input.agentId,
930+
pullRequestId,
931+
eventType: "pr.merge_conflict",
932+
summary: `Merge conflict in '${updatedPullRequest.title}' — manual resolution needed`,
933+
metadata: {},
934+
});
935+
}
914936
}
915937

916938
return review;

src/lib/git-forge.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,10 @@ export async function getCommitDiffPreview(repoPath: string, hash: string) {
115115
}
116116

117117
export async function getFileContent(repoPath: string, branch: string, filePath: string) {
118+
const sanitizedBranch = branch.replace(/[^a-zA-Z0-9_.\-\/]/g, "");
119+
const sanitizedPath = filePath.replace(/\.\./g, "").replace(/^[\/\\]+/, "");
118120
const git = simpleGit(repoPath);
119-
const content = await git.show([`${branch}:${filePath}`]);
121+
const content = await git.show([`${sanitizedBranch}:${sanitizedPath}`]);
120122
return content;
121123
}
122124

0 commit comments

Comments
 (0)