perf(runtime): send Claude Code lifecycle hooks over loopback HTTP, keep decisions on the helper - #90
Merged
Conversation
Every Claude lifecycle hook ran the helper through Electron-as-Node, about 105 ms per call, and PostToolUse fires after every tool. Claude Code 2.1.281 can POST hooks itself (type "http"), so the gateway now also listens on 127.0.0.1 (random port) and Claude's UserPromptSubmit, PermissionRequest, PostToolUse, Stop, StopFailure, SessionEnd and Notification hooks go there. Measured with the real CLI, each hook costs about 1.4 ms instead of 105 ms. - Claude does not send SessionStart over HTTP, so that hook keeps the helper. - PreToolUse (base protection and plugin decisions) also stays on permission-gate.mjs and the 0600 socket. An HTTP hook that fails in any way lets the tool run: refused, 5xx, 401, timeout or a malformed answer. So do the sandbox proxy (auto profile), HTTP_PROXY, allowedHttpHookUrls and httpHookAllowedEnvVars. A loopback port is also reachable by other local users. - Claude fills the session id and capability headers from the session's environment (allowedEnvVars), so the token never enters the --settings argv. - The listener takes only POST requests with application/json, a loopback Host and no Origin, Referer or Sec-Fetch-*. Bodies are bounded to 512 KB like the helper's, and the request is checked against the same lease and token. Revoking a session revokes its capability. - ClaudeHttpHookPolicy keeps the helper on Windows, in plugin environments, for the auto profile, for Claude versions older than 2.1.281 or not yet known, when a proxy is set, and when inline, managed, user or project settings enable the sandbox, restrict hook URLs or headers, or set a proxy. A request whose capability header arrives empty switches HTTP off for later launches. - Plugins may no longer set allowedHttpHookUrls or httpHookAllowedEnvVars in their Claude settings. - Lifecycle acks (socket and HTTP) go out before the app reacts. onSignal runs on setImmediate in arrival order, and a throw there no longer reaches the hook.
This was referenced Sep 28, 2026
BIackFIame
added a commit
to BIackFIame/CanvasTTY
that referenced
this pull request
Sep 28, 2026
…e base for this change
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Goal
Each Claude Code lifecycle hook currently starts the hook helper through Electron-as-Node, which costs about 105 ms of wall time and about 105 ms of CPU per call.
PostToolUsefires after every tool the agent runs, so a turn with 40 tool calls spends more than 4 s just starting helpers.Claude Code 2.1.281 can POST a hook to a URL by itself (
type: "http"). This PR sends Claude's lifecycle hooks to a loopback listener in the gateway instead of starting a process. The decision hook (PreToolUse) deliberately stays on the helper and the 0600 socket; the reasons are in the failure semantics below.Branch
perf/claude-http-hooks, one commit onorigin/main(31f287d). It merges cleanly with #89 (perf/runtime-costs, 44a5fa7). On the merged tree typecheck passes and all 1051 tests pass.Numbers (real Claude Code 2.1.281, end to end)
Each run is
claude -pagainst a local mock of the Messages API that asks for 40Bashcalls (true), under a throwaway HOME. The helper is the production one: the Electron binary withELECTRON_RUN_AS_NODE=1. A cycle is the time between two consecutive model requests, so it contains the tool plus every hook around it. 3 runs × 37 cycles per row. M1 Max; the machine had a load average of 5–30 from other work.Bash|Write|Edit|MultiEdit|NotebookEdit). That cost is the helper process; see the native helper issue.bench-real.mjs(outside the repository). It usestests/fixtures/mock-anthropic-api.mjsfrom this PR.What changes
RuntimeGateway.tshttpHooks: true, POSIX only).POST /claude/v1/<state>/<event>is authenticated against the same lease and token as the socket. From the Claude hook input it builds exactly the messagehook-helper.mjswould send: the same fields, the 4096-character result bound and the surrogate cut. The answer is always{}.RuntimeGateway.ts(socket and HTTP)lease.latest/activeTurnIdare updated synchronously, then the ack is sent, thenonSignal(applyProviderSignal,agentControl.onSignal, Even G2) runs onsetImmediatein arrival order. Before, the hook waited for all of that work. An exception inonSignalis logged and never reaches the hook.ProviderRuntimeLaunch.tstype: "http"hooks. SessionStart stays a command because Claude skips HTTP hooks for it. PreToolUse stayspermission-gate.mjs. Without a base URL the generated arguments are byte-for-byte what main produces (tested).ClaudeHttpHooks.ts(new)ClaudeHttpHookPolicydecides per launch whether HTTP may be used; see "When HTTP is used".ClaudeVersionsreads the version from the native installer's layout (…/claude/versions/2.1.281). Otherwise it runsclaude --versiononce in the background, and launches before that answer arrives use the helper.AgentRuntimeBridge.ts,TerminalManager.ts,index.tsplanSpawnpasses what the policy needs: the executable, the profile, whether an environment wraps the launch, the env, the contributed args and the cwd. The bridge asks the policy. Launch results reporthttpHooks.terminalLaunch.tsallowedHttpHookUrlsorhttpHookAllowedEnvVars. Either key would silently switch the HTTP hooks off (#88 validation).Failure semantics (measured, Claude Code 2.1.281)
PreToolUseHTTP hook answering deny,timeout: 3, tool =echo ran > marker:permissionDecision: denyask(in-p){"decision":"block"}{})timeout, or no answerHTTP_PROXYwithoutNO_PROXYfor 127.0.0.1 → sent through the proxy (CONNECT)allowedHttpHookUrlsin inline, user or project settingshttpHookAllowedEnvVarsset → headers arrive empty → 401For comparison, the command hook: exit 1, a crash, malformed stdout, a timeout and a missing script all let the tool run, and only exit 2 blocks.
permission-gate.mjsalways exits 0, so today an unreachable or broken gateway is already fail open. For failures inside CanvasTTY, HTTP is no worse. The rows from the sandbox down, however, are failures that exist only for HTTP and are driven by settings. The auto profile is one of them, and a project's.claude/settings.local.jsonis inside the folder the agent writes to. A loopback port can also be tied up by any local user, while the 0700/0600 socket cannot be reached by them at all. Deny-critical decisions therefore stay on the helper and the socket. Only hooks whose failure costs a card's status move to HTTP.When HTTP is used
HTTP is used only if every condition below holds; otherwise the launch uses the helper exactly as on main:
auto;HTTP(S)_PROXY/ALL_PROXYin the launch environment;sandbox.enabled, setsallowedHttpHookUrlsorhttpHookAllowedEnvVars, or sets a proxy inenv: the inline--settings, the managed settings (plusmanaged-settings.d/),$CLAUDE_CONFIG_DIR/settings.json, or the project's.claude/settings{,.local}.jsonfrom the cwd up to the repository root.If a request arrives with a known session but an empty capability (a policy emptied the header), later launches go back to the helper for the rest of the run.
Security design of the listener
127.0.0.1only, on port 0 (the OS picks the port). No IPv6, no wildcard address.x-canvastty-sessionandx-canvastty-capability. Claude fills them from the session's environment throughallowedEnvVars, so the 256-bit token is never in the--settingsargv (which every local user can see inps) and never in a file. Interpolation was checked with and withoutCLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1. The token is compared as sha256 digests withtimingSafeEqual, and the lease must belong toclaude.POSTis accepted (other methods and preflights get 405);Content-Typemust beapplication/json, which a cross-site request cannot send without a preflight (415 otherwise);Origin,Referer,Sec-Fetch-SiteorSec-Fetch-Modegets 403;Hostmust be exactly127.0.0.1:<port>(403 otherwise, including forlocalhost:<port>);headersTimeout5 s,requestTimeout10 s, keep-alive 5 s;/claude/v1/<known state>/<event of up to 80 characters>.revokeTerminalSessionzeroes and drops the lease, and the next request with that token gets 401 (tested).close()closes the listener and every connection.What stays on the helper
SessionStartPreToolUse(base protection, plugin decisions)plugin-hook-runner.mjs)Tests
tests/claude-http-hooks.test.mjs(12 tests):hook-helper.mjsproduce identical signals, with and without result capture. Inputs include a UUID in upper case, a session id that is not a UUID, a turn id of 161 characters, an emoji cut at 4096 and a body that is not JSON;localhostHost, form content type, missing content type, unknown session, wrong token, another provider's lease, routes;onSignalbusy-waits 400 ms;onSignal;tests/claude-http-hooks-real.test.mjs: skipped whenclaudeis not installed or is older than 2.1.281. It runs the real CLI with the arguments the bridge generates, against the mock API, under a throwaway HOME. Checks:out/main, hidden and unfocusable windows, keychain calls refused, throwaway HOME and userData), with a real Claude card in the default profile:idle → working → needs_approval → working → idlefrom the HTTP hooks;echoran;sudo -n truethrough the helper, and the model received the elevation message;Origin;npm run typecheck✓,npm test1026/1026 ✓,npm run build✓,npm run audit:secrets✓ (source andout/),npm run test:even47/47 ✓.Not covered