Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 64c1011

Browse files
committed
fix(agent): secure repo-less cloud clones
1 parent 6380d23 commit 64c1011

12 files changed

Lines changed: 314 additions & 22 deletions

File tree

‎packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,54 @@ describe("CodexAppServerAgent", () => {
929929
expect(requestPermission).not.toHaveBeenCalled();
930930
});
931931

932+
it("auto-accepts repository tools from the built-in local MCP server in auto mode", async () => {
933+
const stub = makeStubRpc({
934+
initialize: {},
935+
"thread/start": { thread: { id: "thr_1" } },
936+
});
937+
const requestPermission = vi.fn();
938+
const client = {
939+
sessionUpdate: async () => {},
940+
requestPermission,
941+
extNotification: async () => {},
942+
} as unknown as AgentSideConnection;
943+
const agent = new CodexAppServerAgent(client, {
944+
processOptions: { binaryPath: "/bundle/codex" },
945+
model: "gpt-5.5",
946+
rpcFactory: stub.factory,
947+
});
948+
await agent.initialize(init);
949+
await agent.newSession({
950+
cwd: "/repo",
951+
_meta: {
952+
environment: "cloud",
953+
channelMode: true,
954+
permissionMode: "auto",
955+
},
956+
} as unknown as NewSessionRequest);
957+
958+
stub.emit("item/started", {
959+
item: {
960+
type: "mcpToolCall",
961+
id: "m1",
962+
server: "posthog-code-tools",
963+
tool: "clone_repo",
964+
arguments: { repo: "PostHog/posthog" },
965+
},
966+
});
967+
const decision = await stub.invokeRequest("mcpServer/elicitation/request", {
968+
threadId: "thr_1",
969+
turnId: "turn_1",
970+
serverName: "posthog-code-tools",
971+
mode: "form",
972+
message:
973+
'Allow the posthog-code-tools MCP server to run tool "clone_repo"?',
974+
});
975+
976+
expect(decision).toMatchObject({ action: "accept" });
977+
expect(requestPermission).not.toHaveBeenCalled();
978+
});
979+
932980
it("auto-accepts a gated PostHog exec sub-tool in local hands-off modes", async () => {
933981
const stub = makeStubRpc({
934982
initialize: {},

‎packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
estimateTokens,
5454
} from "../claude/context-breakdown";
5555
import { isLocalSkillCommandChunk } from "../local-skill";
56+
import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
5657
import { resolveSpokenNarration } from "../session-meta";
5758
import {
5859
AppServerClient,
@@ -1939,6 +1940,22 @@ export class CodexAppServerAgent extends BaseAcpAgent {
19391940
);
19401941
}
19411942

1943+
private shouldAutoAcceptMcpToolCall(mcp: {
1944+
server: string;
1945+
tool: string;
1946+
args: unknown;
1947+
}): boolean {
1948+
const isHandsOffMode =
1949+
this.config.mode === "auto" || this.config.mode === "full-access";
1950+
const isRepositoryTool =
1951+
mcp.server === LOCAL_TOOLS_MCP_NAME &&
1952+
(mcp.tool === "list_repos" || mcp.tool === "clone_repo");
1953+
return (
1954+
(isHandsOffMode && isRepositoryTool) ||
1955+
this.shouldAutoAcceptPostHogExec(mcp)
1956+
);
1957+
}
1958+
19421959
/**
19431960
* Server-initiated requests. Simple approvals resolve to a `{ decision }` envelope (a bare
19441961
* string is rejected); richer ones (AskUserQuestion / permission profile / elicitation) go
@@ -1953,7 +1970,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
19531970
logger: this.logger,
19541971
resolveMcpToolCall: (serverName) => this.mcp.byServer(serverName),
19551972
shouldAutoAcceptMcpToolCall: (mcp) =>
1956-
this.shouldAutoAcceptPostHogExec(mcp),
1973+
this.shouldAutoAcceptMcpToolCall(mcp),
19571974
});
19581975
if (richer.handled) {
19591976
return richer.response;
@@ -2001,7 +2018,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
20012018
// Codex has no MCP-specific approval; a known MCP call surfaces the real server/tool/args
20022019
// so the host renders the proper MCP permission (incl. PostHog `exec` unwrapping).
20032020
const mcp = this.mcp.byItemId(detail.itemId);
2004-
if (mcp && this.shouldAutoAcceptPostHogExec(mcp)) {
2021+
if (mcp && this.shouldAutoAcceptMcpToolCall(mcp)) {
20052022
return { decision: "accept" };
20062023
}
20072024
// kind + content route plain command/file approvals to Execute/EditPermission (not the fallback).

‎packages/agent/src/adapters/local-tools/tools/clone-repo.test.ts‎

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,66 @@ describe("clone_repo", () => {
4242
await rm(cwd, { recursive: true, force: true });
4343
});
4444

45-
it("requests a shallow clone of the selected branch", async () => {
45+
it("authenticates a shallow clone without persisting the token in origin", async () => {
46+
mocks.cloneRun.mockImplementationOnce(async (input) => {
47+
await mkdir(input.targetPath, { recursive: true });
48+
const git = createGitClient(input.targetPath);
49+
await git.init();
50+
await git.addRemote("origin", input.repoUrl);
51+
return { success: true, data: { targetPath: input.targetPath } };
52+
});
53+
4654
const result = await cloneRepoTool.handler(
4755
{ cwd, token: "test-token" },
4856
{ repo: "PostHog/posthog", branch: "feature" },
4957
);
5058

5159
expect(result.isError).toBeUndefined();
52-
expect(mocks.cloneRun).toHaveBeenCalledWith({
53-
repoUrl:
54-
"https://x-access-token:test-token@github.com/PostHog/posthog.git",
60+
const cloneInput = mocks.cloneRun.mock.calls[0]?.[0];
61+
expect(cloneInput).toMatchObject({
62+
repoUrl: "https://github.com/PostHog/posthog.git",
5563
targetPath: path.join(cwd, "repos", "PostHog", "posthog"),
5664
branch: "feature",
5765
shallow: true,
5866
});
67+
expect(cloneInput.env).toMatchObject({
68+
GIT_CONFIG_COUNT: "1",
69+
GIT_CONFIG_KEY_0: "http.extraHeader",
70+
});
71+
expect(cloneInput.env.GIT_CONFIG_VALUE_0).not.toContain("test-token");
72+
expect(
73+
Buffer.from(
74+
cloneInput.env.GIT_CONFIG_VALUE_0.replace("AUTHORIZATION: basic ", ""),
75+
"base64",
76+
).toString("utf8"),
77+
).toBe("x-access-token:test-token");
78+
const targetGit = createGitClient(
79+
path.join(cwd, "repos", "PostHog", "posthog"),
80+
);
81+
expect((await targetGit.remote(["get-url", "origin"]))?.trim()).toBe(
82+
"https://github.com/PostHog/posthog.git",
83+
);
84+
});
85+
86+
it("removes credentials from an existing clone origin", async () => {
87+
const targetPath = path.join(cwd, "repos", "PostHog", "posthog");
88+
await mkdir(targetPath, { recursive: true });
89+
const git = createGitClient(targetPath);
90+
await git.init();
91+
await git.addRemote(
92+
"origin",
93+
"https://x-access-token:stale-token@github.com/PostHog/posthog.git",
94+
);
95+
96+
const result = await cloneRepoTool.handler(
97+
{ cwd, token: "test-token" },
98+
{ repo: "PostHog/posthog" },
99+
);
100+
101+
expect(result.isError).toBeUndefined();
102+
expect((await git.remote(["get-url", "origin"]))?.trim()).toBe(
103+
"https://github.com/PostHog/posthog.git",
104+
);
59105
});
60106

61107
it("fetches a missing branch into an existing shallow clone", async () => {

‎packages/agent/src/adapters/local-tools/tools/clone-repo.ts‎

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fs from "node:fs";
22
import * as path from "node:path";
33
import { createGitClient } from "@posthog/git/client";
4+
import { getCleanEnv } from "@posthog/git/operation-manager";
45
import { getCurrentBranch } from "@posthog/git/queries";
56
import { CloneSaga } from "@posthog/git/sagas/clone";
67
import { parseGithubUrl } from "@posthog/git/utils";
@@ -26,6 +27,28 @@ function fail(text: string): LocalToolResult {
2627
return { content: [{ type: "text", text }], isError: true };
2728
}
2829

30+
function githubAuthEnv(token: string | undefined): Record<string, string> {
31+
if (!token) return {};
32+
const basicAuth = Buffer.from(`x-access-token:${token}`).toString("base64");
33+
return {
34+
GIT_CONFIG_COUNT: "1",
35+
GIT_CONFIG_KEY_0: "http.extraHeader",
36+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basicAuth}`,
37+
};
38+
}
39+
40+
function hasHttpCredentials(remoteUrl: string): boolean {
41+
try {
42+
const url = new URL(remoteUrl);
43+
return (
44+
(url.protocol === "http:" || url.protocol === "https:") &&
45+
Boolean(url.username || url.password)
46+
);
47+
} catch {
48+
return false;
49+
}
50+
}
51+
2952
/**
3053
* Lazily brings a repo into a repo-less channel session's scratch workspace.
3154
* Clones into `<cwd>/repos/<repo>` (a subdir of the session cwd, so no session
@@ -61,6 +84,11 @@ export const cloneRepoTool = defineLocalTool({
6184
const slug = `${parsed.owner}/${parsed.repo}`;
6285
const repoName = parsed.repo;
6386
const targetPath = path.join(ctx.cwd, "repos", slug);
87+
const cloneUrl = `https://github.com/${slug}.git`;
88+
const authenticatedGitEnv = {
89+
...getCleanEnv(),
90+
...githubAuthEnv(token),
91+
};
6492

6593
const done = async (note?: string): Promise<LocalToolResult> => {
6694
const checkedOut = (await getCurrentBranch(targetPath)) ?? branch ?? null;
@@ -78,7 +106,7 @@ export const cloneRepoTool = defineLocalTool({
78106

79107
const checkout = async (): Promise<LocalToolResult | null> => {
80108
if (!branch) return null;
81-
const git = createGitClient(targetPath);
109+
const git = createGitClient(targetPath).env(authenticatedGitEnv);
82110
try {
83111
await git.checkout(branch);
84112
return null;
@@ -115,24 +143,35 @@ export const cloneRepoTool = defineLocalTool({
115143
// the repo in place. Reuse it instead of letting git abort on a non-empty
116144
// destination, which the agent would receive as an opaque error.
117145
if (fs.existsSync(path.join(targetPath, ".git"))) {
146+
const git = createGitClient(targetPath);
147+
try {
148+
const originUrl = await git.remote(["get-url", "origin"]);
149+
if (
150+
typeof originUrl === "string" &&
151+
hasHttpCredentials(originUrl.trim())
152+
) {
153+
await git.remote(["set-url", "origin", cloneUrl]);
154+
}
155+
} catch (err) {
156+
return fail(
157+
`clone_repo couldn't secure the existing origin: ${redact(
158+
err instanceof Error ? err.message : String(err),
159+
)}`,
160+
);
161+
}
118162
return (
119163
(await checkout()) ??
120164
(await done(`${slug} already cloned at ${targetPath}`))
121165
);
122166
}
123167

124-
// GitHub accepts a token as the basic-auth username for https clones; this
125-
// covers private repos. Public repos clone fine without it.
126-
const cloneUrl = token
127-
? `https://x-access-token:${token}@github.com/${slug}.git`
128-
: `https://github.com/${slug}.git`;
129-
130168
try {
131169
const result = await new CloneSaga().run({
132170
repoUrl: cloneUrl,
133171
targetPath,
134172
branch,
135173
shallow: true,
174+
env: githubAuthEnv(token),
136175
});
137176
if (!result.success) {
138177
return fail(`clone_repo failed: ${redact(result.error)}`);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type {
2+
ExtensionAPI,
3+
ToolDefinition,
4+
} from "@earendil-works/pi-coding-agent";
5+
import { describe, expect, it } from "vitest";
6+
import { createPiRepositoryToolsExtension } from "./repository-tools-extension";
7+
8+
describe("createPiRepositoryToolsExtension", () => {
9+
it("registers the repo-less clone and discovery tools", async () => {
10+
type RegisteredTool = Pick<ToolDefinition, "name" | "execute">;
11+
const registered: RegisteredTool[] = [];
12+
const extension = createPiRepositoryToolsExtension("/tmp/workspace");
13+
await extension.factory({
14+
registerTool: (tool: ToolDefinition) => {
15+
registered.push(tool);
16+
},
17+
} as unknown as ExtensionAPI);
18+
19+
expect(registered.map((tool) => tool.name)).toEqual([
20+
"list_repos",
21+
"clone_repo",
22+
]);
23+
const cloneTool = registered.find((tool) => tool.name === "clone_repo");
24+
expect(cloneTool).toBeDefined();
25+
await expect(
26+
cloneTool?.execute(
27+
"call-1",
28+
{ repo: "not a repository" },
29+
undefined,
30+
undefined,
31+
{} as never,
32+
),
33+
).rejects.toThrow('clone_repo: invalid repo "not a repository"');
34+
});
35+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type {
2+
ExtensionFactory,
3+
InlineExtension,
4+
} from "@earendil-works/pi-coding-agent";
5+
import { defineTool } from "@earendil-works/pi-coding-agent";
6+
import { convertJsonSchemaToTypebox } from "@posthog/harness/extensions/mcp/schema";
7+
import { z } from "zod";
8+
import { enabledLocalTools, type LocalToolCtx } from "../adapters/local-tools";
9+
10+
const REPOSITORY_TOOL_NAMES = new Set(["list_repos", "clone_repo"]);
11+
type NamedInlineExtension = Exclude<InlineExtension, ExtensionFactory>;
12+
13+
function toolLabel(name: string): string {
14+
return name
15+
.replaceAll("_", " ")
16+
.replace(/^./, (first) => first.toUpperCase());
17+
}
18+
19+
function createRepositoryToolsFactory(cwd: string): ExtensionFactory {
20+
return (pi) => {
21+
const context: LocalToolCtx = { cwd };
22+
const tools = enabledLocalTools(context, { channelMode: true }).filter(
23+
(tool) => REPOSITORY_TOOL_NAMES.has(tool.name),
24+
);
25+
26+
for (const localTool of tools) {
27+
const schema = z.object(localTool.schema);
28+
pi.registerTool(
29+
defineTool({
30+
name: localTool.name,
31+
label: toolLabel(localTool.name),
32+
description: localTool.description,
33+
promptSnippet: localTool.description,
34+
parameters: convertJsonSchemaToTypebox(z.toJSONSchema(schema)),
35+
execute: async (_toolCallId, params) => {
36+
const parsed = schema.safeParse(params);
37+
if (!parsed.success) {
38+
throw new Error(parsed.error.message);
39+
}
40+
const result = await localTool.handler(context, parsed.data);
41+
if (result.isError) {
42+
throw new Error(
43+
result.content.map((item) => item.text).join("\n"),
44+
);
45+
}
46+
return { content: result.content, details: {} };
47+
},
48+
}),
49+
);
50+
}
51+
};
52+
}
53+
54+
export function createPiRepositoryToolsExtension(
55+
cwd: string,
56+
): NamedInlineExtension {
57+
return {
58+
name: "posthog-code-repository-tools",
59+
factory: createRepositoryToolsFactory(cwd),
60+
};
61+
}

0 commit comments

Comments
 (0)