@posthog/agent 2.3.647 → 2.3.656

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/adapters/claude/permissions/permission-options.js +700 -0
  2. package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
  3. package/dist/adapters/claude/tools.js +700 -0
  4. package/dist/adapters/claude/tools.js.map +1 -1
  5. package/dist/adapters/codex/local-tools-mcp-server.d.ts +2 -0
  6. package/dist/adapters/codex/local-tools-mcp-server.js +1172 -0
  7. package/dist/adapters/codex/local-tools-mcp-server.js.map +1 -0
  8. package/dist/agent.js +1488 -219
  9. package/dist/agent.js.map +1 -1
  10. package/dist/execution-mode.js +700 -0
  11. package/dist/execution-mode.js.map +1 -1
  12. package/dist/handoff-checkpoint.js.map +1 -1
  13. package/dist/posthog-api.d.ts +1 -1
  14. package/dist/posthog-api.js +1 -1
  15. package/dist/posthog-api.js.map +1 -1
  16. package/dist/server/agent-server.js +1637 -342
  17. package/dist/server/agent-server.js.map +1 -1
  18. package/dist/server/bin.cjs +1553 -261
  19. package/dist/server/bin.cjs.map +1 -1
  20. package/dist/types.d.ts +1 -1
  21. package/dist/types.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/adapters/claude/claude-agent.ts +32 -2
  24. package/src/adapters/claude/hooks.test.ts +54 -0
  25. package/src/adapters/claude/hooks.ts +86 -0
  26. package/src/adapters/claude/mcp/local-tools.test.ts +50 -0
  27. package/src/adapters/claude/mcp/local-tools.ts +40 -0
  28. package/src/adapters/claude/session/options.ts +14 -9
  29. package/src/adapters/claude/types.ts +1 -0
  30. package/src/adapters/codex/codex-agent.ts +117 -22
  31. package/src/adapters/codex/local-tools-mcp-server.ts +71 -0
  32. package/src/adapters/local-tools/index.ts +22 -0
  33. package/src/adapters/local-tools/registry.test.ts +57 -0
  34. package/src/adapters/local-tools/registry.ts +81 -0
  35. package/src/adapters/local-tools/tools/signed-commit.ts +26 -0
  36. package/src/adapters/session-meta.ts +16 -0
  37. package/src/adapters/signed-commit-shared.ts +82 -0
  38. package/src/server/agent-server.configure-environment.test.ts +64 -1
  39. package/src/server/agent-server.test.ts +2 -4
  40. package/src/server/agent-server.ts +60 -35
  41. package/src/types.ts +2 -1
  42. package/src/utils/common.ts +14 -0
  43. package/src/utils/gateway.test.ts +70 -0
  44. package/src/utils/gateway.ts +31 -1
package/dist/types.d.ts CHANGED
@@ -26,7 +26,7 @@ interface Task {
26
26
  slug?: string;
27
27
  title: string;
28
28
  description: string;
29
- origin_product: "error_tracking" | "eval_clusters" | "user_created" | "support_queue" | "session_summaries";
29
+ origin_product: "error_tracking" | "eval_clusters" | "user_created" | "support_queue" | "session_summaries" | "signal_report";
30
30
  github_integration?: number | null;
31
31
  repository: string;
32
32
  json_schema?: Record<string, unknown> | null;
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type {\n GitHandoffCheckpoint,\n HandoffLocalGitState as GitHandoffLocalGitState,\n} from \"@posthog/git/handoff\";\n\n/**\n * Stored custom notification following ACP extensibility model.\n * Custom notifications use underscore-prefixed methods (e.g., `_posthog/phase_start`).\n * See: https://agentclientprotocol.com/docs/extensibility\n */\nexport interface StoredNotification {\n type: \"notification\";\n /** When this notification was stored */\n timestamp: string;\n /** JSON-RPC 2.0 notification (no id field = notification, not request) */\n notification: {\n jsonrpc: \"2.0\";\n method: string;\n params?: Record<string, unknown>;\n };\n}\n\n/**\n * Type alias for stored log entries.\n */\nexport type StoredEntry = StoredNotification;\n\n// PostHog Task model (matches PostHog Code's OpenAPI schema)\nexport interface Task {\n id: string;\n task_number?: number;\n slug?: string;\n title: string;\n description: string;\n origin_product:\n | \"error_tracking\"\n | \"eval_clusters\"\n | \"user_created\"\n | \"support_queue\"\n | \"session_summaries\";\n github_integration?: number | null;\n repository: string; // Format: \"organization/repository\" (e.g., \"posthog/posthog-js\")\n json_schema?: Record<string, unknown> | null; // JSON schema for task output validation\n internal?: boolean;\n created_at: string;\n updated_at: string;\n created_by?: {\n id: number;\n uuid: string;\n distinct_id: string;\n first_name: string;\n email: string;\n };\n latest_run?: TaskRun;\n}\n\n// Log entry structure for TaskRun.log\n\nexport type ArtifactType =\n | \"plan\"\n | \"context\"\n | \"reference\"\n | \"output\"\n | \"artifact\"\n | \"user_attachment\";\n\nexport interface TaskRunArtifact {\n id?: string;\n name: string;\n type: ArtifactType;\n source?: string;\n size?: number;\n content_type?: string;\n storage_path?: string;\n uploaded_at?: string;\n}\n\nexport type TaskRunStatus =\n | \"not_started\"\n | \"queued\"\n | \"in_progress\"\n | \"completed\"\n | \"failed\"\n | \"cancelled\";\n\nexport type TaskRunEnvironment = \"local\" | \"cloud\";\n\n// TaskRun model - represents individual execution runs of tasks\nexport interface TaskRun {\n id: string;\n task: string; // Task ID\n team: number;\n branch: string | null;\n stage: string | null; // Current stage (e.g., 'research', 'plan', 'build')\n environment: TaskRunEnvironment;\n status: TaskRunStatus;\n log_url: string;\n error_message: string | null;\n output: Record<string, unknown> | null; // Structured output (PR URL, commit SHA, etc.)\n state: Record<string, unknown>; // Intermediate run state (defaults to {}, never null)\n artifacts?: TaskRunArtifact[];\n created_at: string;\n updated_at: string;\n completed_at: string | null;\n}\n\nexport interface ProcessSpawnedCallback {\n onProcessSpawned?: (info: {\n pid: number;\n command: string;\n sessionId?: string;\n }) => void;\n onProcessExited?: (pid: number) => void;\n onMcpServersReady?: (serverNames: string[]) => void;\n}\n\nexport interface TaskExecutionOptions {\n repositoryPath?: string;\n adapter?: \"claude\" | \"codex\";\n model?: string;\n gatewayUrl?: string;\n codexBinaryPath?: string;\n instructions?: string;\n processCallbacks?: ProcessSpawnedCallback;\n /** Callback invoked when the agent calls the create_output tool for structured output */\n onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;\n}\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nexport type OnLogCallback = (\n level: LogLevel,\n scope: string,\n message: string,\n data?: unknown,\n) => void;\n\nexport interface PostHogAPIConfig {\n apiUrl: string;\n getApiKey: () => string | Promise<string>;\n refreshApiKey?: () => string | Promise<string>;\n projectId: number;\n userAgent?: string;\n}\n\nexport interface OtelTransportConfig {\n /** PostHog ingest host, e.g., \"https://us.i.posthog.com\" */\n host: string;\n /** Project API key */\n apiKey: string;\n /** Override the logs endpoint path (default: /i/v1/logs) */\n logsPath?: string;\n}\n\nexport interface AgentConfig {\n posthog?: PostHogAPIConfig;\n /** OTEL transport config for shipping logs to PostHog Logs */\n otelTransport?: OtelTransportConfig;\n /** Skip session log persistence (e.g. for preview sessions with no real task) */\n skipLogPersistence?: boolean;\n /** Local cache path for instant log loading (e.g., ~/.posthog-code) */\n localCachePath?: string;\n /**\n * Annotate files the agent reads with PostHog enrichment (event volume,\n * flag rollout/staleness, experiment links). Defaults to enabled when\n * `posthog` config is present; set `{ enabled: false }` to opt out.\n */\n enricher?: { enabled?: boolean };\n debug?: boolean;\n onLog?: OnLogCallback;\n}\n\n// Device info for tracking where work happens\nexport interface DeviceInfo {\n type: \"local\" | \"cloud\";\n name?: string;\n}\n\n// Agent execution mode - for tracking interactive vs background runs, when backgrounded an agent will continue working without asking questions\nexport type AgentMode = \"interactive\" | \"background\";\n\n// Git file status codes\nexport type FileStatus = \"A\" | \"M\" | \"D\";\n\nexport interface FileChange {\n path: string;\n status: FileStatus;\n}\n\nexport type HandoffLocalGitState = GitHandoffLocalGitState;\n\nexport interface GitCheckpoint extends GitHandoffCheckpoint {\n artifactPath?: string;\n indexArtifactPath?: string;\n}\n\nexport interface GitCheckpointEvent extends GitCheckpoint {\n device?: DeviceInfo;\n}\n\n/**\n * Keeps the emitted `@posthog/agent/types` entrypoint as a runtime ESM module.\n *\n * `export {}` is stripped by tsup in this package, which leaves `dist/types.js`\n * empty and breaks downstream type resolution for the exported subpath.\n */\nexport const AGENT_TYPES_MODULE = true;\n"],"mappings":";AA8MO,IAAM,qBAAqB;","names":[]}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type {\n GitHandoffCheckpoint,\n HandoffLocalGitState as GitHandoffLocalGitState,\n} from \"@posthog/git/handoff\";\n\n/**\n * Stored custom notification following ACP extensibility model.\n * Custom notifications use underscore-prefixed methods (e.g., `_posthog/phase_start`).\n * See: https://agentclientprotocol.com/docs/extensibility\n */\nexport interface StoredNotification {\n type: \"notification\";\n /** When this notification was stored */\n timestamp: string;\n /** JSON-RPC 2.0 notification (no id field = notification, not request) */\n notification: {\n jsonrpc: \"2.0\";\n method: string;\n params?: Record<string, unknown>;\n };\n}\n\n/**\n * Type alias for stored log entries.\n */\nexport type StoredEntry = StoredNotification;\n\n// PostHog Task model (matches PostHog Code's OpenAPI schema)\nexport interface Task {\n id: string;\n task_number?: number;\n slug?: string;\n title: string;\n description: string;\n origin_product:\n | \"error_tracking\"\n | \"eval_clusters\"\n | \"user_created\"\n | \"support_queue\"\n | \"session_summaries\"\n | \"signal_report\";\n github_integration?: number | null;\n repository: string; // Format: \"organization/repository\" (e.g., \"posthog/posthog-js\")\n json_schema?: Record<string, unknown> | null; // JSON schema for task output validation\n internal?: boolean;\n created_at: string;\n updated_at: string;\n created_by?: {\n id: number;\n uuid: string;\n distinct_id: string;\n first_name: string;\n email: string;\n };\n latest_run?: TaskRun;\n}\n\n// Log entry structure for TaskRun.log\n\nexport type ArtifactType =\n | \"plan\"\n | \"context\"\n | \"reference\"\n | \"output\"\n | \"artifact\"\n | \"user_attachment\";\n\nexport interface TaskRunArtifact {\n id?: string;\n name: string;\n type: ArtifactType;\n source?: string;\n size?: number;\n content_type?: string;\n storage_path?: string;\n uploaded_at?: string;\n}\n\nexport type TaskRunStatus =\n | \"not_started\"\n | \"queued\"\n | \"in_progress\"\n | \"completed\"\n | \"failed\"\n | \"cancelled\";\n\nexport type TaskRunEnvironment = \"local\" | \"cloud\";\n\n// TaskRun model - represents individual execution runs of tasks\nexport interface TaskRun {\n id: string;\n task: string; // Task ID\n team: number;\n branch: string | null;\n stage: string | null; // Current stage (e.g., 'research', 'plan', 'build')\n environment: TaskRunEnvironment;\n status: TaskRunStatus;\n log_url: string;\n error_message: string | null;\n output: Record<string, unknown> | null; // Structured output (PR URL, commit SHA, etc.)\n state: Record<string, unknown>; // Intermediate run state (defaults to {}, never null)\n artifacts?: TaskRunArtifact[];\n created_at: string;\n updated_at: string;\n completed_at: string | null;\n}\n\nexport interface ProcessSpawnedCallback {\n onProcessSpawned?: (info: {\n pid: number;\n command: string;\n sessionId?: string;\n }) => void;\n onProcessExited?: (pid: number) => void;\n onMcpServersReady?: (serverNames: string[]) => void;\n}\n\nexport interface TaskExecutionOptions {\n repositoryPath?: string;\n adapter?: \"claude\" | \"codex\";\n model?: string;\n gatewayUrl?: string;\n codexBinaryPath?: string;\n instructions?: string;\n processCallbacks?: ProcessSpawnedCallback;\n /** Callback invoked when the agent calls the create_output tool for structured output */\n onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;\n}\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nexport type OnLogCallback = (\n level: LogLevel,\n scope: string,\n message: string,\n data?: unknown,\n) => void;\n\nexport interface PostHogAPIConfig {\n apiUrl: string;\n getApiKey: () => string | Promise<string>;\n refreshApiKey?: () => string | Promise<string>;\n projectId: number;\n userAgent?: string;\n}\n\nexport interface OtelTransportConfig {\n /** PostHog ingest host, e.g., \"https://us.i.posthog.com\" */\n host: string;\n /** Project API key */\n apiKey: string;\n /** Override the logs endpoint path (default: /i/v1/logs) */\n logsPath?: string;\n}\n\nexport interface AgentConfig {\n posthog?: PostHogAPIConfig;\n /** OTEL transport config for shipping logs to PostHog Logs */\n otelTransport?: OtelTransportConfig;\n /** Skip session log persistence (e.g. for preview sessions with no real task) */\n skipLogPersistence?: boolean;\n /** Local cache path for instant log loading (e.g., ~/.posthog-code) */\n localCachePath?: string;\n /**\n * Annotate files the agent reads with PostHog enrichment (event volume,\n * flag rollout/staleness, experiment links). Defaults to enabled when\n * `posthog` config is present; set `{ enabled: false }` to opt out.\n */\n enricher?: { enabled?: boolean };\n debug?: boolean;\n onLog?: OnLogCallback;\n}\n\n// Device info for tracking where work happens\nexport interface DeviceInfo {\n type: \"local\" | \"cloud\";\n name?: string;\n}\n\n// Agent execution mode - for tracking interactive vs background runs, when backgrounded an agent will continue working without asking questions\nexport type AgentMode = \"interactive\" | \"background\";\n\n// Git file status codes\nexport type FileStatus = \"A\" | \"M\" | \"D\";\n\nexport interface FileChange {\n path: string;\n status: FileStatus;\n}\n\nexport type HandoffLocalGitState = GitHandoffLocalGitState;\n\nexport interface GitCheckpoint extends GitHandoffCheckpoint {\n artifactPath?: string;\n indexArtifactPath?: string;\n}\n\nexport interface GitCheckpointEvent extends GitCheckpoint {\n device?: DeviceInfo;\n}\n\n/**\n * Keeps the emitted `@posthog/agent/types` entrypoint as a runtime ESM module.\n *\n * `export {}` is stripped by tsup in this package, which leaves `dist/types.js`\n * empty and breaks downstream type resolution for the exported subpath.\n */\nexport const AGENT_TYPES_MODULE = true;\n"],"mappings":";AA+MO,IAAM,qBAAqB;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.647",
3
+ "version": "2.3.656",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -57,10 +57,17 @@ import {
57
57
  type FileEnrichmentDeps,
58
58
  } from "../../enrichment/file-enricher";
59
59
  import type { PostHogAPIConfig } from "../../types";
60
- import { unreachable, withTimeout } from "../../utils/common";
60
+ import {
61
+ isCloudRun,
62
+ resolveGithubToken,
63
+ unreachable,
64
+ withTimeout,
65
+ } from "../../utils/common";
61
66
  import { Logger } from "../../utils/logger";
62
67
  import { Pushable } from "../../utils/streams";
63
68
  import { BaseAcpAgent } from "../base-acp-agent";
69
+ import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
70
+ import { resolveTaskId } from "../session-meta";
64
71
  import { promptToClaude } from "./conversion/acp-to-sdk";
65
72
  import {
66
73
  handleResultMessage,
@@ -69,6 +76,7 @@ import {
69
76
  handleUserAssistantMessage,
70
77
  } from "./conversion/sdk-to-acp";
71
78
  import type { EnrichedReadCache } from "./hooks";
79
+ import { createLocalToolsMcpServer } from "./mcp/local-tools";
72
80
  import {
73
81
  fetchMcpToolMetadata,
74
82
  getConnectedMcpServerNames,
@@ -1091,7 +1099,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1091
1099
  const isResume = !!resume;
1092
1100
 
1093
1101
  const meta = params._meta as NewSessionMeta | undefined;
1094
- const taskId = meta?.persistence?.taskId;
1102
+ const taskId = resolveTaskId(meta);
1103
+ // Gate signed-commit wiring on cloud-run detection so the desktop (which
1104
+ // signs via CommitSaga) is untouched.
1105
+ const cloudRun = isCloudRun(meta);
1095
1106
  const effort = meta?.claudeCode?.options?.effort as EffortLevel | undefined;
1096
1107
 
1097
1108
  // We want to create a new session id unless it is resume,
@@ -1115,6 +1126,24 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1115
1126
  const mcpServers = supportsMcpInjection(earlyModelId)
1116
1127
  ? parseMcpServers(params)
1117
1128
  : {};
1129
+
1130
+ // Register the in-process general local-tools MCP server. Tools self-gate
1131
+ // via the registry (e.g. signed-commit is cloud-only and needs a GH token),
1132
+ // so adding a tool needs no change here. In cloud runs `git commit`/`git
1133
+ // push` are blocked by the PreToolUse guard (and the sandbox git shim), so
1134
+ // the agent commits via the signed-commit tool instead.
1135
+ const localToolsServer = createLocalToolsMcpServer(
1136
+ { cwd, token: resolveGithubToken(), taskId },
1137
+ meta,
1138
+ );
1139
+ if (localToolsServer) {
1140
+ mcpServers[LOCAL_TOOLS_MCP_NAME] = localToolsServer;
1141
+ } else if (cloudRun) {
1142
+ this.logger.warn(
1143
+ "Cloud run registered no local tools — missing GH_TOKEN/GITHUB_TOKEN? signed commits unavailable",
1144
+ );
1145
+ }
1146
+
1118
1147
  const systemPrompt = buildSystemPrompt(meta?.systemPrompt);
1119
1148
 
1120
1149
  if (meta?.mcpToolApprovals) {
@@ -1164,6 +1193,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1164
1193
  effort,
1165
1194
  enrichmentDeps: this.enrichment?.deps,
1166
1195
  enrichedReadCache: this.enrichedReadCache,
1196
+ cloudMode: cloudRun,
1167
1197
  });
1168
1198
 
1169
1199
  // Use the same abort controller that buildSessionOptions gave to the query
@@ -11,6 +11,7 @@ import { Logger } from "../../utils/logger";
11
11
  import {
12
12
  createPreToolUseHook,
13
13
  createReadEnrichmentHook,
14
+ createSignedCommitGuardHook,
14
15
  type EnrichedReadCache,
15
16
  } from "./hooks";
16
17
  import type {
@@ -311,3 +312,56 @@ describe("createPreToolUseHook", () => {
311
312
  });
312
313
  });
313
314
  });
315
+
316
+ describe("createSignedCommitGuardHook", () => {
317
+ const logger = new Logger();
318
+
319
+ function bashInput(command: string): HookInput {
320
+ return {
321
+ session_id: "s",
322
+ transcript_path: "/tmp/t",
323
+ cwd: "/tmp",
324
+ hook_event_name: "PreToolUse",
325
+ tool_name: "Bash",
326
+ tool_use_id: "toolu_1",
327
+ tool_input: { command },
328
+ } as HookInput;
329
+ }
330
+
331
+ const guard = createSignedCommitGuardHook(logger);
332
+ const opts = { signal: new AbortController().signal };
333
+
334
+ test.each([
335
+ "git commit -m x",
336
+ "git push origin main",
337
+ "git add . && git commit -m 'y'",
338
+ "git -C /repo commit",
339
+ "git --no-pager push",
340
+ ])("denies %s", async (command) => {
341
+ const result = await guard(bashInput(command), undefined, opts);
342
+ expect(result).toMatchObject({
343
+ hookSpecificOutput: { permissionDecision: "deny" },
344
+ });
345
+ });
346
+
347
+ test.each([
348
+ "git status",
349
+ "git add .",
350
+ "git fetch origin",
351
+ "git log --grep=commit",
352
+ "git stash push",
353
+ "git ls-remote --heads origin x",
354
+ ])("allows %s", async (command) => {
355
+ const result = await guard(bashInput(command), undefined, opts);
356
+ expect(result).toEqual({ continue: true });
357
+ });
358
+
359
+ test("ignores non-Bash tools", async () => {
360
+ const result = await guard(
361
+ { ...bashInput("git commit"), tool_name: "Read" } as HookInput,
362
+ undefined,
363
+ opts,
364
+ );
365
+ expect(result).toEqual({ continue: true });
366
+ });
367
+ });
@@ -4,6 +4,7 @@ import {
4
4
  type FileEnrichmentDeps,
5
5
  } from "../../enrichment/file-enricher";
6
6
  import type { Logger } from "../../utils/logger";
7
+ import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../signed-commit-shared";
7
8
  import { stripCatLineNumbers } from "./conversion/sdk-to-acp";
8
9
  import {
9
10
  extractPostHogSubTool,
@@ -222,6 +223,91 @@ export const createSubagentRewriteHook =
222
223
  };
223
224
  };
224
225
 
226
+ // git global options that consume the following token as their value, so the
227
+ // subcommand detector must skip both (mirrors the sandbox `git` PATH shim).
228
+ const GIT_VALUE_FLAGS = new Set([
229
+ "-C",
230
+ "-c",
231
+ "--git-dir",
232
+ "--work-tree",
233
+ "--namespace",
234
+ "--exec-path",
235
+ ]);
236
+
237
+ function gitSubcommand(segment: string): string | null {
238
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
239
+ if (tokens.length === 0) return null;
240
+ // Strip a leading path so `/usr/bin/git` is still recognised as git.
241
+ const head = tokens[0].split("/").pop();
242
+ if (head !== "git") return null;
243
+
244
+ let skipNext = false;
245
+ for (const tok of tokens.slice(1)) {
246
+ if (skipNext) {
247
+ skipNext = false;
248
+ continue;
249
+ }
250
+ if (GIT_VALUE_FLAGS.has(tok)) {
251
+ skipNext = true;
252
+ continue;
253
+ }
254
+ if (tok.startsWith("-")) continue;
255
+ return tok;
256
+ }
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * True when any top-level shell segment of `command` is a direct `git commit` /
262
+ * `git push` invocation (allowing `git`-level global flags like `-C path` or
263
+ * `--no-pager`). Does not match subcommands such as `git stash push` or
264
+ * `git log --grep=commit`. Git reached via command substitution (`$(git push)`)
265
+ * is not caught here — the sandbox `git` PATH shim is the authoritative backstop;
266
+ * this hook is a fast in-band deny with a helpful message.
267
+ */
268
+ function blocksUnsignedGit(command: string): boolean {
269
+ // Cheap reject for the overwhelmingly common non-git Bash call before splitting.
270
+ if (!command.includes("git")) return false;
271
+ return command.split(/&&|\|\||[;\n|]/).some((segment) => {
272
+ const sub = gitSubcommand(segment);
273
+ return sub === "commit" || sub === "push";
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Cloud-only guard: blocks raw `git commit` / `git push` so unsigned commits
279
+ * cannot leave the sandbox. The agent must use the `git_signed_commit` tool,
280
+ * which creates GitHub-signed (Verified) commits via the API.
281
+ */
282
+ export const createSignedCommitGuardHook =
283
+ (logger: Logger): HookCallback =>
284
+ async (input: HookInput, _toolUseID: string | undefined) => {
285
+ if (input.hook_event_name !== "PreToolUse") return { continue: true };
286
+ if (input.tool_name !== "Bash") return { continue: true };
287
+
288
+ const command = (input.tool_input as { command?: string } | undefined)
289
+ ?.command;
290
+ if (!command || !blocksUnsignedGit(command)) {
291
+ return { continue: true };
292
+ }
293
+
294
+ logger.info(
295
+ `[SignedCommitGuard] Blocking unsigned git command: ${command}`,
296
+ );
297
+ return {
298
+ continue: true,
299
+ hookSpecificOutput: {
300
+ hookEventName: "PreToolUse" as const,
301
+ permissionDecision: "deny" as const,
302
+ permissionDecisionReason:
303
+ "Commits must be signed: `git commit` and `git push` are disabled here. " +
304
+ "Stage changes with `git add`, then call the `git_signed_commit` tool " +
305
+ `(${SIGNED_COMMIT_QUALIFIED_TOOL_NAME}) with a \`message\` to create a signed ` +
306
+ "commit on the branch.",
307
+ },
308
+ };
309
+ };
310
+
225
311
  export const createPreToolUseHook =
226
312
  (settingsManager: SettingsManager, logger: Logger): HookCallback =>
227
313
  async (input: HookInput, _toolUseID: string | undefined) => {
@@ -0,0 +1,50 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
+ import { createLocalToolsMcpServer } from "./local-tools";
5
+
6
+ describe("createLocalToolsMcpServer", () => {
7
+ const savedSandbox = process.env.IS_SANDBOX;
8
+
9
+ beforeEach(() => {
10
+ // isCloudRun also keys off IS_SANDBOX; clear it so the meta arg is the only
11
+ // cloud signal under test.
12
+ delete process.env.IS_SANDBOX;
13
+ });
14
+
15
+ afterEach(() => {
16
+ if (savedSandbox === undefined) {
17
+ delete process.env.IS_SANDBOX;
18
+ } else {
19
+ process.env.IS_SANDBOX = savedSandbox;
20
+ }
21
+ });
22
+
23
+ it("returns undefined when no tool's gate passes (desktop run)", () => {
24
+ expect(
25
+ createLocalToolsMcpServer({ cwd: "/repo", token: "ghs_x" }, undefined),
26
+ ).toBeUndefined();
27
+ });
28
+
29
+ it("exposes git_signed_commit over MCP in a cloud run with a token", async () => {
30
+ const server = createLocalToolsMcpServer(
31
+ { cwd: "/repo", token: "ghs_x" },
32
+ { taskRunId: "run-1" },
33
+ );
34
+ if (!server) {
35
+ throw new Error("expected the local-tools server to be registered");
36
+ }
37
+ expect(server.name).toBe("posthog-local");
38
+
39
+ const [clientTransport, serverTransport] =
40
+ InMemoryTransport.createLinkedPair();
41
+ await server.instance.connect(serverTransport);
42
+ const client = new Client({ name: "test", version: "1.0.0" });
43
+ await client.connect(clientTransport);
44
+
45
+ const { tools } = await client.listTools();
46
+ expect(tools.map((t) => t.name)).toContain("git_signed_commit");
47
+
48
+ await client.close();
49
+ });
50
+ });
@@ -0,0 +1,40 @@
1
+ import {
2
+ createSdkMcpServer,
3
+ type McpSdkServerConfigWithInstance,
4
+ tool,
5
+ } from "@anthropic-ai/claude-agent-sdk";
6
+ import {
7
+ enabledLocalTools,
8
+ LOCAL_TOOLS_MCP_NAME,
9
+ type LocalToolCtx,
10
+ type LocalToolGateMeta,
11
+ } from "../../local-tools";
12
+
13
+ /**
14
+ * In-process SDK MCP server exposing the enabled local tools to the Claude
15
+ * adapter (see `../../local-tools` for the registry). Returns `undefined` when
16
+ * no tool's gate passes, so the caller can skip registering an empty server.
17
+ * Registered per session in `claude-agent.ts`.
18
+ */
19
+ export function createLocalToolsMcpServer(
20
+ ctx: LocalToolCtx,
21
+ meta: LocalToolGateMeta | undefined,
22
+ ): McpSdkServerConfigWithInstance | undefined {
23
+ const tools = enabledLocalTools(ctx, meta);
24
+ if (tools.length === 0) {
25
+ return undefined;
26
+ }
27
+ return createSdkMcpServer({
28
+ name: LOCAL_TOOLS_MCP_NAME,
29
+ version: "1.0.0",
30
+ tools: tools.map((t) =>
31
+ tool(
32
+ t.name,
33
+ t.description,
34
+ t.schema,
35
+ async (args) => t.handler(ctx, args),
36
+ { alwaysLoad: t.alwaysLoad ?? false },
37
+ ),
38
+ ),
39
+ });
40
+ }
@@ -17,6 +17,7 @@ import {
17
17
  createPostToolUseHook,
18
18
  createPreToolUseHook,
19
19
  createReadEnrichmentHook,
20
+ createSignedCommitGuardHook,
20
21
  createSubagentRewriteHook,
21
22
  type EnrichedReadCache,
22
23
  type OnModeChange,
@@ -55,6 +56,8 @@ export interface BuildOptionsParams {
55
56
  effort?: EffortLevel;
56
57
  enrichmentDeps?: FileEnrichmentDeps;
57
58
  enrichedReadCache?: EnrichedReadCache;
59
+ /** Cloud task session — enables the signed-commit guard. */
60
+ cloudMode?: boolean;
58
61
  }
59
62
 
60
63
  export function buildSystemPrompt(
@@ -129,6 +132,7 @@ function buildHooks(
129
132
  enrichmentDeps: FileEnrichmentDeps | undefined,
130
133
  enrichedReadCache: EnrichedReadCache | undefined,
131
134
  registeredAgents: ReadonlySet<string>,
135
+ cloudMode: boolean,
132
136
  ): Options["hooks"] {
133
137
  const postToolUseHooks = [createPostToolUseHook({ onModeChange })];
134
138
  if (enrichmentDeps && enrichedReadCache) {
@@ -137,21 +141,21 @@ function buildHooks(
137
141
  );
138
142
  }
139
143
 
144
+ const preToolUseHooks = [
145
+ createPreToolUseHook(settingsManager, logger),
146
+ createSubagentRewriteHook(logger, registeredAgents),
147
+ ];
148
+ if (cloudMode) {
149
+ preToolUseHooks.push(createSignedCommitGuardHook(logger));
150
+ }
151
+
140
152
  return {
141
153
  ...userHooks,
142
154
  PostToolUse: [
143
155
  ...(userHooks?.PostToolUse || []),
144
156
  { hooks: postToolUseHooks },
145
157
  ],
146
- PreToolUse: [
147
- ...(userHooks?.PreToolUse || []),
148
- {
149
- hooks: [
150
- createPreToolUseHook(settingsManager, logger),
151
- createSubagentRewriteHook(logger, registeredAgents),
152
- ],
153
- },
154
- ],
158
+ PreToolUse: [...(userHooks?.PreToolUse || []), { hooks: preToolUseHooks }],
155
159
  };
156
160
  }
157
161
 
@@ -352,6 +356,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
352
356
  params.enrichmentDeps,
353
357
  params.enrichedReadCache,
354
358
  registeredAgentNames,
359
+ params.cloudMode ?? false,
355
360
  ),
356
361
  outputFormat: params.outputFormat,
357
362
  abortController: getAbortController(
@@ -120,6 +120,7 @@ export type SDKMessageFilter = {
120
120
 
121
121
  export type NewSessionMeta = {
122
122
  taskRunId?: string;
123
+ taskId?: string;
123
124
  disableBuiltInTools?: boolean;
124
125
  systemPrompt?: unknown;
125
126
  sessionId?: string;