@canonmsg/core 0.2.2

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.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared agent profile management — loading, locking, and resolution.
3
+ * Used by both host.ts and server.ts.
4
+ */
5
+ export interface AgentProfile {
6
+ apiKey: string;
7
+ agentId: string;
8
+ agentName: string;
9
+ registeredAt: string;
10
+ }
11
+ export declare const CANON_DIR: string;
12
+ export declare const AGENTS_PATH: string;
13
+ export declare const LOCKS_DIR: string;
14
+ export declare function loadProfiles(): Record<string, AgentProfile>;
15
+ export declare function isProcessAlive(pid: number): boolean;
16
+ export declare function isProfileLocked(profile: string): {
17
+ locked: boolean;
18
+ pid?: number;
19
+ };
20
+ export declare function acquireLock(profile: string): void;
21
+ export declare function releaseLock(profile: string): void;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared agent profile management — loading, locking, and resolution.
3
+ * Used by both host.ts and server.ts.
4
+ */
5
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ // ── Paths ────────────────────────────────────────────────────────────
9
+ export const CANON_DIR = join(homedir(), '.canon');
10
+ export const AGENTS_PATH = join(CANON_DIR, 'agents.json');
11
+ export const LOCKS_DIR = join(CANON_DIR, 'locks');
12
+ // ── Profile loading ──────────────────────────────────────────────────
13
+ export function loadProfiles() {
14
+ try {
15
+ return JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ // ── Process locking ──────────────────────────────────────────────────
22
+ export function isProcessAlive(pid) {
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ export function isProfileLocked(profile) {
32
+ const lockPath = join(LOCKS_DIR, `${profile}.lock`);
33
+ try {
34
+ const pid = parseInt(readFileSync(lockPath, 'utf-8').trim());
35
+ if (isProcessAlive(pid))
36
+ return { locked: true, pid };
37
+ // Stale lock — clean it up
38
+ try {
39
+ unlinkSync(lockPath);
40
+ }
41
+ catch { }
42
+ }
43
+ catch {
44
+ // No lock file
45
+ }
46
+ return { locked: false };
47
+ }
48
+ export function acquireLock(profile) {
49
+ mkdirSync(LOCKS_DIR, { recursive: true });
50
+ const lockPath = join(LOCKS_DIR, `${profile}.lock`);
51
+ const check = isProfileLocked(profile);
52
+ if (check.locked) {
53
+ throw new Error(`Agent "${profile}" is in use by another session (PID ${check.pid})`);
54
+ }
55
+ writeFileSync(lockPath, String(process.pid));
56
+ }
57
+ export function releaseLock(profile) {
58
+ const lockPath = join(LOCKS_DIR, `${profile}.lock`);
59
+ try {
60
+ unlinkSync(lockPath);
61
+ }
62
+ catch { }
63
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Unified agent resolution — resolves Canon agent credentials from
3
+ * environment variables or ~/.canon/agents.json profiles.
4
+ *
5
+ * Used by the Claude Code plugin (host.ts, server.ts) and available
6
+ * for any future agent integration.
7
+ *
8
+ * Resolution order:
9
+ * 1. CANON_API_KEY env var (highest priority, no lock)
10
+ * 2. CANON_AGENT env var (named profile with lock)
11
+ * 3. Auto-select from profiles (first unlocked, with lock)
12
+ */
13
+ export interface ResolvedAgent {
14
+ apiKey: string;
15
+ agentId?: string;
16
+ profile: string | null;
17
+ }
18
+ /** Get the currently locked profile name (for cleanup on shutdown). */
19
+ export declare function getActiveProfile(): string | null;
20
+ /**
21
+ * Resolve Canon agent credentials.
22
+ *
23
+ * @param opts.logPrefix - Log prefix for console messages (default: '[canon]')
24
+ * @returns Resolved agent with API key, optional agentId, and profile name
25
+ * @throws Error if no agent can be resolved
26
+ */
27
+ export declare function resolveCanonAgent(opts?: {
28
+ logPrefix?: string;
29
+ }): ResolvedAgent;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Unified agent resolution — resolves Canon agent credentials from
3
+ * environment variables or ~/.canon/agents.json profiles.
4
+ *
5
+ * Used by the Claude Code plugin (host.ts, server.ts) and available
6
+ * for any future agent integration.
7
+ *
8
+ * Resolution order:
9
+ * 1. CANON_API_KEY env var (highest priority, no lock)
10
+ * 2. CANON_AGENT env var (named profile with lock)
11
+ * 3. Auto-select from profiles (first unlocked, with lock)
12
+ */
13
+ import { loadProfiles, isProfileLocked, acquireLock, } from './agent-profiles.js';
14
+ let activeResolvedProfile = null;
15
+ /** Get the currently locked profile name (for cleanup on shutdown). */
16
+ export function getActiveProfile() {
17
+ return activeResolvedProfile;
18
+ }
19
+ /**
20
+ * Resolve Canon agent credentials.
21
+ *
22
+ * @param opts.logPrefix - Log prefix for console messages (default: '[canon]')
23
+ * @returns Resolved agent with API key, optional agentId, and profile name
24
+ * @throws Error if no agent can be resolved
25
+ */
26
+ export function resolveCanonAgent(opts) {
27
+ const prefix = opts?.logPrefix ?? '[canon]';
28
+ // 1. Explicit API key (highest priority, no lock)
29
+ // CANON_API_KEY is set by the host wrapper or user environment.
30
+ // CANON_PLUGIN_API_KEY is set by plugin.json from user_config.
31
+ const envKey = process.env.CANON_API_KEY || process.env.CANON_PLUGIN_API_KEY;
32
+ if (envKey) {
33
+ return { apiKey: envKey, profile: null };
34
+ }
35
+ const profiles = loadProfiles();
36
+ const names = Object.keys(profiles);
37
+ // 2. Named profile via env var
38
+ if (process.env.CANON_AGENT) {
39
+ const name = process.env.CANON_AGENT;
40
+ const p = profiles[name];
41
+ if (!p) {
42
+ throw new Error(`${prefix} Profile "${name}" not found in ~/.canon/agents.json`);
43
+ }
44
+ acquireLock(name);
45
+ activeResolvedProfile = name;
46
+ return { apiKey: p.apiKey, agentId: p.agentId, profile: name };
47
+ }
48
+ // 3. Auto-select from profiles
49
+ if (names.length === 0) {
50
+ throw new Error(`${prefix} No agents registered. Run canon-register first.`);
51
+ }
52
+ if (names.length === 1) {
53
+ const name = names[0];
54
+ acquireLock(name);
55
+ activeResolvedProfile = name;
56
+ return { apiKey: profiles[name].apiKey, agentId: profiles[name].agentId, profile: name };
57
+ }
58
+ // Multiple agents — pick first unlocked
59
+ for (const name of names) {
60
+ if (!isProfileLocked(name).locked) {
61
+ acquireLock(name);
62
+ activeResolvedProfile = name;
63
+ console.error(`${prefix} Auto-selected agent "${name}" (${profiles[name].agentName})`);
64
+ return { apiKey: profiles[name].apiKey, agentId: profiles[name].agentId, profile: name };
65
+ }
66
+ }
67
+ throw new Error(`${prefix} All agents are in use by other sessions.`);
68
+ }
@@ -0,0 +1,21 @@
1
+ import type { ApprovalRequestMetadata, ApprovalReplyMetadata, SessionRule } from './approval-types.js';
2
+ export declare function generateApprovalId(): string;
3
+ export declare function redactSecrets(text: string, patterns: string[]): string;
4
+ export declare function buildApprovalRequest(approvalId: string, toolName: string, toolInput: Record<string, unknown>, opts: {
5
+ riskLevel?: 'normal' | 'destructive';
6
+ expiresAt: string;
7
+ redactPatterns?: string[];
8
+ }): {
9
+ text: string;
10
+ metadata: ApprovalRequestMetadata;
11
+ };
12
+ export declare function buildApprovalReply(approvalId: string, decision: 'allow' | 'deny', sessionRule?: SessionRule): {
13
+ text: string;
14
+ metadata: ApprovalReplyMetadata;
15
+ };
16
+ export declare function buildApprovalOutcome(approvalId: string, toolName: string, toolSummary: string, decision: 'allow' | 'deny', reason: 'replied' | 'timeout' | 'session-rule'): string;
17
+ export declare function parseTextApprovalReply(text: string): {
18
+ decision: 'allow' | 'deny';
19
+ sessionRule?: SessionRule;
20
+ targetApprovalId?: string;
21
+ } | null;
@@ -0,0 +1,148 @@
1
+ // ── ID generation ───────────────────────────────────────────────────
2
+ let seq = 0;
3
+ export function generateApprovalId() {
4
+ const ts = Date.now().toString(36);
5
+ const r = Math.random().toString(36).slice(2, 6);
6
+ return `apr_${ts}${r}${++seq}`;
7
+ }
8
+ // ── Tool input summarisation ────────────────────────────────────────
9
+ function summarizeToolInput(toolName, toolInput) {
10
+ // Bash: show the command
11
+ if (toolName === 'Bash' && typeof toolInput.command === 'string') {
12
+ return toolInput.command;
13
+ }
14
+ // Edit/Write: show the file path
15
+ if ((toolName === 'Edit' || toolName === 'Write') && typeof toolInput.file_path === 'string') {
16
+ return toolInput.file_path;
17
+ }
18
+ // Generic: compact JSON, truncated
19
+ const json = JSON.stringify(toolInput);
20
+ return json.length > 200 ? json.slice(0, 200) + '...' : json;
21
+ }
22
+ // ── Redaction ───────────────────────────────────────────────────────
23
+ export function redactSecrets(text, patterns) {
24
+ let result = text;
25
+ for (const pat of patterns) {
26
+ result = result.replace(new RegExp(pat, 'gi'), '[REDACTED]');
27
+ }
28
+ return result;
29
+ }
30
+ // ── Build approval request message ──────────────────────────────────
31
+ export function buildApprovalRequest(approvalId, toolName, toolInput, opts) {
32
+ let summary = summarizeToolInput(toolName, toolInput);
33
+ if (opts.redactPatterns?.length) {
34
+ summary = redactSecrets(summary, opts.redactPatterns);
35
+ }
36
+ const timeoutMin = Math.round((new Date(opts.expiresAt).getTime() - Date.now()) / 60_000);
37
+ const lines = [
38
+ `Tool Approval Required [${approvalId}]`,
39
+ '',
40
+ `Tool: ${toolName}`,
41
+ summary,
42
+ '',
43
+ ];
44
+ if (opts.riskLevel === 'destructive') {
45
+ lines.push('This is a destructive operation');
46
+ lines.push('');
47
+ }
48
+ lines.push(`Reply "approve" or "deny" (expires in ${timeoutMin}m)`);
49
+ const metadata = {
50
+ type: 'approval_request',
51
+ approvalId,
52
+ toolName,
53
+ toolSummary: summary,
54
+ riskLevel: opts.riskLevel ?? 'normal',
55
+ expiresAt: opts.expiresAt,
56
+ };
57
+ return { text: lines.join('\n'), metadata };
58
+ }
59
+ // ── Build approval reply message (used by app) ─────────────────────
60
+ export function buildApprovalReply(approvalId, decision, sessionRule) {
61
+ const label = decision === 'allow' ? 'Approved' : 'Denied';
62
+ return {
63
+ text: label,
64
+ metadata: {
65
+ type: 'approval_reply',
66
+ approvalId,
67
+ decision,
68
+ ...(sessionRule ? { sessionRule } : {}),
69
+ },
70
+ };
71
+ }
72
+ // ── Build outcome confirmation message ──────────────────────────────
73
+ export function buildApprovalOutcome(approvalId, toolName, toolSummary, decision, reason) {
74
+ const icon = decision === 'allow' ? 'Approved' : 'Denied';
75
+ const short = toolSummary.length > 60 ? toolSummary.slice(0, 60) + '...' : toolSummary;
76
+ if (reason === 'timeout') {
77
+ return `Expired [${approvalId}] -- ${toolName}: ${short} (auto-denied)`;
78
+ }
79
+ if (reason === 'session-rule') {
80
+ return `Auto-approved (session rule) -- ${toolName}: ${short}`;
81
+ }
82
+ return `${icon} [${approvalId}] -- ${toolName}: ${short}`;
83
+ }
84
+ // ── Parse text-based approval reply (fallback for non-card UIs) ─────
85
+ const ALLOW_WORDS = new Set([
86
+ 'approve', 'approved', 'allow', 'yes', 'y', 'ok', 'go', 'do it', 'go ahead',
87
+ ]);
88
+ const DENY_WORDS = new Set([
89
+ 'deny', 'denied', 'reject', 'no', 'n', 'nope', 'stop', "don't",
90
+ ]);
91
+ export function parseTextApprovalReply(text) {
92
+ const trimmed = text.trim().toLowerCase();
93
+ if (!trimmed)
94
+ return null;
95
+ // "approve apr_xxxxx" or "deny apr_xxxxx" — target specific approval
96
+ const idMatch = trimmed.match(/^(approve|deny|allow|reject)\s+(apr_\w+)$/);
97
+ if (idMatch) {
98
+ const decision = idMatch[1] === 'deny' || idMatch[1] === 'reject' ? 'deny' : 'allow';
99
+ return { decision, targetApprovalId: idMatch[2] };
100
+ }
101
+ // "approve all for Xm" — time-limited blanket
102
+ const timeMatch = trimmed.match(/^approve\s+all\s+for\s+(\d+)\s*m(?:in(?:utes?)?)?$/);
103
+ if (timeMatch) {
104
+ const minutes = parseInt(timeMatch[1], 10);
105
+ return {
106
+ decision: 'allow',
107
+ sessionRule: {
108
+ type: 'approve-all',
109
+ expiresAt: new Date(Date.now() + minutes * 60_000).toISOString(),
110
+ },
111
+ };
112
+ }
113
+ // "approve all <ToolName>" — per-tool blanket
114
+ const toolMatch = trimmed.match(/^approve\s+all\s+(\w+)$/);
115
+ if (toolMatch && toolMatch[1] !== 'for') {
116
+ return {
117
+ decision: 'allow',
118
+ sessionRule: {
119
+ type: 'approve-tool',
120
+ toolPattern: toolMatch[1],
121
+ },
122
+ };
123
+ }
124
+ // "deny all <ToolName>"
125
+ const denyToolMatch = trimmed.match(/^deny\s+all\s+(\w+)$/);
126
+ if (denyToolMatch) {
127
+ return {
128
+ decision: 'deny',
129
+ sessionRule: {
130
+ type: 'deny-tool',
131
+ toolPattern: denyToolMatch[1],
132
+ },
133
+ };
134
+ }
135
+ // "approve all" — session blanket
136
+ if (trimmed === 'approve all' || trimmed === 'allow all') {
137
+ return {
138
+ decision: 'allow',
139
+ sessionRule: { type: 'approve-all' },
140
+ };
141
+ }
142
+ // Simple allow/deny words
143
+ if (ALLOW_WORDS.has(trimmed))
144
+ return { decision: 'allow' };
145
+ if (DENY_WORDS.has(trimmed))
146
+ return { decision: 'deny' };
147
+ return null;
148
+ }
@@ -0,0 +1,51 @@
1
+ import type { CanonClient } from './client.js';
2
+ import type { ApprovalConfig, ApprovalResult, SessionRule } from './approval-types.js';
3
+ /**
4
+ * Platform-agnostic approval protocol for Canon.
5
+ *
6
+ * Manages the lifecycle of tool approval requests:
7
+ * send request → wait for reply → resolve with decision.
8
+ *
9
+ * Session rules allow the owner to blanket-approve categories of tools.
10
+ */
11
+ export declare class ApprovalManager {
12
+ private client;
13
+ private agentId;
14
+ private ownerId;
15
+ private config;
16
+ /** Pending approvals keyed by approvalId */
17
+ private pending;
18
+ /** Active session rules */
19
+ private rules;
20
+ constructor(client: CanonClient, agentId: string, ownerId: string, config?: Partial<ApprovalConfig>);
21
+ /**
22
+ * Request approval for a tool call.
23
+ *
24
+ * 1. Checks session rules — may resolve immediately.
25
+ * 2. Sends a message with approval card metadata.
26
+ * 3. Waits for the owner's reply (or timeout).
27
+ */
28
+ requestApproval(conversationId: string, toolName: string, toolInput: Record<string, unknown>, opts?: {
29
+ riskLevel?: 'normal' | 'destructive';
30
+ }): Promise<ApprovalResult>;
31
+ /**
32
+ * Feed an inbound message to the approval manager.
33
+ * Returns true if the message was consumed as an approval reply.
34
+ */
35
+ handleMessage(conversationId: string, message: {
36
+ senderId: string;
37
+ text?: string;
38
+ metadata?: Record<string, unknown>;
39
+ }): boolean;
40
+ /** Check if a tool would be auto-resolved by a session rule */
41
+ checkSessionRules(toolName: string): SessionRule | null;
42
+ getSessionRules(): SessionRule[];
43
+ clearSessionRules(): void;
44
+ get pendingCount(): number;
45
+ dispose(): void;
46
+ private resolveApproval;
47
+ private findMostRecentPending;
48
+ private pruneExpiredRules;
49
+ private summarizeTool;
50
+ private describeRule;
51
+ }
@@ -0,0 +1,225 @@
1
+ import { DEFAULT_APPROVAL_CONFIG } from './approval-types.js';
2
+ import { generateApprovalId, buildApprovalRequest, buildApprovalOutcome, parseTextApprovalReply, } from './approval-format.js';
3
+ // ── ApprovalManager ─────────────────────────────────────────────────
4
+ /**
5
+ * Platform-agnostic approval protocol for Canon.
6
+ *
7
+ * Manages the lifecycle of tool approval requests:
8
+ * send request → wait for reply → resolve with decision.
9
+ *
10
+ * Session rules allow the owner to blanket-approve categories of tools.
11
+ */
12
+ export class ApprovalManager {
13
+ client;
14
+ agentId;
15
+ ownerId;
16
+ config;
17
+ /** Pending approvals keyed by approvalId */
18
+ pending = new Map();
19
+ /** Active session rules */
20
+ rules = [];
21
+ constructor(client, agentId, ownerId, config) {
22
+ this.client = client;
23
+ this.agentId = agentId;
24
+ this.ownerId = ownerId;
25
+ this.config = { ...DEFAULT_APPROVAL_CONFIG, ...config };
26
+ }
27
+ // ── Public API ────────────────────────────────────────────────────
28
+ /**
29
+ * Request approval for a tool call.
30
+ *
31
+ * 1. Checks session rules — may resolve immediately.
32
+ * 2. Sends a message with approval card metadata.
33
+ * 3. Waits for the owner's reply (or timeout).
34
+ */
35
+ async requestApproval(conversationId, toolName, toolInput, opts) {
36
+ // Check session rules first
37
+ const matchingRule = this.checkSessionRules(toolName);
38
+ if (matchingRule) {
39
+ const decision = matchingRule.type === 'deny-tool' ? 'deny' : 'allow';
40
+ // Send silent log (fire-and-forget)
41
+ const summary = this.summarizeTool(toolName, toolInput);
42
+ const logMsg = buildApprovalOutcome('', toolName, summary, decision, 'session-rule');
43
+ this.client.sendMessage(conversationId, logMsg, {
44
+ metadata: { type: 'approval_outcome' },
45
+ }).catch(() => { });
46
+ return { decision };
47
+ }
48
+ const approvalId = generateApprovalId();
49
+ const expiresAt = new Date(Date.now() + this.config.timeoutSeconds * 1000).toISOString();
50
+ const { text, metadata } = buildApprovalRequest(approvalId, toolName, toolInput, {
51
+ riskLevel: opts?.riskLevel,
52
+ expiresAt,
53
+ redactPatterns: this.config.redactPatterns,
54
+ });
55
+ // Send the approval request message with metadata
56
+ // Spread to satisfy Record<string, unknown> without double cast
57
+ await this.client.sendMessage(conversationId, text, {
58
+ metadata: { ...metadata },
59
+ });
60
+ // Wait for reply or timeout
61
+ return new Promise((resolve) => {
62
+ const timer = setTimeout(() => {
63
+ this.pending.delete(approvalId);
64
+ const summary = this.summarizeTool(toolName, toolInput);
65
+ const msg = buildApprovalOutcome(approvalId, toolName, summary, 'deny', 'timeout');
66
+ this.client.sendMessage(conversationId, msg, {
67
+ metadata: { type: 'approval_outcome' },
68
+ }).catch(() => { });
69
+ resolve({ decision: 'deny' });
70
+ }, this.config.timeoutSeconds * 1000);
71
+ this.pending.set(approvalId, {
72
+ approvalId,
73
+ conversationId,
74
+ toolName,
75
+ toolSummary: this.summarizeTool(toolName, toolInput),
76
+ resolve,
77
+ timer,
78
+ });
79
+ });
80
+ }
81
+ /**
82
+ * Feed an inbound message to the approval manager.
83
+ * Returns true if the message was consumed as an approval reply.
84
+ */
85
+ handleMessage(conversationId, message) {
86
+ // Only accept messages from the owner
87
+ if (message.senderId !== this.ownerId)
88
+ return false;
89
+ if (this.pending.size === 0)
90
+ return false;
91
+ // Try structured metadata first (from card UI)
92
+ if (message.metadata?.type === 'approval_reply') {
93
+ const m = message.metadata;
94
+ const approvalId = m.approvalId;
95
+ const decision = m.decision;
96
+ const sessionRule = m.sessionRule;
97
+ return this.resolveApproval(approvalId, decision, sessionRule, conversationId);
98
+ }
99
+ // Fall back to text parsing
100
+ if (message.text) {
101
+ const parsed = parseTextApprovalReply(message.text);
102
+ if (!parsed)
103
+ return false;
104
+ // If the user targeted a specific approval ID
105
+ if (parsed.targetApprovalId) {
106
+ return this.resolveApproval(parsed.targetApprovalId, parsed.decision, parsed.sessionRule, conversationId);
107
+ }
108
+ // Otherwise match the most recent pending in this conversation
109
+ const pending = this.findMostRecentPending(conversationId);
110
+ if (!pending)
111
+ return false;
112
+ return this.resolveApproval(pending.approvalId, parsed.decision, parsed.sessionRule, conversationId);
113
+ }
114
+ return false;
115
+ }
116
+ /** Check if a tool would be auto-resolved by a session rule */
117
+ checkSessionRules(toolName) {
118
+ this.pruneExpiredRules();
119
+ for (const rule of this.rules) {
120
+ if (rule.type === 'approve-all')
121
+ return rule;
122
+ if ((rule.type === 'approve-tool' || rule.type === 'deny-tool') &&
123
+ rule.toolPattern) {
124
+ const re = new RegExp(`^${rule.toolPattern}$`, 'i');
125
+ if (re.test(toolName))
126
+ return rule;
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ getSessionRules() {
132
+ this.pruneExpiredRules();
133
+ return [...this.rules];
134
+ }
135
+ clearSessionRules() {
136
+ this.rules = [];
137
+ }
138
+ get pendingCount() {
139
+ return this.pending.size;
140
+ }
141
+ dispose() {
142
+ for (const p of this.pending.values()) {
143
+ clearTimeout(p.timer);
144
+ }
145
+ this.pending.clear();
146
+ this.rules = [];
147
+ }
148
+ // ── Private helpers ───────────────────────────────────────────────
149
+ resolveApproval(approvalId, decision, sessionRule, conversationId) {
150
+ const entry = this.pending.get(approvalId);
151
+ if (!entry) {
152
+ // No exact match — try most recent
153
+ if (approvalId && this.pending.size > 0) {
154
+ return false;
155
+ }
156
+ return false;
157
+ }
158
+ clearTimeout(entry.timer);
159
+ this.pending.delete(approvalId);
160
+ // Store session rule if provided
161
+ if (sessionRule) {
162
+ this.rules.push(sessionRule);
163
+ }
164
+ // Send confirmation (fire-and-forget)
165
+ const msg = buildApprovalOutcome(approvalId, entry.toolName, entry.toolSummary, decision, 'replied');
166
+ this.client.sendMessage(conversationId, msg, {
167
+ metadata: { type: 'approval_outcome' },
168
+ }).catch(() => { });
169
+ // If session rule was set, log that too
170
+ if (sessionRule) {
171
+ const ruleDesc = this.describeRule(sessionRule);
172
+ this.client
173
+ .sendMessage(conversationId, `Session rule set: ${ruleDesc}`, {
174
+ metadata: { type: 'approval_outcome' },
175
+ })
176
+ .catch(() => { });
177
+ }
178
+ entry.resolve({ decision, sessionRule });
179
+ return true;
180
+ }
181
+ findMostRecentPending(conversationId) {
182
+ let latest = null;
183
+ for (const p of this.pending.values()) {
184
+ if (p.conversationId === conversationId) {
185
+ latest = p; // Map iterates in insertion order, so last = most recent
186
+ }
187
+ }
188
+ return latest;
189
+ }
190
+ pruneExpiredRules() {
191
+ const now = Date.now();
192
+ this.rules = this.rules.filter((r) => {
193
+ if (!r.expiresAt)
194
+ return true;
195
+ return new Date(r.expiresAt).getTime() > now;
196
+ });
197
+ }
198
+ summarizeTool(toolName, toolInput) {
199
+ if (toolName === 'Bash' && typeof toolInput.command === 'string') {
200
+ return toolInput.command;
201
+ }
202
+ if ((toolName === 'Edit' || toolName === 'Write') &&
203
+ typeof toolInput.file_path === 'string') {
204
+ return toolInput.file_path;
205
+ }
206
+ const json = JSON.stringify(toolInput);
207
+ return json.length > 100 ? json.slice(0, 100) + '...' : json;
208
+ }
209
+ describeRule(rule) {
210
+ if (rule.type === 'approve-all') {
211
+ if (rule.expiresAt) {
212
+ const min = Math.round((new Date(rule.expiresAt).getTime() - Date.now()) / 60_000);
213
+ return `auto-approving all tools for ${min}m`;
214
+ }
215
+ return 'auto-approving all tools for this session';
216
+ }
217
+ if (rule.type === 'approve-tool') {
218
+ return `auto-approving all ${rule.toolPattern} commands`;
219
+ }
220
+ if (rule.type === 'deny-tool') {
221
+ return `auto-denying all ${rule.toolPattern} commands`;
222
+ }
223
+ return 'unknown rule';
224
+ }
225
+ }
@@ -0,0 +1,33 @@
1
+ export interface ApprovalRequestMetadata {
2
+ type: 'approval_request';
3
+ approvalId: string;
4
+ toolName: string;
5
+ /** Pre-computed, redacted summary — raw toolInput is never stored */
6
+ toolSummary: string;
7
+ riskLevel?: 'normal' | 'destructive';
8
+ expiresAt: string;
9
+ }
10
+ export interface ApprovalReplyMetadata {
11
+ type: 'approval_reply';
12
+ approvalId: string;
13
+ decision: 'allow' | 'deny';
14
+ sessionRule?: SessionRule;
15
+ }
16
+ export interface SessionRule {
17
+ type: 'approve-all' | 'approve-tool' | 'deny-tool';
18
+ /** Tool name pattern (regex) — only for approve-tool/deny-tool */
19
+ toolPattern?: string;
20
+ /** When this rule expires (ISO 8601), null = session lifetime */
21
+ expiresAt?: string | null;
22
+ }
23
+ export interface ApprovalResult {
24
+ decision: 'allow' | 'deny';
25
+ sessionRule?: SessionRule;
26
+ }
27
+ export interface ApprovalConfig {
28
+ enabled: boolean;
29
+ timeoutSeconds: number;
30
+ defaultOnTimeout: 'deny' | 'ask';
31
+ redactPatterns: string[];
32
+ }
33
+ export declare const DEFAULT_APPROVAL_CONFIG: ApprovalConfig;
@@ -0,0 +1,9 @@
1
+ // ── Approval request metadata (attached to agent's message) ─────────
2
+ export const DEFAULT_APPROVAL_CONFIG = {
3
+ enabled: true,
4
+ timeoutSeconds: 300,
5
+ defaultOnTimeout: 'deny',
6
+ redactPatterns: [
7
+ '(?:api[_-]?key|token|secret|password)\\s*[:=]\\s*\\S+',
8
+ ],
9
+ };