@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.
- package/dist/adapters/claude/permissions/permission-options.js +700 -0
- package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
- package/dist/adapters/claude/tools.js +700 -0
- package/dist/adapters/claude/tools.js.map +1 -1
- package/dist/adapters/codex/local-tools-mcp-server.d.ts +2 -0
- package/dist/adapters/codex/local-tools-mcp-server.js +1172 -0
- package/dist/adapters/codex/local-tools-mcp-server.js.map +1 -0
- package/dist/agent.js +1488 -219
- package/dist/agent.js.map +1 -1
- package/dist/execution-mode.js +700 -0
- package/dist/execution-mode.js.map +1 -1
- package/dist/handoff-checkpoint.js.map +1 -1
- package/dist/posthog-api.d.ts +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.js +1637 -342
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +1553 -261
- package/dist/server/bin.cjs.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/claude/claude-agent.ts +32 -2
- package/src/adapters/claude/hooks.test.ts +54 -0
- package/src/adapters/claude/hooks.ts +86 -0
- package/src/adapters/claude/mcp/local-tools.test.ts +50 -0
- package/src/adapters/claude/mcp/local-tools.ts +40 -0
- package/src/adapters/claude/session/options.ts +14 -9
- package/src/adapters/claude/types.ts +1 -0
- package/src/adapters/codex/codex-agent.ts +117 -22
- package/src/adapters/codex/local-tools-mcp-server.ts +71 -0
- package/src/adapters/local-tools/index.ts +22 -0
- package/src/adapters/local-tools/registry.test.ts +57 -0
- package/src/adapters/local-tools/registry.ts +81 -0
- package/src/adapters/local-tools/tools/signed-commit.ts +26 -0
- package/src/adapters/session-meta.ts +16 -0
- package/src/adapters/signed-commit-shared.ts +82 -0
- package/src/server/agent-server.configure-environment.test.ts +64 -1
- package/src/server/agent-server.test.ts +2 -4
- package/src/server/agent-server.ts +60 -35
- package/src/types.ts +2 -1
- package/src/utils/common.ts +14 -0
- package/src/utils/gateway.test.ts +70 -0
- 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":";
|
|
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
|
@@ -57,10 +57,17 @@ import {
|
|
|
57
57
|
type FileEnrichmentDeps,
|
|
58
58
|
} from "../../enrichment/file-enricher";
|
|
59
59
|
import type { PostHogAPIConfig } from "../../types";
|
|
60
|
-
import {
|
|
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
|
|
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(
|