@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 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
 
@@ -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 rendered exactly as the Claude runtime reports them; Canon does not flatten them to a smaller hardcoded set
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
@@ -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
- // Default: allow (permission mode handles restrictions)
813
- 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
+ });
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({ promptText, materialized });
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({ promptText, materialized });
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
- 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) {
2201
2301
  try {
2202
- await session.query.setPermissionMode(ctrl.permissionMode);
2203
- session.state.permissionMode = ctrl.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 → ${ctrl.permissionMode}`);
2305
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
2206
2306
  }
2207
2307
  catch (err) {
2208
- session.setRuntimeControlError('permissionMode', ctrl.permissionMode, err);
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 {};
@@ -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) {
@@ -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, () => triggerConversationId);
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 ───────────────────────────────────────────────────────────────
@@ -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.2",
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.0",
35
- "@canonmsg/core": "^0.18.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": {