@canonmsg/claude-code-plugin 0.14.2 → 0.14.4
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/README.md +4 -3
- package/dist/approval-server.d.ts +10 -0
- package/dist/approval-server.js +78 -11
- package/dist/host.js +119 -18
- package/dist/mcp-args.d.ts +43 -0
- package/dist/mcp-args.js +151 -0
- package/dist/server.js +54 -35
- package/dist/session-state.d.ts +2 -0
- package/dist/session-state.js +3 -0
- package/dist/tool-policy.d.ts +30 -0
- package/dist/tool-policy.js +208 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ canon-register --name "My Claude" --description "My Claude Code agent" --phone "
|
|
|
15
15
|
canon-claude
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Send a message to your agent in the Canon app. Claude
|
|
18
|
+
Send a message to your agent in the Canon app. Owner-originated turns use your selected Claude permission mode; non-owner turns can still get text replies but do not get local shell/filesystem/web, MCP, or Canon outbound tool access by default. If you have a safe read-only tool for non-owner turns, allow exact tool names with `CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS=tool_a,tool_b`. `canon-claude` is the local agent process. Keep that terminal open while you want Canon to reach the agent. Closing it, logging out, rebooting, or sleeping long enough to stop the process takes this local agent offline until you revive it.
|
|
19
19
|
|
|
20
20
|
After a restart or closed terminal, use the shared local manager to list and revive all recorded local agents:
|
|
21
21
|
|
|
@@ -48,7 +48,7 @@ Advertise multiple project choices to the Canon app:
|
|
|
48
48
|
canon-claude --cwd ~/dev --workspace-root ~/dev
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
`--cwd` is the default workspace. Each `--workspace-root` value is an approved local root; the host discovers immediate child projects with common markers such as `.git`, `package.json`, `pyproject.toml`, `Cargo.toml`, or `go.mod` and publishes them as selectable projects during session creation. Use repeated `--workspace /path/to/project` entries to advertise specific projects outside those roots. Worktree mode creates a per-conversation git worktree under `~/.canon/conversation-worktrees`; shared-project mode runs directly in the selected directory.
|
|
51
|
+
`--cwd` is the default workspace. Each `--workspace-root` value is an approved local root; the host discovers immediate child projects with common markers such as `.git`, `package.json`, `pyproject.toml`, `Cargo.toml`, or `go.mod` and publishes them as selectable projects during session creation. Use repeated `--workspace /path/to/project` entries to advertise specific projects outside those roots. Worktree mode creates a best-effort per-conversation git worktree under `~/.canon/conversation-worktrees`; shared-project mode runs directly in the selected directory.
|
|
52
52
|
|
|
53
53
|
Current Canon truth for Claude host mode:
|
|
54
54
|
|
|
@@ -56,7 +56,8 @@ Current Canon truth for Claude host mode:
|
|
|
56
56
|
- thinking level / effort is live-editable when the host reports it
|
|
57
57
|
- workspace selection is setup-only
|
|
58
58
|
- execution mode selection is setup-only
|
|
59
|
-
- permission modes are
|
|
59
|
+
- permission modes are the known Claude Code modes supported by this host; Claude Code applies and enforces the selected mode for owner-originated turns
|
|
60
|
+
- non-owner turns deny local filesystem, shell, web/network, task, MCP, and Canon outbound tools by default in first-party host mode; pure text replies still work, and owners can allow exact safe tool names with `CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS`
|
|
60
61
|
- if worktree creation is unavailable for the selected project, Canon may fall back to shared-project execution and surface the fallback reason in session details
|
|
61
62
|
|
|
62
63
|
## Multiple agents
|
|
@@ -10,13 +10,23 @@ export declare class ApprovalHttpServer {
|
|
|
10
10
|
private manager;
|
|
11
11
|
private port;
|
|
12
12
|
private getConversationId;
|
|
13
|
+
private getIsOwnerTurn;
|
|
14
|
+
private token;
|
|
15
|
+
private maxBodyBytes;
|
|
16
|
+
private requestTimeoutMs;
|
|
13
17
|
constructor(manager: ApprovalManager, getConversationId: () => string | null, opts?: {
|
|
14
18
|
port?: number;
|
|
19
|
+
token?: string;
|
|
20
|
+
maxBodyBytes?: number;
|
|
21
|
+
requestTimeoutMs?: number;
|
|
22
|
+
getIsOwnerTurn?: () => boolean;
|
|
15
23
|
});
|
|
16
24
|
private static readonly MAX_PORT_RETRIES;
|
|
25
|
+
get authToken(): string;
|
|
17
26
|
start(): Promise<number>;
|
|
18
27
|
stop(): Promise<void>;
|
|
19
28
|
private handleRequest;
|
|
29
|
+
private hasValidToken;
|
|
20
30
|
private handleApproval;
|
|
21
31
|
private sendHookResponse;
|
|
22
32
|
private classifyRisk;
|
package/dist/approval-server.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { buildClaudeApprovalDetails, classifyClaudeApprovalCategory, classifyClaudeApprovalRisk, } from './tool-policy.js';
|
|
2
4
|
/**
|
|
3
5
|
* HTTP server that bridges Claude Code's PermissionRequest hook
|
|
4
6
|
* to Canon's ApprovalManager.
|
|
@@ -10,12 +12,23 @@ export class ApprovalHttpServer {
|
|
|
10
12
|
manager;
|
|
11
13
|
port;
|
|
12
14
|
getConversationId;
|
|
15
|
+
getIsOwnerTurn;
|
|
16
|
+
token;
|
|
17
|
+
maxBodyBytes;
|
|
18
|
+
requestTimeoutMs;
|
|
13
19
|
constructor(manager, getConversationId, opts) {
|
|
14
20
|
this.manager = manager;
|
|
15
21
|
this.getConversationId = getConversationId;
|
|
22
|
+
this.getIsOwnerTurn = opts?.getIsOwnerTurn ?? (() => true);
|
|
16
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;
|
|
17
27
|
}
|
|
18
28
|
static MAX_PORT_RETRIES = 10;
|
|
29
|
+
get authToken() {
|
|
30
|
+
return this.token;
|
|
31
|
+
}
|
|
19
32
|
async start() {
|
|
20
33
|
const startPort = this.port;
|
|
21
34
|
return new Promise((resolve, reject) => {
|
|
@@ -34,6 +47,10 @@ export class ApprovalHttpServer {
|
|
|
34
47
|
}
|
|
35
48
|
});
|
|
36
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
|
+
}
|
|
37
54
|
console.error(`[canon] Approval server listening on 127.0.0.1:${this.port}`);
|
|
38
55
|
resolve(this.port);
|
|
39
56
|
});
|
|
@@ -55,18 +72,53 @@ export class ApprovalHttpServer {
|
|
|
55
72
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
56
73
|
return;
|
|
57
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
|
+
}
|
|
58
85
|
let body = '';
|
|
59
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
|
+
}
|
|
60
95
|
body += chunk.toString();
|
|
61
96
|
});
|
|
62
97
|
req.on('end', () => {
|
|
98
|
+
if (res.writableEnded)
|
|
99
|
+
return;
|
|
63
100
|
this.handleApproval(body, res).catch((err) => {
|
|
64
101
|
console.error('[canon] Approval handler error:', err);
|
|
102
|
+
if (res.writableEnded)
|
|
103
|
+
return;
|
|
65
104
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
66
105
|
res.end(JSON.stringify({ error: 'Internal error' }));
|
|
67
106
|
});
|
|
68
107
|
});
|
|
69
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
|
+
}
|
|
70
122
|
async handleApproval(body, res) {
|
|
71
123
|
let payload;
|
|
72
124
|
try {
|
|
@@ -81,23 +133,38 @@ export class ApprovalHttpServer {
|
|
|
81
133
|
const toolInput = payload.tool_input ?? {};
|
|
82
134
|
// Determine risk level
|
|
83
135
|
const riskLevel = this.classifyRisk(toolName, toolInput);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (existingRule) {
|
|
87
|
-
const decision = existingRule.type === 'deny-tool' ? 'deny' : 'allow';
|
|
88
|
-
this.sendHookResponse(res, decision);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
// Find the conversation to send the approval to
|
|
136
|
+
const isOwnerTurn = this.getIsOwnerTurn();
|
|
137
|
+
// Find the conversation to send the approval to.
|
|
92
138
|
const conversationId = this.getConversationId();
|
|
93
139
|
if (!conversationId) {
|
|
94
|
-
console.error('[canon] No active conversation for approval — falling back to
|
|
95
|
-
// Can't send approval message, fall through to terminal prompt
|
|
140
|
+
console.error('[canon] No active conversation for approval — falling back to ask');
|
|
141
|
+
// Can't send approval message, fall through to terminal prompt.
|
|
96
142
|
this.sendHookResponse(res, 'ask');
|
|
97
143
|
return;
|
|
98
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
|
+
}
|
|
99
154
|
try {
|
|
100
|
-
const result = await this.manager.requestApproval(conversationId, toolName, toolInput, {
|
|
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
|
+
});
|
|
101
168
|
this.sendHookResponse(res, result.decision);
|
|
102
169
|
}
|
|
103
170
|
catch (err) {
|
package/dist/host.js
CHANGED
|
@@ -25,11 +25,12 @@ import { randomUUID } from 'node:crypto';
|
|
|
25
25
|
import { execFileSync } from 'node:child_process';
|
|
26
26
|
import { existsSync } from 'node:fs';
|
|
27
27
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
28
|
-
import { isAnthropicImageAttachment, materializeMessageMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
29
|
-
import { CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
28
|
+
import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
29
|
+
import { CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
30
30
|
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
31
31
|
import { createStreamingBuffer } from './streaming-buffer.js';
|
|
32
32
|
import { runCli } from './cli-entry.js';
|
|
33
|
+
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
33
34
|
import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
|
|
34
35
|
const HELP = `canon-claude — run a local Claude Code agent host for Canon
|
|
35
36
|
|
|
@@ -61,6 +62,7 @@ let workspaceRoots = [];
|
|
|
61
62
|
let workspaceRootMetadata = [];
|
|
62
63
|
let runtimeModels = [];
|
|
63
64
|
const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
|
|
65
|
+
const allowedNonOwnerClaudeTools = parseAllowedNonOwnerClaudeTools(process.env.CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS);
|
|
64
66
|
let runtimeMetadata = {
|
|
65
67
|
account: null,
|
|
66
68
|
mcpServers: [],
|
|
@@ -261,6 +263,17 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
261
263
|
],
|
|
262
264
|
});
|
|
263
265
|
}
|
|
266
|
+
function parseClaudePermissionMode(value) {
|
|
267
|
+
if (value === 'acceptEdits'
|
|
268
|
+
|| value === 'auto'
|
|
269
|
+
|| value === 'bypassPermissions'
|
|
270
|
+
|| value === 'default'
|
|
271
|
+
|| value === 'dontAsk'
|
|
272
|
+
|| value === 'plan') {
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
264
277
|
function normalizeClaudeEffortLevel(value) {
|
|
265
278
|
return value === 'low' || value === 'medium' || value === 'high'
|
|
266
279
|
? value
|
|
@@ -319,7 +332,9 @@ async function loadSessionConfig(conversationId, agentId) {
|
|
|
319
332
|
});
|
|
320
333
|
return {
|
|
321
334
|
...config,
|
|
322
|
-
permissionMode: config?.permissionMode
|
|
335
|
+
permissionMode: config?.permissionMode
|
|
336
|
+
? parseClaudePermissionMode(config.permissionMode) ?? undefined
|
|
337
|
+
: undefined,
|
|
323
338
|
effort: normalizeClaudeEffortLevel(config?.effort),
|
|
324
339
|
};
|
|
325
340
|
}
|
|
@@ -618,6 +633,21 @@ function buildClaudeSessionContextLines(session) {
|
|
|
618
633
|
function renderInboundContent(message, materialized) {
|
|
619
634
|
return renderCanonHostInboundContent(message, materialized);
|
|
620
635
|
}
|
|
636
|
+
async function materializePromptReplyContext(input) {
|
|
637
|
+
if (!input.replyContext?.found || !input.replyContext.attachments?.length) {
|
|
638
|
+
return { replyContext: input.replyContext, materialized: [] };
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
return await materializeReplyContextMedia(input.replyContext, {
|
|
642
|
+
agentId: input.agentId,
|
|
643
|
+
conversationId: input.conversationId,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
console.error(`${input.logPrefix} Failed to materialize replied-to media:`, error instanceof Error ? error.message : error);
|
|
648
|
+
return { replyContext: input.replyContext, materialized: [] };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
621
651
|
/**
|
|
622
652
|
* Build the multimodal `content` payload handed to the Claude Code SDK.
|
|
623
653
|
*
|
|
@@ -656,13 +686,14 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
656
686
|
messageQueue.push(input);
|
|
657
687
|
}
|
|
658
688
|
};
|
|
659
|
-
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false) => {
|
|
689
|
+
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false) => {
|
|
660
690
|
sendInput(createClaudeInputEnvelope({
|
|
661
691
|
kind: 'canon',
|
|
662
692
|
msg,
|
|
663
693
|
intent,
|
|
664
694
|
sourceMessageId,
|
|
665
695
|
markAccepted,
|
|
696
|
+
isOwnerTurn,
|
|
666
697
|
}));
|
|
667
698
|
};
|
|
668
699
|
const inputStream = {
|
|
@@ -717,7 +748,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
717
748
|
const queryOptions = {
|
|
718
749
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
719
750
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
720
|
-
permissionMode: config.permissionMode,
|
|
751
|
+
permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
|
|
721
752
|
allowDangerouslySkipPermissions: true,
|
|
722
753
|
cwd,
|
|
723
754
|
includePartialMessages: true,
|
|
@@ -809,8 +840,20 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
809
840
|
return { behavior: 'deny', message: 'No reply received (timed out)' };
|
|
810
841
|
}
|
|
811
842
|
}
|
|
812
|
-
|
|
813
|
-
|
|
843
|
+
return decideClaudeToolPermissionForMode({
|
|
844
|
+
permissionMode: session.state.permissionMode ?? config.permissionMode,
|
|
845
|
+
toolName,
|
|
846
|
+
toolInput: input,
|
|
847
|
+
conversationId,
|
|
848
|
+
runtimeId: config.runtimeId,
|
|
849
|
+
turnId: session.currentTurnId,
|
|
850
|
+
isOwnerTurn: session.activeInput?.kind === 'canon'
|
|
851
|
+
? session.activeInput.isOwnerTurn === true
|
|
852
|
+
: true,
|
|
853
|
+
approvalManager: config.approvalManager,
|
|
854
|
+
canRequestApproval: config.canRequestApproval,
|
|
855
|
+
allowedNonOwnerTools: allowedNonOwnerClaudeTools,
|
|
856
|
+
});
|
|
814
857
|
},
|
|
815
858
|
};
|
|
816
859
|
if (resumeSessionId) {
|
|
@@ -826,7 +869,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
826
869
|
environment,
|
|
827
870
|
query: q,
|
|
828
871
|
sendInput,
|
|
829
|
-
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted),
|
|
872
|
+
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn),
|
|
830
873
|
state: {
|
|
831
874
|
model: undefined,
|
|
832
875
|
permissionMode: config.permissionMode,
|
|
@@ -1119,7 +1162,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
1119
1162
|
void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
|
|
1120
1163
|
.finally(() => sendInput(next));
|
|
1121
1164
|
}
|
|
1122
|
-
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
|
|
1165
|
+
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false) => {
|
|
1123
1166
|
session.lastActivity = Date.now();
|
|
1124
1167
|
const input = createClaudeInputEnvelope({
|
|
1125
1168
|
kind: 'canon',
|
|
@@ -1127,6 +1170,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
|
|
|
1127
1170
|
intent,
|
|
1128
1171
|
sourceMessageId,
|
|
1129
1172
|
markAccepted,
|
|
1173
|
+
isOwnerTurn,
|
|
1130
1174
|
});
|
|
1131
1175
|
if (session.pendingFinalDelivery) {
|
|
1132
1176
|
session.pendingInputs.push(input);
|
|
@@ -1622,6 +1666,7 @@ export async function main() {
|
|
|
1622
1666
|
}
|
|
1623
1667
|
// ── Session manager ──
|
|
1624
1668
|
const sessions = new Map();
|
|
1669
|
+
const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
|
|
1625
1670
|
const pendingSessionCreations = new Map();
|
|
1626
1671
|
let controlStopped = false;
|
|
1627
1672
|
const lastSeenControl = new Map();
|
|
@@ -1647,6 +1692,13 @@ export async function main() {
|
|
|
1647
1692
|
}
|
|
1648
1693
|
lastKnownConversationRefreshAt = Date.now();
|
|
1649
1694
|
}
|
|
1695
|
+
async function canRequestCanonApproval(conversationId) {
|
|
1696
|
+
if (!ownerId)
|
|
1697
|
+
return false;
|
|
1698
|
+
const conversation = await getConversationMeta(conversationId)
|
|
1699
|
+
.catch(() => conversationCache.get(conversationId) ?? null);
|
|
1700
|
+
return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
|
|
1701
|
+
}
|
|
1650
1702
|
function handleConversationUpdated(payload) {
|
|
1651
1703
|
const rawMemberIds = payload.changes.memberIds;
|
|
1652
1704
|
if (!Array.isArray(rawMemberIds))
|
|
@@ -1695,10 +1747,12 @@ export async function main() {
|
|
|
1695
1747
|
]);
|
|
1696
1748
|
return buildHydratedInboundContext({
|
|
1697
1749
|
agentId,
|
|
1750
|
+
conversationId: input.conversationId,
|
|
1698
1751
|
conversation,
|
|
1699
1752
|
page,
|
|
1700
1753
|
activeSelfContextId: input.activeSelfContextId,
|
|
1701
1754
|
selfContexts: input.selfContexts,
|
|
1755
|
+
provenance: input.provenance,
|
|
1702
1756
|
message: input.message,
|
|
1703
1757
|
senderName: input.senderName,
|
|
1704
1758
|
isOwner: input.isOwner,
|
|
@@ -1873,6 +1927,9 @@ export async function main() {
|
|
|
1873
1927
|
effort: config?.effort,
|
|
1874
1928
|
availableModels: runtimeModels,
|
|
1875
1929
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1930
|
+
approvalManager,
|
|
1931
|
+
canRequestApproval: () => canRequestCanonApproval(conversationId),
|
|
1932
|
+
runtimeId,
|
|
1876
1933
|
}, resumeId);
|
|
1877
1934
|
sessions.set(conversationId, session);
|
|
1878
1935
|
await Promise.all([
|
|
@@ -1945,6 +2002,13 @@ export async function main() {
|
|
|
1945
2002
|
const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
|
|
1946
2003
|
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
1947
2004
|
const selfContexts = hydrated.selfContexts;
|
|
2005
|
+
const replyMedia = await materializePromptReplyContext({
|
|
2006
|
+
replyContext: hydrated.replyContext,
|
|
2007
|
+
agentId,
|
|
2008
|
+
conversationId: input.conversationId,
|
|
2009
|
+
logPrefix: `[canon-host] [${input.conversationId.slice(0, 8)}]`,
|
|
2010
|
+
});
|
|
2011
|
+
const replyContext = replyMedia.replyContext;
|
|
1948
2012
|
const participantContext = hydrated.participantContext;
|
|
1949
2013
|
const autoReply = decideAutoReply(participantContext, behavior);
|
|
1950
2014
|
if (!autoReply.allow) {
|
|
@@ -1980,9 +2044,13 @@ export async function main() {
|
|
|
1980
2044
|
participantContext,
|
|
1981
2045
|
behavior,
|
|
1982
2046
|
selfContexts,
|
|
2047
|
+
replyContext,
|
|
1983
2048
|
session,
|
|
1984
2049
|
});
|
|
1985
|
-
const messageContent = await buildCanonUserContent({
|
|
2050
|
+
const messageContent = await buildCanonUserContent({
|
|
2051
|
+
promptText,
|
|
2052
|
+
materialized: [...replyMedia.materialized, ...materialized],
|
|
2053
|
+
});
|
|
1986
2054
|
session.enqueueInbound({
|
|
1987
2055
|
type: 'user',
|
|
1988
2056
|
message: {
|
|
@@ -1990,7 +2058,7 @@ export async function main() {
|
|
|
1990
2058
|
content: messageContent,
|
|
1991
2059
|
},
|
|
1992
2060
|
parent_tool_use_id: null,
|
|
1993
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted);
|
|
2061
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner);
|
|
1994
2062
|
if (m.id) {
|
|
1995
2063
|
saveRuntimeSessionState(runtimeId, {
|
|
1996
2064
|
conversationId: input.conversationId,
|
|
@@ -2039,6 +2107,21 @@ export async function main() {
|
|
|
2039
2107
|
return;
|
|
2040
2108
|
const convoId = payload.conversationId;
|
|
2041
2109
|
knownConversationIds.add(convoId);
|
|
2110
|
+
if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2111
|
+
const consumed = approvalManager?.handleMessage(convoId, {
|
|
2112
|
+
senderId: m.senderId,
|
|
2113
|
+
text: m.text ?? undefined,
|
|
2114
|
+
metadata: m.metadata,
|
|
2115
|
+
}) ?? false;
|
|
2116
|
+
if (m.metadata?.type === 'approval_reply') {
|
|
2117
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
if (consumed) {
|
|
2121
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2042
2125
|
// Check for metadata replies (question answers, plan approvals)
|
|
2043
2126
|
if (m.metadata?.type === 'question_reply' || m.metadata?.type === 'plan_approval_reply') {
|
|
2044
2127
|
const existingSession = sessions.get(convoId);
|
|
@@ -2091,10 +2174,18 @@ export async function main() {
|
|
|
2091
2174
|
isOwner,
|
|
2092
2175
|
activeSelfContextId: payload.activeSelfContextId,
|
|
2093
2176
|
selfContexts: payload.selfContexts,
|
|
2177
|
+
provenance: payload.provenance,
|
|
2094
2178
|
});
|
|
2095
2179
|
const behavior = payload.behavior ?? hydrated.behavior;
|
|
2096
2180
|
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
2097
2181
|
const selfContexts = hydrated.selfContexts;
|
|
2182
|
+
const replyMedia = await materializePromptReplyContext({
|
|
2183
|
+
replyContext: hydrated.replyContext,
|
|
2184
|
+
agentId,
|
|
2185
|
+
conversationId: convoId,
|
|
2186
|
+
logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
|
|
2187
|
+
});
|
|
2188
|
+
const replyContext = replyMedia.replyContext;
|
|
2098
2189
|
const participantContext = hydrated.participantContext;
|
|
2099
2190
|
const autoReply = decideAutoReply(participantContext, behavior);
|
|
2100
2191
|
if (!autoReply.allow) {
|
|
@@ -2131,9 +2222,13 @@ export async function main() {
|
|
|
2131
2222
|
participantContext,
|
|
2132
2223
|
behavior,
|
|
2133
2224
|
selfContexts,
|
|
2225
|
+
replyContext,
|
|
2134
2226
|
session,
|
|
2135
2227
|
});
|
|
2136
|
-
const messageContent = await buildCanonUserContent({
|
|
2228
|
+
const messageContent = await buildCanonUserContent({
|
|
2229
|
+
promptText,
|
|
2230
|
+
materialized: [...replyMedia.materialized, ...materialized],
|
|
2231
|
+
});
|
|
2137
2232
|
session.enqueueInbound({
|
|
2138
2233
|
type: 'user',
|
|
2139
2234
|
message: {
|
|
@@ -2141,7 +2236,7 @@ export async function main() {
|
|
|
2141
2236
|
content: messageContent,
|
|
2142
2237
|
},
|
|
2143
2238
|
parent_tool_use_id: null,
|
|
2144
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted);
|
|
2239
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner);
|
|
2145
2240
|
if (m.id) {
|
|
2146
2241
|
saveRuntimeSessionState(runtimeId, {
|
|
2147
2242
|
conversationId: convoId,
|
|
@@ -2197,15 +2292,20 @@ export async function main() {
|
|
|
2197
2292
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
|
|
2198
2293
|
}
|
|
2199
2294
|
}
|
|
2200
|
-
|
|
2295
|
+
const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
|
|
2296
|
+
if (ctrl.permissionMode && !nextPermissionMode) {
|
|
2297
|
+
session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
|
|
2298
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
|
|
2299
|
+
}
|
|
2300
|
+
else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
|
|
2201
2301
|
try {
|
|
2202
|
-
await session.query.setPermissionMode(
|
|
2203
|
-
session.state.permissionMode =
|
|
2302
|
+
await session.query.setPermissionMode(nextPermissionMode);
|
|
2303
|
+
session.state.permissionMode = nextPermissionMode;
|
|
2204
2304
|
session.clearRuntimeControlError('permissionMode');
|
|
2205
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${
|
|
2305
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
|
|
2206
2306
|
}
|
|
2207
2307
|
catch (err) {
|
|
2208
|
-
session.setRuntimeControlError('permissionMode',
|
|
2308
|
+
session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
|
|
2209
2309
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
|
|
2210
2310
|
}
|
|
2211
2311
|
}
|
|
@@ -2327,6 +2427,7 @@ export async function main() {
|
|
|
2327
2427
|
clearInterval(heartbeat);
|
|
2328
2428
|
clearInterval(idleCheck);
|
|
2329
2429
|
stream.stop();
|
|
2430
|
+
approvalManager?.dispose();
|
|
2330
2431
|
await runtimeState.clearAgentRuntime().catch(() => { });
|
|
2331
2432
|
for (const convoId of [...sessions.keys()]) {
|
|
2332
2433
|
closeSession(convoId);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SendContextualMessageOptions } from '@canonmsg/core';
|
|
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' | '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
|
+
fileUrl?: string;
|
|
22
|
+
mediaPath?: string;
|
|
23
|
+
fileName?: string;
|
|
24
|
+
mimeType?: string;
|
|
25
|
+
durationMs?: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function parseSendMessageArgs(value: unknown): McpParseResult<SendMessageArgs>;
|
|
28
|
+
export interface SendContextualMessageArgs {
|
|
29
|
+
sourceConversationId: string;
|
|
30
|
+
targetConversationId?: string;
|
|
31
|
+
targetUserId?: string;
|
|
32
|
+
text: string;
|
|
33
|
+
selfContext: SendContextualMessageOptions['selfContext'];
|
|
34
|
+
messageOptions?: SendContextualMessageOptions['messageOptions'];
|
|
35
|
+
}
|
|
36
|
+
export declare function parseSendContextualMessageArgs(value: unknown): McpParseResult<SendContextualMessageArgs>;
|
|
37
|
+
export interface SetTypingArgs {
|
|
38
|
+
conversationId: string;
|
|
39
|
+
typing: boolean;
|
|
40
|
+
status?: 'typing' | 'thinking';
|
|
41
|
+
}
|
|
42
|
+
export declare function parseSetTypingArgs(value: unknown): McpParseResult<SetTypingArgs>;
|
|
43
|
+
export {};
|
package/dist/mcp-args.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
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 === '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 fileUrl = optionalString(value, 'file_url', 4096);
|
|
68
|
+
const mediaPath = optionalString(value, 'media_path', 4096);
|
|
69
|
+
const fileName = optionalString(value, 'file_name', 512);
|
|
70
|
+
const mimeType = optionalString(value, 'mime_type', 256);
|
|
71
|
+
const durationMs = optionalNumber(value, 'duration_ms');
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
value: {
|
|
75
|
+
conversationId: conversationId.value,
|
|
76
|
+
text: optionalString(value, 'text') ?? '',
|
|
77
|
+
contentType: contentType.value,
|
|
78
|
+
...(imageUrl ? { imageUrl } : {}),
|
|
79
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
80
|
+
...(fileUrl ? { fileUrl } : {}),
|
|
81
|
+
...(mediaPath ? { mediaPath } : {}),
|
|
82
|
+
...(fileName ? { fileName } : {}),
|
|
83
|
+
...(mimeType ? { mimeType } : {}),
|
|
84
|
+
...(durationMs != null ? { durationMs } : {}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function parseSendContextualMessageArgs(value) {
|
|
89
|
+
if (!isRecord(value))
|
|
90
|
+
return { ok: false, error: 'Arguments must be an object' };
|
|
91
|
+
const sourceConversationId = requiredString(value, 'source_conversation_id', 256);
|
|
92
|
+
if (!sourceConversationId.ok)
|
|
93
|
+
return sourceConversationId;
|
|
94
|
+
const text = requiredString(value, 'text');
|
|
95
|
+
if (!text.ok)
|
|
96
|
+
return text;
|
|
97
|
+
const targetConversationId = optionalString(value, 'target_conversation_id', 256);
|
|
98
|
+
const targetUserId = optionalString(value, 'target_user_id', 256);
|
|
99
|
+
if (Boolean(targetConversationId) === Boolean(targetUserId)) {
|
|
100
|
+
return { ok: false, error: 'Provide exactly one target_conversation_id or target_user_id' };
|
|
101
|
+
}
|
|
102
|
+
if (!isRecord(value.self_context)) {
|
|
103
|
+
return { ok: false, error: 'Missing self_context' };
|
|
104
|
+
}
|
|
105
|
+
const type = value.self_context.type;
|
|
106
|
+
const context = requiredString(value.self_context, 'context');
|
|
107
|
+
if (!context.ok)
|
|
108
|
+
return context;
|
|
109
|
+
if (type !== 'cross_session') {
|
|
110
|
+
return { ok: false, error: 'self_context.type must be cross_session' };
|
|
111
|
+
}
|
|
112
|
+
const messageOptions = isRecord(value.message_options)
|
|
113
|
+
? value.message_options
|
|
114
|
+
: undefined;
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
value: {
|
|
118
|
+
sourceConversationId: sourceConversationId.value,
|
|
119
|
+
...(targetConversationId ? { targetConversationId } : {}),
|
|
120
|
+
...(targetUserId ? { targetUserId } : {}),
|
|
121
|
+
text: text.value,
|
|
122
|
+
selfContext: {
|
|
123
|
+
type: 'cross_session',
|
|
124
|
+
context: context.value,
|
|
125
|
+
},
|
|
126
|
+
...(messageOptions ? { messageOptions } : {}),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function parseSetTypingArgs(value) {
|
|
131
|
+
if (!isRecord(value))
|
|
132
|
+
return { ok: false, error: 'Arguments must be an object' };
|
|
133
|
+
const conversationId = requiredString(value, 'conversation_id', 256);
|
|
134
|
+
if (!conversationId.ok)
|
|
135
|
+
return conversationId;
|
|
136
|
+
if (typeof value.typing !== 'boolean') {
|
|
137
|
+
return { ok: false, error: 'typing must be a boolean' };
|
|
138
|
+
}
|
|
139
|
+
const status = value.status;
|
|
140
|
+
if (status !== undefined && status !== 'typing' && status !== 'thinking') {
|
|
141
|
+
return { ok: false, error: 'Invalid status' };
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
value: {
|
|
146
|
+
conversationId: conversationId.value,
|
|
147
|
+
typing: value.typing,
|
|
148
|
+
...(status ? { status } : {}),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -14,6 +14,7 @@ import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActi
|
|
|
14
14
|
import { materializeMessageMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
15
15
|
import { ApprovalHttpServer } from './approval-server.js';
|
|
16
16
|
import { runCli } from './cli-entry.js';
|
|
17
|
+
import { parseReplyArgs, parseSendContextualMessageArgs, parseSendMessageArgs, parseSetTypingArgs, } from './mcp-args.js';
|
|
17
18
|
const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
|
|
18
19
|
|
|
19
20
|
USAGE
|
|
@@ -42,8 +43,10 @@ const TURN_COMPLETE_METADATA = {
|
|
|
42
43
|
turnSemantics: 'turn_complete',
|
|
43
44
|
turnComplete: true,
|
|
44
45
|
};
|
|
45
|
-
/**
|
|
46
|
-
let
|
|
46
|
+
/** Last owner-visible conversation to route approval cards into. */
|
|
47
|
+
let approvalConversationId = null;
|
|
48
|
+
/** Whether the message that most recently triggered work came from the owner. */
|
|
49
|
+
let triggerIsOwner = true;
|
|
47
50
|
function getPrimaryAttachment(message) {
|
|
48
51
|
const attachment = message.attachments?.[0];
|
|
49
52
|
if (attachment?.url)
|
|
@@ -53,6 +56,12 @@ function getPrimaryAttachment(message) {
|
|
|
53
56
|
function renderInboundContent(message, materialized) {
|
|
54
57
|
return renderCanonHostInboundContent(message, materialized);
|
|
55
58
|
}
|
|
59
|
+
function toolArgumentError(error) {
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: 'text', text: `Invalid arguments: ${error}` }],
|
|
62
|
+
isError: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
56
65
|
// ── Tool definitions ───────────────────────────────────────────────────
|
|
57
66
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
58
67
|
tools: [
|
|
@@ -216,8 +225,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
216
225
|
const args = request.params.arguments;
|
|
217
226
|
switch (request.params.name) {
|
|
218
227
|
case 'reply': {
|
|
219
|
-
const
|
|
220
|
-
|
|
228
|
+
const parsed = parseReplyArgs(args);
|
|
229
|
+
if (!parsed.ok)
|
|
230
|
+
return toolArgumentError(parsed.error);
|
|
231
|
+
const { conversationId, text } = parsed.value;
|
|
221
232
|
const result = await client.sendMessage(conversationId, text, {
|
|
222
233
|
metadata: TURN_COMPLETE_METADATA,
|
|
223
234
|
});
|
|
@@ -231,17 +242,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
231
242
|
};
|
|
232
243
|
}
|
|
233
244
|
case 'send_message': {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
245
|
+
const parsed = parseSendMessageArgs(args);
|
|
246
|
+
if (!parsed.ok)
|
|
247
|
+
return toolArgumentError(parsed.error);
|
|
248
|
+
const { conversationId, text, contentType, imageUrl, audioUrl, fileUrl, mediaPath, fileName, mimeType, durationMs, } = parsed.value;
|
|
237
249
|
const opts = {};
|
|
238
|
-
const imageUrl = typeof args.image_url === 'string' ? args.image_url : null;
|
|
239
|
-
const audioUrl = typeof args.audio_url === 'string' ? args.audio_url : null;
|
|
240
|
-
const fileUrl = typeof args.file_url === 'string' ? args.file_url : null;
|
|
241
|
-
const mediaPath = typeof args.media_path === 'string' ? args.media_path : null;
|
|
242
|
-
const fileName = typeof args.file_name === 'string' ? args.file_name : null;
|
|
243
|
-
const mimeType = typeof args.mime_type === 'string' ? args.mime_type : null;
|
|
244
|
-
const durationMs = typeof args.duration_ms === 'number' ? args.duration_ms : null;
|
|
245
250
|
const metadata = TURN_COMPLETE_METADATA;
|
|
246
251
|
if (mediaPath) {
|
|
247
252
|
const result = await sendMediaFileMessage(client, conversationId, mediaPath, text, {
|
|
@@ -301,25 +306,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
301
306
|
};
|
|
302
307
|
}
|
|
303
308
|
case 'send_contextual_message': {
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const targetUserId = typeof args.target_user_id === 'string' ? args.target_user_id : undefined;
|
|
309
|
-
const text = args.text;
|
|
310
|
-
const selfContext = typeof args.self_context === 'object' && args.self_context != null
|
|
311
|
-
? args.self_context
|
|
312
|
-
: undefined;
|
|
313
|
-
const messageOptions = typeof args.message_options === 'object' && args.message_options != null
|
|
314
|
-
? args.message_options
|
|
315
|
-
: undefined;
|
|
309
|
+
const parsed = parseSendContextualMessageArgs(args);
|
|
310
|
+
if (!parsed.ok)
|
|
311
|
+
return toolArgumentError(parsed.error);
|
|
312
|
+
const { sourceConversationId, targetConversationId, targetUserId, text, selfContext, messageOptions, } = parsed.value;
|
|
316
313
|
const contextualMessageOptions = messageOptions;
|
|
317
314
|
const result = await client.sendContextualMessage({
|
|
318
315
|
sourceConversationId,
|
|
319
316
|
...(targetConversationId ? { targetConversationId } : {}),
|
|
320
317
|
...(targetUserId ? { targetUserId } : {}),
|
|
321
318
|
text,
|
|
322
|
-
selfContext
|
|
319
|
+
selfContext,
|
|
323
320
|
...(contextualMessageOptions
|
|
324
321
|
? {
|
|
325
322
|
messageOptions: {
|
|
@@ -344,7 +341,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
344
341
|
};
|
|
345
342
|
}
|
|
346
343
|
case 'list_conversations': {
|
|
347
|
-
const
|
|
344
|
+
const rawArgs = args && typeof args === 'object' ? args : {};
|
|
345
|
+
const limit = typeof rawArgs.limit === 'number' && Number.isFinite(rawArgs.limit)
|
|
346
|
+
? Math.max(1, Math.min(100, Math.floor(rawArgs.limit)))
|
|
347
|
+
: 20;
|
|
348
348
|
const convos = await client.getConversations();
|
|
349
349
|
const slice = convos.slice(0, limit);
|
|
350
350
|
const summary = slice.map((c) => ({
|
|
@@ -381,9 +381,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
381
381
|
};
|
|
382
382
|
}
|
|
383
383
|
case 'set_typing': {
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
384
|
+
const parsed = parseSetTypingArgs(args);
|
|
385
|
+
if (!parsed.ok)
|
|
386
|
+
return toolArgumentError(parsed.error);
|
|
387
|
+
const { conversationId, typing, status } = parsed.value;
|
|
387
388
|
await client.setTyping(conversationId, typing, status);
|
|
388
389
|
return {
|
|
389
390
|
content: [
|
|
@@ -407,9 +408,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
407
408
|
async function handleInboundMessage(payload) {
|
|
408
409
|
const m = payload.message;
|
|
409
410
|
const convo = conversationCache.get(payload.conversationId);
|
|
410
|
-
// Track
|
|
411
|
-
|
|
412
|
-
|
|
411
|
+
// Track turn provenance separately from the owner-visible approval route.
|
|
412
|
+
// Non-owner direct chats do not include the owner, so approval cards still go
|
|
413
|
+
// to the last owner conversation while the hook bypasses cached session rules.
|
|
414
|
+
triggerIsOwner = m.isOwner === true;
|
|
415
|
+
if (triggerIsOwner) {
|
|
416
|
+
approvalConversationId = payload.conversationId;
|
|
413
417
|
}
|
|
414
418
|
// Route to approval manager first — if consumed, don't forward to Claude
|
|
415
419
|
if (approvalManager) {
|
|
@@ -476,6 +480,9 @@ async function handleInboundMessage(payload) {
|
|
|
476
480
|
}
|
|
477
481
|
}
|
|
478
482
|
const primaryAttachment = getPrimaryAttachment(m);
|
|
483
|
+
if (m.attachments?.length) {
|
|
484
|
+
meta.attachments_json = JSON.stringify(m.attachments);
|
|
485
|
+
}
|
|
479
486
|
if (primaryAttachment?.url) {
|
|
480
487
|
meta.attachment_kind = primaryAttachment.kind;
|
|
481
488
|
meta.attachment_url = primaryAttachment.url;
|
|
@@ -489,6 +496,11 @@ async function handleInboundMessage(payload) {
|
|
|
489
496
|
const primaryMaterialized = materialized.find((attachment) => attachment.index === 0);
|
|
490
497
|
if (primaryMaterialized?.path)
|
|
491
498
|
meta.attachment_path = primaryMaterialized.path;
|
|
499
|
+
if (materialized.length > 0) {
|
|
500
|
+
meta.attachment_paths = materialized
|
|
501
|
+
.map((attachment) => attachment.path)
|
|
502
|
+
.join('\n');
|
|
503
|
+
}
|
|
492
504
|
const renderedContent = renderInboundContent(m, materialized);
|
|
493
505
|
console.error(`[canon] Inbound message from ${meta.sender_name}: "${renderedContent}"`);
|
|
494
506
|
try {
|
|
@@ -612,11 +624,18 @@ async function startChannel() {
|
|
|
612
624
|
// Start approval system
|
|
613
625
|
if (agentContext?.ownerId) {
|
|
614
626
|
approvalManager = new ApprovalManager(client, agentId, agentContext.ownerId);
|
|
615
|
-
approvalServer = new ApprovalHttpServer(approvalManager, () =>
|
|
627
|
+
approvalServer = new ApprovalHttpServer(approvalManager, () => approvalConversationId, {
|
|
628
|
+
token: process.env.CANON_CLAUDE_APPROVAL_TOKEN,
|
|
629
|
+
getIsOwnerTurn: () => triggerIsOwner,
|
|
630
|
+
});
|
|
616
631
|
approvalServer.start().catch((err) => {
|
|
617
632
|
const msg = err instanceof Error ? err.message : String(err);
|
|
618
633
|
console.error(`[canon] Approval server error: ${msg}`);
|
|
619
634
|
});
|
|
635
|
+
console.error('[canon] Approval hooks must send Authorization: Bearer <token> or x-canon-approval-token.');
|
|
636
|
+
if (!process.env.CANON_CLAUDE_APPROVAL_TOKEN) {
|
|
637
|
+
console.error(`[canon] Generated local approval token: ${approvalServer.authToken}`);
|
|
638
|
+
}
|
|
620
639
|
}
|
|
621
640
|
}
|
|
622
641
|
// ── Main ───────────────────────────────────────────────────────────────
|
package/dist/session-state.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface ClaudeInputEnvelope {
|
|
|
8
8
|
sourceMessageId: string | null;
|
|
9
9
|
markAccepted: boolean;
|
|
10
10
|
turnKey: string;
|
|
11
|
+
isOwnerTurn: boolean;
|
|
11
12
|
}
|
|
12
13
|
export interface ClaudeRuntimeControlError {
|
|
13
14
|
value: string;
|
|
@@ -38,6 +39,7 @@ export declare function createClaudeInputEnvelope(input: {
|
|
|
38
39
|
intent?: DeliveryIntent;
|
|
39
40
|
sourceMessageId?: string | null;
|
|
40
41
|
markAccepted?: boolean;
|
|
42
|
+
isOwnerTurn?: boolean;
|
|
41
43
|
}): ClaudeInputEnvelope;
|
|
42
44
|
export declare function buildClaudeFinalMessageId(input: {
|
|
43
45
|
agentId: string;
|
package/dist/session-state.js
CHANGED
|
@@ -8,6 +8,9 @@ export function createClaudeInputEnvelope(input) {
|
|
|
8
8
|
intent: input.intent ?? 'queue',
|
|
9
9
|
sourceMessageId,
|
|
10
10
|
markAccepted: Boolean(input.markAccepted),
|
|
11
|
+
isOwnerTurn: input.kind === 'canon'
|
|
12
|
+
? input.isOwnerTurn === true
|
|
13
|
+
: input.isOwnerTurn !== false,
|
|
11
14
|
turnKey: input.kind === 'canon' && sourceMessageId
|
|
12
15
|
? `canon:${sourceMessageId}`
|
|
13
16
|
: `${input.kind}:${fallbackId}`,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ApprovalManager, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRisk } from '@canonmsg/core';
|
|
2
|
+
export type ClaudeToolPolicyDecision = {
|
|
3
|
+
behavior: 'allow';
|
|
4
|
+
} | {
|
|
5
|
+
behavior: 'deny';
|
|
6
|
+
message: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const CLAUDE_NATIVE_APPROVAL_PERMISSION_MODE = "default";
|
|
9
|
+
export declare function parseAllowedNonOwnerClaudeTools(value: string | null | undefined): string[];
|
|
10
|
+
export declare function isSensitiveNonOwnerClaudeTool(toolName: string, allowedNonOwnerTools?: Iterable<string>): boolean;
|
|
11
|
+
export declare function decideClaudeToolPermission(input: {
|
|
12
|
+
toolName: string;
|
|
13
|
+
isOwnerTurn: boolean;
|
|
14
|
+
allowedNonOwnerTools?: Iterable<string>;
|
|
15
|
+
}): ClaudeToolPolicyDecision;
|
|
16
|
+
export declare function classifyClaudeApprovalCategory(toolName: string): ApprovalRequestCategory;
|
|
17
|
+
export declare function classifyClaudeApprovalRisk(toolName: string, toolInput: Record<string, unknown>): ApprovalRisk;
|
|
18
|
+
export declare function buildClaudeApprovalDetails(toolName: string, toolInput: Record<string, unknown>): ApprovalRequestDetail[];
|
|
19
|
+
export declare function decideClaudeToolPermissionForMode(input: {
|
|
20
|
+
permissionMode: string;
|
|
21
|
+
toolName: string;
|
|
22
|
+
toolInput: Record<string, unknown>;
|
|
23
|
+
conversationId: string;
|
|
24
|
+
runtimeId?: string;
|
|
25
|
+
turnId?: string | null;
|
|
26
|
+
isOwnerTurn: boolean;
|
|
27
|
+
approvalManager?: ApprovalManager | null;
|
|
28
|
+
canRequestApproval?: () => Promise<boolean>;
|
|
29
|
+
allowedNonOwnerTools?: Iterable<string>;
|
|
30
|
+
}): Promise<ClaudeToolPolicyDecision>;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const NON_OWNER_DENY_MESSAGE = 'Only the agent owner can authorize Claude Code to use runtime tools from Canon chat.';
|
|
2
|
+
const NATIVE_APPROVAL_UNAVAILABLE_MESSAGE = 'Runtime action approval is not available for this conversation.';
|
|
3
|
+
const NATIVE_APPROVAL_DENIED_MESSAGE = 'The agent owner denied this action in Canon.';
|
|
4
|
+
export const CLAUDE_NATIVE_APPROVAL_PERMISSION_MODE = 'default';
|
|
5
|
+
const SENSITIVE_CLAUDE_TOOLS = new Set([
|
|
6
|
+
'agent',
|
|
7
|
+
'bash',
|
|
8
|
+
'bashoutput',
|
|
9
|
+
'config',
|
|
10
|
+
'edit',
|
|
11
|
+
'enterworktree',
|
|
12
|
+
'exitworktree',
|
|
13
|
+
'glob',
|
|
14
|
+
'grep',
|
|
15
|
+
'killbash',
|
|
16
|
+
'listmcpresources',
|
|
17
|
+
'ls',
|
|
18
|
+
'multiedit',
|
|
19
|
+
'notebookedit',
|
|
20
|
+
'notebookread',
|
|
21
|
+
'powershell',
|
|
22
|
+
'read',
|
|
23
|
+
'readmcpresource',
|
|
24
|
+
'task',
|
|
25
|
+
'taskoutput',
|
|
26
|
+
'taskstop',
|
|
27
|
+
'todowrite',
|
|
28
|
+
'webfetch',
|
|
29
|
+
'websearch',
|
|
30
|
+
'write',
|
|
31
|
+
]);
|
|
32
|
+
function normalizeToolName(toolName) {
|
|
33
|
+
return toolName.trim().toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
function isCanonOutboundTool(normalizedToolName) {
|
|
36
|
+
return normalizedToolName.endsWith('__reply')
|
|
37
|
+
|| normalizedToolName.endsWith('__send_message')
|
|
38
|
+
|| normalizedToolName.endsWith('__send_contextual_message')
|
|
39
|
+
|| normalizedToolName === 'reply'
|
|
40
|
+
|| normalizedToolName === 'send_message'
|
|
41
|
+
|| normalizedToolName === 'send_contextual_message';
|
|
42
|
+
}
|
|
43
|
+
function isMcpTool(normalizedToolName) {
|
|
44
|
+
return normalizedToolName.startsWith('mcp__');
|
|
45
|
+
}
|
|
46
|
+
function normalizeAllowedToolNames(values) {
|
|
47
|
+
const normalized = new Set();
|
|
48
|
+
for (const value of values ?? []) {
|
|
49
|
+
const toolName = normalizeToolName(value);
|
|
50
|
+
if (toolName)
|
|
51
|
+
normalized.add(toolName);
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
export function parseAllowedNonOwnerClaudeTools(value) {
|
|
56
|
+
if (!value)
|
|
57
|
+
return [];
|
|
58
|
+
return value
|
|
59
|
+
.split(',')
|
|
60
|
+
.map((entry) => entry.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
export function isSensitiveNonOwnerClaudeTool(toolName, allowedNonOwnerTools) {
|
|
64
|
+
const normalized = normalizeToolName(toolName);
|
|
65
|
+
if (normalizeAllowedToolNames(allowedNonOwnerTools).has(normalized))
|
|
66
|
+
return false;
|
|
67
|
+
if (SENSITIVE_CLAUDE_TOOLS.has(normalized))
|
|
68
|
+
return true;
|
|
69
|
+
if (isMcpTool(normalized))
|
|
70
|
+
return true;
|
|
71
|
+
return isCanonOutboundTool(normalized);
|
|
72
|
+
}
|
|
73
|
+
export function decideClaudeToolPermission(input) {
|
|
74
|
+
if (input.isOwnerTurn)
|
|
75
|
+
return { behavior: 'allow' };
|
|
76
|
+
if (!isSensitiveNonOwnerClaudeTool(input.toolName, input.allowedNonOwnerTools)) {
|
|
77
|
+
return { behavior: 'allow' };
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
behavior: 'deny',
|
|
81
|
+
message: NON_OWNER_DENY_MESSAGE,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function classifyClaudeApprovalCategory(toolName) {
|
|
85
|
+
const normalized = normalizeToolName(toolName);
|
|
86
|
+
if (normalized === 'bash' || normalized === 'bashoutput' || normalized === 'powershell' || normalized === 'killbash') {
|
|
87
|
+
return 'command';
|
|
88
|
+
}
|
|
89
|
+
if (normalized === 'edit'
|
|
90
|
+
|| normalized === 'multiedit'
|
|
91
|
+
|| normalized === 'write'
|
|
92
|
+
|| normalized === 'notebookedit'
|
|
93
|
+
|| normalized === 'read'
|
|
94
|
+
|| normalized === 'notebookread'
|
|
95
|
+
|| normalized === 'ls'
|
|
96
|
+
|| normalized === 'glob'
|
|
97
|
+
|| normalized === 'grep') {
|
|
98
|
+
return 'file';
|
|
99
|
+
}
|
|
100
|
+
if (normalized === 'webfetch' || normalized === 'websearch') {
|
|
101
|
+
return 'network';
|
|
102
|
+
}
|
|
103
|
+
if (isMcpTool(normalized) || normalized === 'listmcpresources' || normalized === 'readmcpresource') {
|
|
104
|
+
return 'mcp';
|
|
105
|
+
}
|
|
106
|
+
if (isCanonOutboundTool(normalized)) {
|
|
107
|
+
return 'canon';
|
|
108
|
+
}
|
|
109
|
+
return 'tool';
|
|
110
|
+
}
|
|
111
|
+
export function classifyClaudeApprovalRisk(toolName, toolInput) {
|
|
112
|
+
const normalized = normalizeToolName(toolName);
|
|
113
|
+
if (normalized === 'bash' || normalized === 'powershell') {
|
|
114
|
+
const command = typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
115
|
+
const destructivePatterns = [
|
|
116
|
+
/\brm\s+(-rf?|--force)/,
|
|
117
|
+
/\bgit\s+push\s+--force/,
|
|
118
|
+
/\bgit\s+reset\s+--hard/,
|
|
119
|
+
/\bdrop\s+(table|database)/i,
|
|
120
|
+
/\bformat\b/i,
|
|
121
|
+
];
|
|
122
|
+
if (destructivePatterns.some((pattern) => pattern.test(command))) {
|
|
123
|
+
return 'destructive';
|
|
124
|
+
}
|
|
125
|
+
return 'high';
|
|
126
|
+
}
|
|
127
|
+
if (normalized === 'write' || normalized === 'edit' || normalized === 'multiedit' || normalized === 'notebookedit') {
|
|
128
|
+
return 'high';
|
|
129
|
+
}
|
|
130
|
+
if (normalized === 'webfetch' || normalized === 'websearch' || isMcpTool(normalized)) {
|
|
131
|
+
return 'high';
|
|
132
|
+
}
|
|
133
|
+
return 'normal';
|
|
134
|
+
}
|
|
135
|
+
export function buildClaudeApprovalDetails(toolName, toolInput) {
|
|
136
|
+
const details = [];
|
|
137
|
+
const normalized = normalizeToolName(toolName);
|
|
138
|
+
const command = typeof toolInput.command === 'string' ? toolInput.command : null;
|
|
139
|
+
const filePath = typeof toolInput.file_path === 'string'
|
|
140
|
+
? toolInput.file_path
|
|
141
|
+
: typeof toolInput.path === 'string'
|
|
142
|
+
? toolInput.path
|
|
143
|
+
: null;
|
|
144
|
+
if (command) {
|
|
145
|
+
details.push({ label: 'Command', value: command, monospace: true });
|
|
146
|
+
}
|
|
147
|
+
if (filePath) {
|
|
148
|
+
details.push({ label: 'Path', value: filePath, monospace: true });
|
|
149
|
+
}
|
|
150
|
+
if (normalized.startsWith('mcp__')) {
|
|
151
|
+
details.push({ label: 'MCP tool', value: toolName, monospace: true });
|
|
152
|
+
}
|
|
153
|
+
const json = JSON.stringify(toolInput);
|
|
154
|
+
if (json && json !== '{}' && details.length === 0) {
|
|
155
|
+
details.push({
|
|
156
|
+
label: 'Input',
|
|
157
|
+
value: json.length > 500 ? `${json.slice(0, 497)}...` : json,
|
|
158
|
+
monospace: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return details;
|
|
162
|
+
}
|
|
163
|
+
export async function decideClaudeToolPermissionForMode(input) {
|
|
164
|
+
if (input.permissionMode !== CLAUDE_NATIVE_APPROVAL_PERMISSION_MODE) {
|
|
165
|
+
return decideClaudeToolPermission({
|
|
166
|
+
toolName: input.toolName,
|
|
167
|
+
isOwnerTurn: input.isOwnerTurn,
|
|
168
|
+
allowedNonOwnerTools: input.allowedNonOwnerTools,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (!input.approvalManager || !(await input.canRequestApproval?.())) {
|
|
172
|
+
return {
|
|
173
|
+
behavior: 'deny',
|
|
174
|
+
message: NATIVE_APPROVAL_UNAVAILABLE_MESSAGE,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
let result;
|
|
178
|
+
try {
|
|
179
|
+
const risk = classifyClaudeApprovalRisk(input.toolName, input.toolInput);
|
|
180
|
+
result = await input.approvalManager.requestApproval(input.conversationId, input.toolName, input.toolInput, {
|
|
181
|
+
category: classifyClaudeApprovalCategory(input.toolName),
|
|
182
|
+
risk,
|
|
183
|
+
riskLevel: risk === 'destructive' ? 'destructive' : 'normal',
|
|
184
|
+
runtimeId: input.runtimeId,
|
|
185
|
+
turnId: input.turnId ?? undefined,
|
|
186
|
+
native: {
|
|
187
|
+
runtime: 'claude-code',
|
|
188
|
+
method: 'canUseTool',
|
|
189
|
+
},
|
|
190
|
+
details: buildClaudeApprovalDetails(input.toolName, input.toolInput),
|
|
191
|
+
ignoreSessionRules: !input.isOwnerTurn,
|
|
192
|
+
allowSessionRule: input.isOwnerTurn,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return {
|
|
197
|
+
behavior: 'deny',
|
|
198
|
+
message: NATIVE_APPROVAL_UNAVAILABLE_MESSAGE,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (result.decision === 'allow') {
|
|
202
|
+
return { behavior: 'allow' };
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
behavior: 'deny',
|
|
206
|
+
message: NATIVE_APPROVAL_DENIED_MESSAGE,
|
|
207
|
+
};
|
|
208
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.4",
|
|
4
4
|
"description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@anthropic-ai/claude-agent-sdk": "^0.2.101",
|
|
34
|
-
"@canonmsg/agent-sdk": "^1.
|
|
35
|
-
"@canonmsg/core": "^0.
|
|
34
|
+
"@canonmsg/agent-sdk": "^1.5.0",
|
|
35
|
+
"@canonmsg/core": "^0.19.0",
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|