@canonmsg/claude-code-plugin 0.21.1 → 0.22.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.
@@ -1,200 +0,0 @@
1
- import { createServer } from 'node:http';
2
- import { randomBytes, timingSafeEqual } from 'node:crypto';
3
- import { buildClaudeApprovalDetails, classifyClaudeApprovalCategory, classifyClaudeApprovalRisk, } from './tool-policy.js';
4
- /**
5
- * HTTP server that bridges Claude Code's PermissionRequest hook
6
- * to Canon's ApprovalManager.
7
- *
8
- * Listens on localhost for POST /approve from Claude Code hooks.
9
- */
10
- export class ApprovalHttpServer {
11
- server = null;
12
- manager;
13
- port;
14
- getConversationId;
15
- getIsOwnerTurn;
16
- token;
17
- maxBodyBytes;
18
- requestTimeoutMs;
19
- constructor(manager, getConversationId, opts) {
20
- this.manager = manager;
21
- this.getConversationId = getConversationId;
22
- this.getIsOwnerTurn = opts?.getIsOwnerTurn ?? (() => true);
23
- this.port = opts?.port ?? 19420;
24
- this.token = opts?.token ?? randomBytes(32).toString('base64url');
25
- this.maxBodyBytes = opts?.maxBodyBytes ?? 64 * 1024;
26
- this.requestTimeoutMs = opts?.requestTimeoutMs ?? 10_000;
27
- }
28
- static MAX_PORT_RETRIES = 10;
29
- get authToken() {
30
- return this.token;
31
- }
32
- async start() {
33
- const startPort = this.port;
34
- return new Promise((resolve, reject) => {
35
- this.server = createServer((req, res) => this.handleRequest(req, res));
36
- this.server.on('error', (err) => {
37
- if (err.code === 'EADDRINUSE') {
38
- this.port++;
39
- if (this.port - startPort >= ApprovalHttpServer.MAX_PORT_RETRIES) {
40
- reject(new Error(`No available port found in range ${startPort}–${this.port}`));
41
- return;
42
- }
43
- this.server.listen(this.port, '127.0.0.1');
44
- }
45
- else {
46
- reject(err);
47
- }
48
- });
49
- this.server.listen(this.port, '127.0.0.1', () => {
50
- const address = this.server?.address();
51
- if (address && typeof address === 'object') {
52
- this.port = address.port;
53
- }
54
- console.error(`[canon] Approval server listening on 127.0.0.1:${this.port}`);
55
- resolve(this.port);
56
- });
57
- });
58
- }
59
- async stop() {
60
- return new Promise((resolve) => {
61
- if (this.server) {
62
- this.server.close(() => resolve());
63
- }
64
- else {
65
- resolve();
66
- }
67
- });
68
- }
69
- handleRequest(req, res) {
70
- if (req.method !== 'POST' || req.url !== '/approve') {
71
- res.writeHead(404, { 'Content-Type': 'application/json' });
72
- res.end(JSON.stringify({ error: 'Not found' }));
73
- return;
74
- }
75
- req.setTimeout(this.requestTimeoutMs, () => {
76
- res.writeHead(408, { 'Content-Type': 'application/json' });
77
- res.end(JSON.stringify({ error: 'Request timeout' }));
78
- req.destroy();
79
- });
80
- if (!this.hasValidToken(req)) {
81
- res.writeHead(401, { 'Content-Type': 'application/json' });
82
- res.end(JSON.stringify({ error: 'Unauthorized' }));
83
- return;
84
- }
85
- let body = '';
86
- req.on('data', (chunk) => {
87
- if (res.writableEnded)
88
- return;
89
- if (body.length + chunk.length > this.maxBodyBytes) {
90
- res.writeHead(413, { 'Content-Type': 'application/json' });
91
- res.end(JSON.stringify({ error: 'Request body too large' }));
92
- req.destroy();
93
- return;
94
- }
95
- body += chunk.toString();
96
- });
97
- req.on('end', () => {
98
- if (res.writableEnded)
99
- return;
100
- this.handleApproval(body, res).catch((err) => {
101
- console.error('[canon] Approval handler error:', err);
102
- if (res.writableEnded)
103
- return;
104
- res.writeHead(500, { 'Content-Type': 'application/json' });
105
- res.end(JSON.stringify({ error: 'Internal error' }));
106
- });
107
- });
108
- }
109
- hasValidToken(req) {
110
- const authHeader = req.headers.authorization;
111
- const bearer = typeof authHeader === 'string' && authHeader.toLowerCase().startsWith('bearer ')
112
- ? authHeader.slice('bearer '.length).trim()
113
- : null;
114
- const headerToken = req.headers['x-canon-approval-token'];
115
- const supplied = bearer || (typeof headerToken === 'string' ? headerToken : null);
116
- if (!supplied)
117
- return false;
118
- const expected = Buffer.from(this.token);
119
- const received = Buffer.from(supplied);
120
- return expected.length === received.length && timingSafeEqual(expected, received);
121
- }
122
- async handleApproval(body, res) {
123
- let payload;
124
- try {
125
- payload = JSON.parse(body);
126
- }
127
- catch {
128
- res.writeHead(400, { 'Content-Type': 'application/json' });
129
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
130
- return;
131
- }
132
- const toolName = payload.tool_name ?? 'Unknown';
133
- const toolInput = payload.tool_input ?? {};
134
- // Determine risk level
135
- const riskLevel = this.classifyRisk(toolName, toolInput);
136
- const isOwnerTurn = this.getIsOwnerTurn();
137
- // Find the conversation to send the approval to.
138
- const conversationId = this.getConversationId();
139
- if (!conversationId) {
140
- console.error('[canon] No active conversation for approval — falling back to ask');
141
- // Can't send approval message, fall through to terminal prompt.
142
- this.sendHookResponse(res, 'ask');
143
- return;
144
- }
145
- // Check session rules first for owner-triggered turns. Non-owner-triggered
146
- // tools require a fresh owner decision so an old "approve all" does not
147
- // turn into remote control of the owner's local runtime.
148
- const existingRule = isOwnerTurn ? this.manager.checkSessionRules(toolName, conversationId) : null;
149
- if (existingRule) {
150
- const decision = existingRule.type === 'deny-tool' ? 'deny' : 'allow';
151
- this.sendHookResponse(res, decision);
152
- return;
153
- }
154
- try {
155
- const result = await this.manager.requestApproval(conversationId, toolName, toolInput, {
156
- riskLevel,
157
- risk: classifyClaudeApprovalRisk(toolName, toolInput),
158
- category: classifyClaudeApprovalCategory(toolName),
159
- native: {
160
- runtime: 'claude-code',
161
- method: 'PermissionRequest',
162
- ...(payload.session_id ? { requestId: payload.session_id } : {}),
163
- },
164
- details: buildClaudeApprovalDetails(toolName, toolInput),
165
- ignoreSessionRules: !isOwnerTurn,
166
- allowSessionRule: isOwnerTurn,
167
- });
168
- this.sendHookResponse(res, result.decision);
169
- }
170
- catch (err) {
171
- console.error('[canon] Approval request failed:', err);
172
- // On error, fall back to terminal prompt
173
- this.sendHookResponse(res, 'ask');
174
- }
175
- }
176
- sendHookResponse(res, behavior) {
177
- res.writeHead(200, { 'Content-Type': 'application/json' });
178
- res.end(JSON.stringify({
179
- hookSpecificOutput: {
180
- hookEventName: 'PermissionRequest',
181
- decision: { behavior },
182
- },
183
- }));
184
- }
185
- classifyRisk(toolName, toolInput) {
186
- if (toolName !== 'Bash')
187
- return 'normal';
188
- const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
189
- const destructivePatterns = [
190
- /\brm\s+(-rf?|--force)/,
191
- /\bgit\s+push\s+--force/,
192
- /\bgit\s+reset\s+--hard/,
193
- /\bdrop\s+(table|database)/i,
194
- /\bformat\b/,
195
- ];
196
- return destructivePatterns.some((p) => p.test(cmd))
197
- ? 'destructive'
198
- : 'normal';
199
- }
200
- }
@@ -1,45 +0,0 @@
1
- import type { DirectSessionSelection, SendContextualMessageOptions } from '@canonmsg/core/contract';
2
- export type McpParseResult<T> = {
3
- ok: true;
4
- value: T;
5
- } | {
6
- ok: false;
7
- error: string;
8
- };
9
- type ContentType = 'text' | 'image' | 'audio' | 'video' | 'file';
10
- export interface ReplyArgs {
11
- conversationId: string;
12
- text: string;
13
- }
14
- export declare function parseReplyArgs(value: unknown): McpParseResult<ReplyArgs>;
15
- export interface SendMessageArgs {
16
- conversationId: string;
17
- text: string;
18
- contentType: ContentType;
19
- imageUrl?: string;
20
- audioUrl?: string;
21
- videoUrl?: string;
22
- fileUrl?: string;
23
- mediaPath?: string;
24
- fileName?: string;
25
- mimeType?: string;
26
- durationMs?: number;
27
- }
28
- export declare function parseSendMessageArgs(value: unknown): McpParseResult<SendMessageArgs>;
29
- export interface SendContextualMessageArgs {
30
- sourceConversationId: string;
31
- targetConversationId?: string;
32
- targetUserId?: string;
33
- text: string;
34
- selfContext: SendContextualMessageOptions['selfContext'];
35
- sessionSelection?: DirectSessionSelection;
36
- messageOptions?: SendContextualMessageOptions['messageOptions'];
37
- }
38
- export declare function parseSendContextualMessageArgs(value: unknown): McpParseResult<SendContextualMessageArgs>;
39
- export interface SetTypingArgs {
40
- conversationId: string;
41
- typing: boolean;
42
- status?: 'typing' | 'thinking';
43
- }
44
- export declare function parseSetTypingArgs(value: unknown): McpParseResult<SetTypingArgs>;
45
- export {};
package/dist/mcp-args.js DELETED
@@ -1,179 +0,0 @@
1
- function isRecord(value) {
2
- return Boolean(value && typeof value === 'object' && !Array.isArray(value));
3
- }
4
- function requiredString(record, key, maxLength = 4096) {
5
- const value = record[key];
6
- if (typeof value !== 'string') {
7
- return { ok: false, error: `Missing ${key}` };
8
- }
9
- const trimmed = value.trim();
10
- if (!trimmed) {
11
- return { ok: false, error: `Missing ${key}` };
12
- }
13
- if (trimmed.length > maxLength) {
14
- return { ok: false, error: `${key} is too long` };
15
- }
16
- return { ok: true, value: trimmed };
17
- }
18
- function optionalString(record, key, maxLength = 4096) {
19
- const value = record[key];
20
- if (typeof value !== 'string')
21
- return undefined;
22
- const trimmed = value.trim();
23
- return trimmed && trimmed.length <= maxLength ? trimmed : undefined;
24
- }
25
- function optionalNumber(record, key) {
26
- const value = record[key];
27
- return typeof value === 'number' && Number.isFinite(value) && value >= 0
28
- ? value
29
- : undefined;
30
- }
31
- function parseContentType(value) {
32
- if (value == null)
33
- return { ok: true, value: 'text' };
34
- if (value === 'text' || value === 'image' || value === 'audio' || value === 'video' || value === 'file') {
35
- return { ok: true, value };
36
- }
37
- return { ok: false, error: 'Invalid content_type' };
38
- }
39
- export function parseReplyArgs(value) {
40
- if (!isRecord(value))
41
- return { ok: false, error: 'Arguments must be an object' };
42
- const conversationId = requiredString(value, 'conversation_id', 256);
43
- if (!conversationId.ok)
44
- return conversationId;
45
- const text = requiredString(value, 'text');
46
- if (!text.ok)
47
- return text;
48
- return {
49
- ok: true,
50
- value: {
51
- conversationId: conversationId.value,
52
- text: text.value,
53
- },
54
- };
55
- }
56
- export function parseSendMessageArgs(value) {
57
- if (!isRecord(value))
58
- return { ok: false, error: 'Arguments must be an object' };
59
- const conversationId = requiredString(value, 'conversation_id', 256);
60
- if (!conversationId.ok)
61
- return conversationId;
62
- const contentType = parseContentType(value.content_type);
63
- if (!contentType.ok)
64
- return contentType;
65
- const imageUrl = optionalString(value, 'image_url', 4096);
66
- const audioUrl = optionalString(value, 'audio_url', 4096);
67
- const videoUrl = optionalString(value, 'video_url', 4096);
68
- const fileUrl = optionalString(value, 'file_url', 4096);
69
- const mediaPath = optionalString(value, 'media_path', 4096);
70
- const fileName = optionalString(value, 'file_name', 512);
71
- const mimeType = optionalString(value, 'mime_type', 256);
72
- const durationMs = optionalNumber(value, 'duration_ms');
73
- return {
74
- ok: true,
75
- value: {
76
- conversationId: conversationId.value,
77
- text: optionalString(value, 'text') ?? '',
78
- contentType: contentType.value,
79
- ...(imageUrl ? { imageUrl } : {}),
80
- ...(audioUrl ? { audioUrl } : {}),
81
- ...(videoUrl ? { videoUrl } : {}),
82
- ...(fileUrl ? { fileUrl } : {}),
83
- ...(mediaPath ? { mediaPath } : {}),
84
- ...(fileName ? { fileName } : {}),
85
- ...(mimeType ? { mimeType } : {}),
86
- ...(durationMs != null ? { durationMs } : {}),
87
- },
88
- };
89
- }
90
- function parseSessionSelection(value) {
91
- if (value == null)
92
- return { ok: true, value: undefined };
93
- const raw = typeof value === 'string'
94
- ? value
95
- : isRecord(value) && typeof value.mode === 'string'
96
- ? value.mode
97
- : null;
98
- const mode = raw?.trim().toLowerCase().replace(/-/g, '_');
99
- if (!mode)
100
- return { ok: false, error: 'Invalid session_selection' };
101
- if (mode === 'new' || mode === 'fresh' || mode === 'start_new') {
102
- return { ok: true, value: { mode: 'new' } };
103
- }
104
- if (mode === 'continue_latest' || mode === 'latest' || mode === 'continue') {
105
- return { ok: true, value: { mode: 'continue_latest' } };
106
- }
107
- if (mode === 'continue_or_create' || mode === 'auto' || mode === 'reuse_or_create') {
108
- return { ok: true, value: { mode: 'continue_or_create' } };
109
- }
110
- return { ok: false, error: 'session_selection must be new, continue_latest, or continue_or_create' };
111
- }
112
- export function parseSendContextualMessageArgs(value) {
113
- if (!isRecord(value))
114
- return { ok: false, error: 'Arguments must be an object' };
115
- const sourceConversationId = requiredString(value, 'source_conversation_id', 256);
116
- if (!sourceConversationId.ok)
117
- return sourceConversationId;
118
- const text = requiredString(value, 'text');
119
- if (!text.ok)
120
- return text;
121
- const targetConversationId = optionalString(value, 'target_conversation_id', 256);
122
- const targetUserId = optionalString(value, 'target_user_id', 256);
123
- if (Boolean(targetConversationId) === Boolean(targetUserId)) {
124
- return { ok: false, error: 'Provide exactly one target_conversation_id or target_user_id' };
125
- }
126
- if (!isRecord(value.self_context)) {
127
- return { ok: false, error: 'Missing self_context' };
128
- }
129
- const type = value.self_context.type;
130
- const context = requiredString(value.self_context, 'context');
131
- if (!context.ok)
132
- return context;
133
- if (type !== 'cross_session') {
134
- return { ok: false, error: 'self_context.type must be cross_session' };
135
- }
136
- const sessionSelection = parseSessionSelection(value.session_selection);
137
- if (!sessionSelection.ok)
138
- return sessionSelection;
139
- const messageOptions = isRecord(value.message_options)
140
- ? value.message_options
141
- : undefined;
142
- return {
143
- ok: true,
144
- value: {
145
- sourceConversationId: sourceConversationId.value,
146
- ...(targetConversationId ? { targetConversationId } : {}),
147
- ...(targetUserId ? { targetUserId } : {}),
148
- text: text.value,
149
- selfContext: {
150
- type: 'cross_session',
151
- context: context.value,
152
- },
153
- ...(sessionSelection.value ? { sessionSelection: sessionSelection.value } : {}),
154
- ...(messageOptions ? { messageOptions } : {}),
155
- },
156
- };
157
- }
158
- export function parseSetTypingArgs(value) {
159
- if (!isRecord(value))
160
- return { ok: false, error: 'Arguments must be an object' };
161
- const conversationId = requiredString(value, 'conversation_id', 256);
162
- if (!conversationId.ok)
163
- return conversationId;
164
- if (typeof value.typing !== 'boolean') {
165
- return { ok: false, error: 'typing must be a boolean' };
166
- }
167
- const status = value.status;
168
- if (status !== undefined && status !== 'typing' && status !== 'thinking') {
169
- return { ok: false, error: 'Invalid status' };
170
- }
171
- return {
172
- ok: true,
173
- value: {
174
- conversationId: conversationId.value,
175
- typing: value.typing,
176
- ...(status ? { status } : {}),
177
- },
178
- };
179
- }
package/dist/server.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export declare function main(): Promise<void>;