@canonmsg/claude-code-plugin 0.14.3 → 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 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 Code responds with full capabilities (file read/write, bash, etc.). `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.
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
 
@@ -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 the known Claude Code modes supported by this host; Claude Code applies and enforces the selected mode
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;
@@ -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
- // Check session rules first (may resolve immediately)
85
- const existingRule = this.manager.checkSessionRules(toolName);
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 allow');
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, { riskLevel });
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
@@ -26,10 +26,11 @@ import { execFileSync } from 'node:child_process';
26
26
  import { existsSync } from 'node:fs';
27
27
  import { query, } from '@anthropic-ai/claude-agent-sdk';
28
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, 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';
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
  }
@@ -671,13 +686,14 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
671
686
  messageQueue.push(input);
672
687
  }
673
688
  };
674
- let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false) => {
689
+ let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false) => {
675
690
  sendInput(createClaudeInputEnvelope({
676
691
  kind: 'canon',
677
692
  msg,
678
693
  intent,
679
694
  sourceMessageId,
680
695
  markAccepted,
696
+ isOwnerTurn,
681
697
  }));
682
698
  };
683
699
  const inputStream = {
@@ -732,7 +748,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
732
748
  const queryOptions = {
733
749
  systemPrompt: { type: 'preset', preset: 'claude_code' },
734
750
  tools: { type: 'preset', preset: 'claude_code' },
735
- permissionMode: config.permissionMode,
751
+ permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
736
752
  allowDangerouslySkipPermissions: true,
737
753
  cwd,
738
754
  includePartialMessages: true,
@@ -824,8 +840,20 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
824
840
  return { behavior: 'deny', message: 'No reply received (timed out)' };
825
841
  }
826
842
  }
827
- // Default: allow (permission mode handles restrictions)
828
- return { behavior: 'allow' };
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
+ });
829
857
  },
830
858
  };
831
859
  if (resumeSessionId) {
@@ -841,7 +869,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
841
869
  environment,
842
870
  query: q,
843
871
  sendInput,
844
- 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),
845
873
  state: {
846
874
  model: undefined,
847
875
  permissionMode: config.permissionMode,
@@ -1134,7 +1162,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
1134
1162
  void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
1135
1163
  .finally(() => sendInput(next));
1136
1164
  }
1137
- enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
1165
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false) => {
1138
1166
  session.lastActivity = Date.now();
1139
1167
  const input = createClaudeInputEnvelope({
1140
1168
  kind: 'canon',
@@ -1142,6 +1170,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
1142
1170
  intent,
1143
1171
  sourceMessageId,
1144
1172
  markAccepted,
1173
+ isOwnerTurn,
1145
1174
  });
1146
1175
  if (session.pendingFinalDelivery) {
1147
1176
  session.pendingInputs.push(input);
@@ -1637,6 +1666,7 @@ export async function main() {
1637
1666
  }
1638
1667
  // ── Session manager ──
1639
1668
  const sessions = new Map();
1669
+ const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
1640
1670
  const pendingSessionCreations = new Map();
1641
1671
  let controlStopped = false;
1642
1672
  const lastSeenControl = new Map();
@@ -1662,6 +1692,13 @@ export async function main() {
1662
1692
  }
1663
1693
  lastKnownConversationRefreshAt = Date.now();
1664
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
+ }
1665
1702
  function handleConversationUpdated(payload) {
1666
1703
  const rawMemberIds = payload.changes.memberIds;
1667
1704
  if (!Array.isArray(rawMemberIds))
@@ -1710,10 +1747,12 @@ export async function main() {
1710
1747
  ]);
1711
1748
  return buildHydratedInboundContext({
1712
1749
  agentId,
1750
+ conversationId: input.conversationId,
1713
1751
  conversation,
1714
1752
  page,
1715
1753
  activeSelfContextId: input.activeSelfContextId,
1716
1754
  selfContexts: input.selfContexts,
1755
+ provenance: input.provenance,
1717
1756
  message: input.message,
1718
1757
  senderName: input.senderName,
1719
1758
  isOwner: input.isOwner,
@@ -1888,6 +1927,9 @@ export async function main() {
1888
1927
  effort: config?.effort,
1889
1928
  availableModels: runtimeModels,
1890
1929
  availableExecutionModes: hostAvailableExecutionModes,
1930
+ approvalManager,
1931
+ canRequestApproval: () => canRequestCanonApproval(conversationId),
1932
+ runtimeId,
1891
1933
  }, resumeId);
1892
1934
  sessions.set(conversationId, session);
1893
1935
  await Promise.all([
@@ -2016,7 +2058,7 @@ export async function main() {
2016
2058
  content: messageContent,
2017
2059
  },
2018
2060
  parent_tool_use_id: null,
2019
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted);
2061
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner);
2020
2062
  if (m.id) {
2021
2063
  saveRuntimeSessionState(runtimeId, {
2022
2064
  conversationId: input.conversationId,
@@ -2065,6 +2107,21 @@ export async function main() {
2065
2107
  return;
2066
2108
  const convoId = payload.conversationId;
2067
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
+ }
2068
2125
  // Check for metadata replies (question answers, plan approvals)
2069
2126
  if (m.metadata?.type === 'question_reply' || m.metadata?.type === 'plan_approval_reply') {
2070
2127
  const existingSession = sessions.get(convoId);
@@ -2117,6 +2174,7 @@ export async function main() {
2117
2174
  isOwner,
2118
2175
  activeSelfContextId: payload.activeSelfContextId,
2119
2176
  selfContexts: payload.selfContexts,
2177
+ provenance: payload.provenance,
2120
2178
  });
2121
2179
  const behavior = payload.behavior ?? hydrated.behavior;
2122
2180
  const activeSelfContextId = hydrated.activeSelfContextId;
@@ -2178,7 +2236,7 @@ export async function main() {
2178
2236
  content: messageContent,
2179
2237
  },
2180
2238
  parent_tool_use_id: null,
2181
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted);
2239
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner);
2182
2240
  if (m.id) {
2183
2241
  saveRuntimeSessionState(runtimeId, {
2184
2242
  conversationId: convoId,
@@ -2234,15 +2292,20 @@ export async function main() {
2234
2292
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
2235
2293
  }
2236
2294
  }
2237
- if (ctrl.permissionMode && ctrl.permissionMode !== session.state.permissionMode) {
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) {
2238
2301
  try {
2239
- await session.query.setPermissionMode(ctrl.permissionMode);
2240
- session.state.permissionMode = ctrl.permissionMode;
2302
+ await session.query.setPermissionMode(nextPermissionMode);
2303
+ session.state.permissionMode = nextPermissionMode;
2241
2304
  session.clearRuntimeControlError('permissionMode');
2242
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${ctrl.permissionMode}`);
2305
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
2243
2306
  }
2244
2307
  catch (err) {
2245
- session.setRuntimeControlError('permissionMode', ctrl.permissionMode, err);
2308
+ session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
2246
2309
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
2247
2310
  }
2248
2311
  }
@@ -2364,6 +2427,7 @@ export async function main() {
2364
2427
  clearInterval(heartbeat);
2365
2428
  clearInterval(idleCheck);
2366
2429
  stream.stop();
2430
+ approvalManager?.dispose();
2367
2431
  await runtimeState.clearAgentRuntime().catch(() => { });
2368
2432
  for (const convoId of [...sessions.keys()]) {
2369
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 {};
@@ -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
- /** The conversation that most recently triggered work (for approval routing) */
46
- let triggerConversationId = null;
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 conversationId = args.conversation_id;
220
- const text = args.text;
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 conversationId = args.conversation_id;
235
- const text = typeof args.text === 'string' ? args.text : '';
236
- const contentType = args.content_type || 'text';
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 sourceConversationId = args.source_conversation_id;
305
- const targetConversationId = typeof args.target_conversation_id === 'string'
306
- ? args.target_conversation_id
307
- : undefined;
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: 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 limit = args.limit || 20;
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 conversationId = args.conversation_id;
385
- const typing = args.typing;
386
- const status = args.status;
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 trigger conversation — updated when the owner sends a message
411
- if (m.isOwner) {
412
- triggerConversationId = payload.conversationId;
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) {
@@ -620,11 +624,18 @@ async function startChannel() {
620
624
  // Start approval system
621
625
  if (agentContext?.ownerId) {
622
626
  approvalManager = new ApprovalManager(client, agentId, agentContext.ownerId);
623
- approvalServer = new ApprovalHttpServer(approvalManager, () => triggerConversationId);
627
+ approvalServer = new ApprovalHttpServer(approvalManager, () => approvalConversationId, {
628
+ token: process.env.CANON_CLAUDE_APPROVAL_TOKEN,
629
+ getIsOwnerTurn: () => triggerIsOwner,
630
+ });
624
631
  approvalServer.start().catch((err) => {
625
632
  const msg = err instanceof Error ? err.message : String(err);
626
633
  console.error(`[canon] Approval server error: ${msg}`);
627
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
+ }
628
639
  }
629
640
  }
630
641
  // ── Main ───────────────────────────────────────────────────────────────
@@ -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;
@@ -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",
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.4.1",
35
- "@canonmsg/core": "^0.18.1",
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": {