offrouter-adapter-claude 0.0.0

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/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "offrouter-adapter-claude",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json",
9
+ "test": "vitest run --root ../.. packages/adapter-claude/src"
10
+ },
11
+ "dependencies": {
12
+ "offrouter-core": "0.0.0",
13
+ "zod": "0.0.0"
14
+ }
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "offrouter-adapter-claude",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json",
9
+ "test": "vitest run --root ../.. packages/adapter-claude/src"
10
+ },
11
+ "dependencies": {
12
+ "offrouter-core": "*",
13
+ "zod": "^3.23.0"
14
+ },
15
+ "private": true
16
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Claude Code PreToolUse hook placeholder for OffRouter V1.
3
+ *
4
+ * No enforcement behavior is claimed here. Future milestones may add
5
+ * audit/policy guards; V1 only reserves the export surface.
6
+ */
7
+
8
+ export interface PreToolUseHookOutput {
9
+ /** Placeholder: PreToolUse decisions are not produced in V1. */
10
+ continue?: boolean;
11
+ }
12
+
13
+ /**
14
+ * No-op placeholder for the future PreToolUse hook surface.
15
+ */
16
+ export function handlePreToolUse(input: unknown): PreToolUseHookOutput {
17
+ void input;
18
+ return {};
19
+ }
@@ -0,0 +1,222 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { PolicyConfig, ProviderCandidate } from "offrouter-core";
5
+ import {
6
+ handleUserPromptSubmit,
7
+ runUserPromptSubmitHook,
8
+ } from "./user-prompt-submit.js";
9
+
10
+ const fixturePath = fileURLToPath(
11
+ new URL(
12
+ "../../../../tests/fixtures/claude/user-prompt-submit.json",
13
+ import.meta.url,
14
+ ),
15
+ );
16
+
17
+ const policy: PolicyConfig = {
18
+ allowlistedProfiles: ["claude-personal"],
19
+ deniedProfilePatterns: ["*-work"],
20
+ };
21
+
22
+ const candidates: ProviderCandidate[] = [
23
+ {
24
+ providerId: "fake",
25
+ modelId: "fake-coder",
26
+ displayName: "Fake Coder",
27
+ authTier: "api-key",
28
+ authScope: "third-party",
29
+ health: "healthy",
30
+ supportsTools: true,
31
+ supportsStreaming: true,
32
+ supportsJson: true,
33
+ supportsImages: false,
34
+ estimatedCostUsd: 0,
35
+ },
36
+ ];
37
+
38
+ async function fixture(): Promise<string> {
39
+ return readFile(fixturePath, "utf8");
40
+ }
41
+
42
+ function parseStdout(stdout: string): Record<string, unknown> {
43
+ expect(stdout.trim()).toBe(stdout);
44
+ expect(stdout).not.toContain("\n\n");
45
+ return JSON.parse(stdout) as Record<string, unknown>;
46
+ }
47
+
48
+ describe("Claude UserPromptSubmit hook", () => {
49
+ it("injects additionalContext for an allowed claude-personal profile", async () => {
50
+ const input = JSON.parse(await fixture());
51
+
52
+ const output = handleUserPromptSubmit(input, {
53
+ profile: "claude-personal",
54
+ policy,
55
+ candidates,
56
+ trustedWorkspace: true,
57
+ routeConstraints: { allowApiKeyFallback: true },
58
+ });
59
+
60
+ expect(output.decision).toBeUndefined();
61
+ expect(output.hookSpecificOutput).toMatchObject({
62
+ hookEventName: "UserPromptSubmit",
63
+ });
64
+ expect(output.hookSpecificOutput?.additionalContext).toContain(
65
+ "Claude Code's primary model is unchanged in V1; OffRouter routes delegated work.",
66
+ );
67
+ expect(output.hookSpecificOutput?.additionalContext).toContain(
68
+ "fake/fake-coder",
69
+ );
70
+ expect(output.hookSpecificOutput?.additionalContext).not.toContain(
71
+ input.prompt,
72
+ );
73
+ });
74
+
75
+ it("blocks a denied claude-work profile with a clear reason", async () => {
76
+ const input = JSON.parse(await fixture());
77
+
78
+ const output = handleUserPromptSubmit(input, {
79
+ profile: "claude-work",
80
+ policy,
81
+ candidates,
82
+ trustedWorkspace: true,
83
+ routeConstraints: { allowApiKeyFallback: true },
84
+ });
85
+
86
+ expect(output).toMatchObject({
87
+ decision: "block",
88
+ });
89
+ expect(output.reason).toContain("claude-work");
90
+ expect(output.reason).toContain("denied");
91
+ });
92
+
93
+ it("blocks an unallowlisted profile with a clear reason", async () => {
94
+ const input = JSON.parse(await fixture());
95
+
96
+ const output = handleUserPromptSubmit(input, {
97
+ profile: "claude-personal-extra",
98
+ policy,
99
+ candidates,
100
+ trustedWorkspace: true,
101
+ routeConstraints: { allowApiKeyFallback: true },
102
+ });
103
+
104
+ expect(output).toMatchObject({
105
+ decision: "block",
106
+ });
107
+ expect(output.reason).toContain("claude-personal-extra");
108
+ expect(output.reason).toContain("not allowlisted");
109
+ });
110
+
111
+ it("does not route from an untrusted workspace by default", async () => {
112
+ const input = JSON.parse(await fixture());
113
+
114
+ const output = handleUserPromptSubmit(input, {
115
+ profile: "claude-personal",
116
+ policy,
117
+ candidates,
118
+ routeConstraints: { allowApiKeyFallback: true },
119
+ });
120
+
121
+ expect(output.decision).toBeUndefined();
122
+ expect(output.hookSpecificOutput?.additionalContext).toContain(
123
+ "could not select",
124
+ );
125
+ expect(output.hookSpecificOutput?.additionalContext).toContain("untrusted");
126
+ expect(output.hookSpecificOutput?.additionalContext).not.toContain(
127
+ "fake/fake-coder",
128
+ );
129
+ });
130
+
131
+ it("does not recommend API-key fallback unless explicitly allowed", async () => {
132
+ const input = JSON.parse(await fixture());
133
+
134
+ const output = handleUserPromptSubmit(input, {
135
+ profile: "claude-personal",
136
+ policy,
137
+ candidates,
138
+ trustedWorkspace: true,
139
+ });
140
+
141
+ expect(output.decision).toBeUndefined();
142
+ expect(output.hookSpecificOutput?.additionalContext).toContain(
143
+ "could not select",
144
+ );
145
+ expect(output.hookSpecificOutput?.additionalContext).toContain(
146
+ "allowApiKeyFallback is false",
147
+ );
148
+ expect(output.hookSpecificOutput?.additionalContext).not.toContain(
149
+ "fake/fake-coder",
150
+ );
151
+ });
152
+
153
+ it("continues Claude primary flow when routing needs configuration", async () => {
154
+ const input = JSON.parse(await fixture());
155
+
156
+ const output = handleUserPromptSubmit(input, {
157
+ profile: "claude-personal",
158
+ policy,
159
+ candidates: [],
160
+ trustedWorkspace: true,
161
+ });
162
+
163
+ expect(output.decision).toBeUndefined();
164
+ expect(output.hookSpecificOutput?.additionalContext).toContain(
165
+ "needs configuration",
166
+ );
167
+ });
168
+
169
+ it("fails closed for schema-invalid hook JSON", async () => {
170
+ const output = handleUserPromptSubmit(
171
+ { hook_event_name: "PreToolUse", prompt: "" },
172
+ {
173
+ profile: "claude-personal",
174
+ policy,
175
+ candidates,
176
+ trustedWorkspace: true,
177
+ routeConstraints: { allowApiKeyFallback: true },
178
+ },
179
+ );
180
+
181
+ expect(output).toMatchObject({
182
+ decision: "block",
183
+ });
184
+ expect(output.reason).toContain("Invalid Claude hook JSON");
185
+ });
186
+
187
+ it("fails closed for invalid hook JSON", async () => {
188
+ const result = await runUserPromptSubmitHook("{not-json", {
189
+ profile: "claude-personal",
190
+ policy,
191
+ candidates,
192
+ trustedWorkspace: true,
193
+ routeConstraints: { allowApiKeyFallback: true },
194
+ });
195
+
196
+ expect(result.exitCode).toBe(0);
197
+ const output = parseStdout(result.stdout);
198
+ expect(output).toMatchObject({
199
+ decision: "block",
200
+ });
201
+ expect(output.reason).toContain("Invalid Claude hook JSON");
202
+ expect(result.stderr).toBe("");
203
+ });
204
+
205
+ it("writes valid JSON to stdout with no extra text", async () => {
206
+ const result = await runUserPromptSubmitHook(await fixture(), {
207
+ profile: "claude-personal",
208
+ policy,
209
+ candidates,
210
+ trustedWorkspace: true,
211
+ routeConstraints: { allowApiKeyFallback: true },
212
+ });
213
+
214
+ expect(result.exitCode).toBe(0);
215
+ const output = parseStdout(result.stdout);
216
+ expect(output).toHaveProperty("hookSpecificOutput");
217
+ expect(result.stdout).not.toContain(
218
+ "Explain this repository and suggest the next test to write.",
219
+ );
220
+ expect(result.stderr).toBe("");
221
+ });
222
+ });
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Claude Code UserPromptSubmit hook adapter.
3
+ *
4
+ * V1 boundary: do not claim to intercept or replace Claude Code's primary
5
+ * model. This handler injects audit-safe routing guidance for OffRouter-
6
+ * managed delegation (or blocks profiles OffRouter refuses to serve).
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ import {
10
+ ROUTE_PROTOCOL_VERSION,
11
+ routeRequest,
12
+ type PolicyConfig,
13
+ type ProviderCandidate,
14
+ type RouteDecision,
15
+ type RouteConstraints,
16
+ type RouteRequest,
17
+ type RouteTask,
18
+ } from "offrouter-core";
19
+ import { z } from "zod";
20
+
21
+ const HONESTY_SENTENCE =
22
+ "Claude Code's primary model is unchanged in V1; OffRouter routes delegated work.";
23
+
24
+ type InferredRouteTask = Pick<
25
+ RouteTask,
26
+ "kind" | "risk" | "requiresTools" | "requiresImages" | "requiresJson"
27
+ >;
28
+
29
+ const ClaudeUserPromptSubmitSchema = z
30
+ .object({
31
+ hook_event_name: z.literal("UserPromptSubmit"),
32
+ prompt: z.string().min(1),
33
+ cwd: z.string().optional(),
34
+ session_id: z.string().optional(),
35
+ transcript_path: z.string().optional(),
36
+ permission_mode: z.string().optional(),
37
+ })
38
+ .passthrough();
39
+
40
+ export type ClaudeUserPromptSubmitPayload = z.infer<
41
+ typeof ClaudeUserPromptSubmitSchema
42
+ >;
43
+
44
+ export interface UserPromptSubmitOptions {
45
+ profile: string;
46
+ policy: PolicyConfig;
47
+ candidates: ProviderCandidate[];
48
+ trustedWorkspace?: boolean;
49
+ routeConstraints?: Partial<RouteConstraints>;
50
+ }
51
+
52
+ export interface UserPromptSubmitHookOutput {
53
+ decision?: "block";
54
+ reason?: string;
55
+ hookSpecificOutput?: {
56
+ hookEventName: "UserPromptSubmit";
57
+ additionalContext: string;
58
+ };
59
+ }
60
+
61
+ export interface HookRunResult {
62
+ exitCode: number;
63
+ stdout: string;
64
+ stderr: string;
65
+ }
66
+
67
+ function promptDigest(prompt: string): string {
68
+ return `sha256:${createHash("sha256").update(prompt).digest("hex")}`;
69
+ }
70
+
71
+ function auditSafePromptPreview(digest: string, prompt: string): string {
72
+ return `redacted:${digest.replace("sha256:", "").slice(0, 12)} chars=${prompt.length}`;
73
+ }
74
+
75
+ function inferTask(prompt: string): InferredRouteTask {
76
+ const normalized = prompt.toLowerCase();
77
+ let kind: RouteTask["kind"] = "other";
78
+ if (/\b(review|pr|diff|audit)\b/.test(normalized)) {
79
+ kind = "review";
80
+ } else if (/\b(plan|spec|roadmap|design)\b/.test(normalized)) {
81
+ kind = "plan";
82
+ } else if (/\b(implement|code|fix|test|refactor|debug)\b/.test(normalized)) {
83
+ kind = "code";
84
+ } else if (/\b(research|latest|look up|web)\b/.test(normalized)) {
85
+ kind = "research";
86
+ } else if (/\b(image|screenshot|diagram|draw)\b/.test(normalized)) {
87
+ kind = "image";
88
+ } else if (/\b(explain|summari[sz]e)\b/.test(normalized)) {
89
+ kind = "explain";
90
+ }
91
+
92
+ let risk: RouteTask["risk"] = "low";
93
+ if (
94
+ /\b(production|deploy|delete|migrate|credential|secret|payment|security|auth|database|data loss|rm -rf)\b/.test(
95
+ normalized,
96
+ )
97
+ ) {
98
+ risk = "high";
99
+ } else if (
100
+ /\b(refactor|install|write|commit|merge|api key|profile|hook)\b/.test(
101
+ normalized,
102
+ )
103
+ ) {
104
+ risk = "medium";
105
+ }
106
+
107
+ return {
108
+ kind,
109
+ risk,
110
+ requiresTools:
111
+ kind === "code" ||
112
+ kind === "review" ||
113
+ /\b(run|test|build|edit|write|git|shell|command)\b/.test(normalized),
114
+ requiresImages: kind === "image",
115
+ requiresJson: /\b(json|schema|structured)\b/.test(normalized),
116
+ };
117
+ }
118
+
119
+ function buildRouteRequest(
120
+ payload: ClaudeUserPromptSubmitPayload,
121
+ options: UserPromptSubmitOptions,
122
+ ): RouteRequest {
123
+ const prompt = payload.prompt;
124
+ const digest = promptDigest(prompt);
125
+ const inferredTask = inferTask(prompt);
126
+ return {
127
+ protocolVersion: ROUTE_PROTOCOL_VERSION,
128
+ requestId: `claude_hook_${digest.replace("sha256:", "").slice(0, 12)}`,
129
+ harness: {
130
+ kind: "claude",
131
+ profile: options.profile,
132
+ },
133
+ task: {
134
+ ...inferredTask,
135
+ promptPreview: auditSafePromptPreview(digest, prompt),
136
+ promptDigest: digest,
137
+ },
138
+ workspace: {
139
+ cwd: payload.cwd ?? process.cwd(),
140
+ trusted: options.trustedWorkspace === true,
141
+ },
142
+ constraints: {
143
+ subscriptionFirst: options.routeConstraints?.subscriptionFirst ?? true,
144
+ allowApiKeyFallback:
145
+ options.routeConstraints?.allowApiKeyFallback ?? false,
146
+ maxCostUsd: options.routeConstraints?.maxCostUsd,
147
+ localOnly: options.routeConstraints?.localOnly,
148
+ },
149
+ };
150
+ }
151
+
152
+ function formatAllowedContext(decision: RouteDecision): string {
153
+ const lines = [HONESTY_SENTENCE];
154
+
155
+ if (decision.route) {
156
+ lines.push(
157
+ `OffRouter recommended delegated route ${decision.route.provider}/${decision.route.model} (${decision.route.authTier}).`,
158
+ );
159
+ lines.push(`Reason: ${decision.reason}. ${decision.explanation}`.trim());
160
+ } else if (decision.needsConfiguration) {
161
+ lines.push(
162
+ "OffRouter needs configuration before it can select a delegated route.",
163
+ );
164
+ lines.push(decision.explanation);
165
+ } else if (decision.blocked) {
166
+ // Profile denials are surfaced as decision:block by the caller; other
167
+ // blocked outcomes still provide context rather than claiming primary
168
+ // model substitution.
169
+ lines.push(
170
+ `OffRouter could not select a delegated route (${decision.reason}).`,
171
+ );
172
+ lines.push(decision.explanation);
173
+ } else {
174
+ lines.push("OffRouter could not select a delegated route.");
175
+ }
176
+
177
+ // Never include raw prompt text; decision fields are already audit-safe.
178
+ return lines.filter(Boolean).join("\n");
179
+ }
180
+
181
+ function invalidHookOutput(detail?: string): UserPromptSubmitHookOutput {
182
+ const reason = detail
183
+ ? `Invalid Claude hook JSON: ${detail}`
184
+ : "Invalid Claude hook JSON";
185
+ return {
186
+ decision: "block",
187
+ reason,
188
+ };
189
+ }
190
+
191
+ function isProfileDenial(decision: RouteDecision): boolean {
192
+ return (
193
+ decision.blocked === true &&
194
+ (decision.policyDenials ?? []).some(
195
+ (d) =>
196
+ d.code === "work_profile_denied" ||
197
+ d.code === "profile_not_allowlisted",
198
+ )
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Pure UserPromptSubmit handler used by tests and the stdio runner.
204
+ */
205
+ export function handleUserPromptSubmit(
206
+ input: unknown,
207
+ options: UserPromptSubmitOptions,
208
+ ): UserPromptSubmitHookOutput {
209
+ const parsed = ClaudeUserPromptSubmitSchema.safeParse(input);
210
+ if (!parsed.success) {
211
+ return invalidHookOutput("payload did not match UserPromptSubmit schema");
212
+ }
213
+
214
+ const request = buildRouteRequest(parsed.data, options);
215
+ const decision = routeRequest(request, options.candidates, options.policy);
216
+
217
+ // Denied / disallowed profiles block closed at the hook boundary.
218
+ if (isProfileDenial(decision)) {
219
+ const denial = (decision.policyDenials ?? []).find(
220
+ (d) =>
221
+ d.code === "work_profile_denied" ||
222
+ d.code === "profile_not_allowlisted",
223
+ );
224
+ return {
225
+ decision: "block",
226
+ reason:
227
+ denial?.message ??
228
+ `Profile ${options.profile} is denied by OffRouter policy.`,
229
+ };
230
+ }
231
+
232
+ return {
233
+ hookSpecificOutput: {
234
+ hookEventName: "UserPromptSubmit",
235
+ additionalContext: formatAllowedContext(decision),
236
+ },
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Stdio-style runner: parse stdin JSON, handle, emit a single JSON object on
242
+ * stdout. Always exit 0 so Claude receives structured block/allow decisions.
243
+ */
244
+ export async function runUserPromptSubmitHook(
245
+ stdin: string,
246
+ options: UserPromptSubmitOptions,
247
+ ): Promise<HookRunResult> {
248
+ let output: UserPromptSubmitHookOutput;
249
+
250
+ try {
251
+ const raw: unknown = JSON.parse(stdin);
252
+ output = handleUserPromptSubmit(raw, options);
253
+ } catch {
254
+ output = invalidHookOutput();
255
+ }
256
+
257
+ return {
258
+ exitCode: 0,
259
+ stdout: JSON.stringify(output),
260
+ stderr: "",
261
+ };
262
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * offrouter-adapter-claude public surface.
3
+ * Hook handlers for Claude Code personal profiles.
4
+ */
5
+
6
+ export const ADAPTER_CLAUDE_PACKAGE = "offrouter-adapter-claude" as const;
7
+
8
+ export {
9
+ handleUserPromptSubmit,
10
+ runUserPromptSubmitHook,
11
+ } from "./hooks/user-prompt-submit.js";
12
+ export type {
13
+ ClaudeUserPromptSubmitPayload,
14
+ HookRunResult,
15
+ UserPromptSubmitHookOutput,
16
+ UserPromptSubmitOptions,
17
+ } from "./hooks/user-prompt-submit.js";
18
+
19
+ export { handlePreToolUse } from "./hooks/pre-tool-use.js";
20
+ export type { PreToolUseHookOutput } from "./hooks/pre-tool-use.js";
21
+
22
+ export {
23
+ CLAUDE_INSTALL_STATE_REL,
24
+ ClaudeInstallStateSchema,
25
+ defaultPluginSourceDir,
26
+ evaluateInstallProfilePolicy,
27
+ formatInstallDiffText,
28
+ installClaudeProfile,
29
+ INSTALLED_PLUGIN_REL,
30
+ isClaudeHarnessProfile,
31
+ isSafeProfileId,
32
+ isWorkLikeProfile,
33
+ listAllowlistedPersonalClaudeProfiles,
34
+ resolveClaudeProfileDir,
35
+ } from "./install.js";
36
+ export type {
37
+ ClaudeInstallOptions,
38
+ ClaudeInstallResult,
39
+ ClaudeInstallState,
40
+ InstallChangeKind,
41
+ InstallFileChange,
42
+ ResolveClaudeProfileDirOptions,
43
+ } from "./install.js";
44
+
45
+ export { rollbackClaudeProfile } from "./rollback.js";
46
+ export type {
47
+ ClaudeRollbackOptions,
48
+ ClaudeRollbackResult,
49
+ } from "./rollback.js";