Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,17 @@ import {
type FileEnrichmentDeps,
} from "../../enrichment/file-enricher";
import type { PostHogAPIConfig } from "../../types";
import { unreachable, withTimeout } from "../../utils/common";
import {
isCloudRun,
resolveGithubToken,
unreachable,
withTimeout,
} from "../../utils/common";
import { Logger } from "../../utils/logger";
import { Pushable } from "../../utils/streams";
import { BaseAcpAgent } from "../base-acp-agent";
import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
import { resolveTaskId } from "../session-meta";
import { promptToClaude } from "./conversion/acp-to-sdk";
import {
handleResultMessage,
Expand All @@ -69,6 +76,7 @@ import {
handleUserAssistantMessage,
} from "./conversion/sdk-to-acp";
import type { EnrichedReadCache } from "./hooks";
import { createLocalToolsMcpServer } from "./mcp/local-tools";
import {
fetchMcpToolMetadata,
getConnectedMcpServerNames,
Expand Down Expand Up @@ -1091,7 +1099,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
const isResume = !!resume;

const meta = params._meta as NewSessionMeta | undefined;
const taskId = meta?.persistence?.taskId;
const taskId = resolveTaskId(meta);
// Gate signed-commit wiring on cloud-run detection so the desktop (which
// signs via CommitSaga) is untouched.
const cloudRun = isCloudRun(meta);
const effort = meta?.claudeCode?.options?.effort as EffortLevel | undefined;

// We want to create a new session id unless it is resume,
Expand All @@ -1115,6 +1126,24 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
const mcpServers = supportsMcpInjection(earlyModelId)
? parseMcpServers(params)
: {};

// Register the in-process general local-tools MCP server. Tools self-gate
// via the registry (e.g. signed-commit is cloud-only and needs a GH token),
// so adding a tool needs no change here. In cloud runs `git commit`/`git
// push` are blocked by the PreToolUse guard (and the sandbox git shim), so
// the agent commits via the signed-commit tool instead.
const localToolsServer = createLocalToolsMcpServer(
{ cwd, token: resolveGithubToken(), taskId },
meta,
);
if (localToolsServer) {
mcpServers[LOCAL_TOOLS_MCP_NAME] = localToolsServer;
} else if (cloudRun) {
this.logger.warn(
"Cloud run registered no local tools — missing GH_TOKEN/GITHUB_TOKEN? signed commits unavailable",
);
}

const systemPrompt = buildSystemPrompt(meta?.systemPrompt);

if (meta?.mcpToolApprovals) {
Expand Down Expand Up @@ -1164,6 +1193,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
effort,
enrichmentDeps: this.enrichment?.deps,
enrichedReadCache: this.enrichedReadCache,
cloudMode: cloudRun,
});

// Use the same abort controller that buildSessionOptions gave to the query
Expand Down
54 changes: 54 additions & 0 deletions packages/agent/src/adapters/claude/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Logger } from "../../utils/logger";
import {
createPreToolUseHook,
createReadEnrichmentHook,
createSignedCommitGuardHook,
type EnrichedReadCache,
} from "./hooks";
import type {
Expand Down Expand Up @@ -311,3 +312,56 @@ describe("createPreToolUseHook", () => {
});
});
});

describe("createSignedCommitGuardHook", () => {
const logger = new Logger();

function bashInput(command: string): HookInput {
return {
session_id: "s",
transcript_path: "/tmp/t",
cwd: "/tmp",
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_use_id: "toolu_1",
tool_input: { command },
} as HookInput;
}

const guard = createSignedCommitGuardHook(logger);
const opts = { signal: new AbortController().signal };

test.each([
"git commit -m x",
"git push origin main",
"git add . && git commit -m 'y'",
"git -C /repo commit",
"git --no-pager push",
])("denies %s", async (command) => {
const result = await guard(bashInput(command), undefined, opts);
expect(result).toMatchObject({
hookSpecificOutput: { permissionDecision: "deny" },
});
});

test.each([
"git status",
"git add .",
"git fetch origin",
"git log --grep=commit",
"git stash push",
"git ls-remote --heads origin x",
])("allows %s", async (command) => {
const result = await guard(bashInput(command), undefined, opts);
expect(result).toEqual({ continue: true });
});

test("ignores non-Bash tools", async () => {
const result = await guard(
{ ...bashInput("git commit"), tool_name: "Read" } as HookInput,
undefined,
opts,
);
expect(result).toEqual({ continue: true });
});
});
86 changes: 86 additions & 0 deletions packages/agent/src/adapters/claude/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type FileEnrichmentDeps,
} from "../../enrichment/file-enricher";
import type { Logger } from "../../utils/logger";
import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../signed-commit-shared";
import { stripCatLineNumbers } from "./conversion/sdk-to-acp";
import {
extractPostHogSubTool,
Expand Down Expand Up @@ -222,6 +223,91 @@ export const createSubagentRewriteHook =
};
};

// git global options that consume the following token as their value, so the
// subcommand detector must skip both (mirrors the sandbox `git` PATH shim).
const GIT_VALUE_FLAGS = new Set([
"-C",
"-c",
"--git-dir",
"--work-tree",
"--namespace",
"--exec-path",
]);

function gitSubcommand(segment: string): string | null {
const tokens = segment.trim().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return null;
// Strip a leading path so `/usr/bin/git` is still recognised as git.
const head = tokens[0].split("/").pop();
if (head !== "git") return null;

let skipNext = false;
for (const tok of tokens.slice(1)) {
if (skipNext) {
skipNext = false;
continue;
}
if (GIT_VALUE_FLAGS.has(tok)) {
skipNext = true;
continue;
}
if (tok.startsWith("-")) continue;
return tok;
}
return null;
}

/**
* True when any top-level shell segment of `command` is a direct `git commit` /
* `git push` invocation (allowing `git`-level global flags like `-C path` or
* `--no-pager`). Does not match subcommands such as `git stash push` or
* `git log --grep=commit`. Git reached via command substitution (`$(git push)`)
* is not caught here — the sandbox `git` PATH shim is the authoritative backstop;
* this hook is a fast in-band deny with a helpful message.
*/
function blocksUnsignedGit(command: string): boolean {
// Cheap reject for the overwhelmingly common non-git Bash call before splitting.
if (!command.includes("git")) return false;
return command.split(/&&|\|\||[;\n|]/).some((segment) => {
const sub = gitSubcommand(segment);
return sub === "commit" || sub === "push";
});
}

/**
* Cloud-only guard: blocks raw `git commit` / `git push` so unsigned commits
* cannot leave the sandbox. The agent must use the `git_signed_commit` tool,
* which creates GitHub-signed (Verified) commits via the API.
*/
export const createSignedCommitGuardHook =
(logger: Logger): HookCallback =>
async (input: HookInput, _toolUseID: string | undefined) => {
if (input.hook_event_name !== "PreToolUse") return { continue: true };
if (input.tool_name !== "Bash") return { continue: true };

const command = (input.tool_input as { command?: string } | undefined)
?.command;
if (!command || !blocksUnsignedGit(command)) {
return { continue: true };
}

logger.info(
`[SignedCommitGuard] Blocking unsigned git command: ${command}`,
);
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "deny" as const,
permissionDecisionReason:
"Commits must be signed: `git commit` and `git push` are disabled here. " +
"Stage changes with `git add`, then call the `git_signed_commit` tool " +
`(${SIGNED_COMMIT_QUALIFIED_TOOL_NAME}) with a \`message\` to create a signed ` +
"commit on the branch.",
},
};
};

export const createPreToolUseHook =
(settingsManager: SettingsManager, logger: Logger): HookCallback =>
async (input: HookInput, _toolUseID: string | undefined) => {
Expand Down
50 changes: 50 additions & 0 deletions packages/agent/src/adapters/claude/mcp/local-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createLocalToolsMcpServer } from "./local-tools";

describe("createLocalToolsMcpServer", () => {
const savedSandbox = process.env.IS_SANDBOX;

beforeEach(() => {
// isCloudRun also keys off IS_SANDBOX; clear it so the meta arg is the only
// cloud signal under test.
delete process.env.IS_SANDBOX;
});

afterEach(() => {
if (savedSandbox === undefined) {
delete process.env.IS_SANDBOX;
} else {
process.env.IS_SANDBOX = savedSandbox;
}
});

it("returns undefined when no tool's gate passes (desktop run)", () => {
expect(
createLocalToolsMcpServer({ cwd: "/repo", token: "ghs_x" }, undefined),
).toBeUndefined();
});

it("exposes git_signed_commit over MCP in a cloud run with a token", async () => {
const server = createLocalToolsMcpServer(
{ cwd: "/repo", token: "ghs_x" },
{ taskRunId: "run-1" },
);
if (!server) {
throw new Error("expected the local-tools server to be registered");
}
expect(server.name).toBe("posthog-local");

const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.instance.connect(serverTransport);
const client = new Client({ name: "test", version: "1.0.0" });
await client.connect(clientTransport);

const { tools } = await client.listTools();
expect(tools.map((t) => t.name)).toContain("git_signed_commit");

await client.close();
});
});
40 changes: 40 additions & 0 deletions packages/agent/src/adapters/claude/mcp/local-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
createSdkMcpServer,
type McpSdkServerConfigWithInstance,
tool,
} from "@anthropic-ai/claude-agent-sdk";
import {
enabledLocalTools,
LOCAL_TOOLS_MCP_NAME,
type LocalToolCtx,
type LocalToolGateMeta,
} from "../../local-tools";

/**
* In-process SDK MCP server exposing the enabled local tools to the Claude
* adapter (see `../../local-tools` for the registry). Returns `undefined` when
* no tool's gate passes, so the caller can skip registering an empty server.
* Registered per session in `claude-agent.ts`.
*/
export function createLocalToolsMcpServer(
ctx: LocalToolCtx,
meta: LocalToolGateMeta | undefined,
): McpSdkServerConfigWithInstance | undefined {
const tools = enabledLocalTools(ctx, meta);
if (tools.length === 0) {
return undefined;
}
return createSdkMcpServer({
name: LOCAL_TOOLS_MCP_NAME,
version: "1.0.0",
tools: tools.map((t) =>
tool(
t.name,
t.description,
t.schema,
async (args) => t.handler(ctx, args),
{ alwaysLoad: t.alwaysLoad ?? false },
),
),
});
}
23 changes: 14 additions & 9 deletions packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createPostToolUseHook,
createPreToolUseHook,
createReadEnrichmentHook,
createSignedCommitGuardHook,
createSubagentRewriteHook,
type EnrichedReadCache,
type OnModeChange,
Expand Down Expand Up @@ -55,6 +56,8 @@ export interface BuildOptionsParams {
effort?: EffortLevel;
enrichmentDeps?: FileEnrichmentDeps;
enrichedReadCache?: EnrichedReadCache;
/** Cloud task session — enables the signed-commit guard. */
cloudMode?: boolean;
}

export function buildSystemPrompt(
Expand Down Expand Up @@ -129,6 +132,7 @@ function buildHooks(
enrichmentDeps: FileEnrichmentDeps | undefined,
enrichedReadCache: EnrichedReadCache | undefined,
registeredAgents: ReadonlySet<string>,
cloudMode: boolean,
): Options["hooks"] {
const postToolUseHooks = [createPostToolUseHook({ onModeChange })];
if (enrichmentDeps && enrichedReadCache) {
Expand All @@ -137,21 +141,21 @@ function buildHooks(
);
}

const preToolUseHooks = [
createPreToolUseHook(settingsManager, logger),
createSubagentRewriteHook(logger, registeredAgents),
];
if (cloudMode) {
preToolUseHooks.push(createSignedCommitGuardHook(logger));
}

return {
...userHooks,
PostToolUse: [
...(userHooks?.PostToolUse || []),
{ hooks: postToolUseHooks },
],
PreToolUse: [
...(userHooks?.PreToolUse || []),
{
hooks: [
createPreToolUseHook(settingsManager, logger),
createSubagentRewriteHook(logger, registeredAgents),
],
},
],
PreToolUse: [...(userHooks?.PreToolUse || []), { hooks: preToolUseHooks }],
};
}

Expand Down Expand Up @@ -352,6 +356,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
params.enrichmentDeps,
params.enrichedReadCache,
registeredAgentNames,
params.cloudMode ?? false,
),
outputFormat: params.outputFormat,
abortController: getAbortController(
Expand Down
Loading
Loading