@hybridaione/hybridclaw 0.2.2 → 0.2.6

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.
Files changed (277) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +85 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +41 -16
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +71 -8
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1303 -0
  12. package/container/src/browser-tools.ts +431 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +34 -13
  15. package/container/src/index.ts +451 -109
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +20 -10
  18. package/container/src/tools.ts +599 -225
  19. package/container/src/types.ts +32 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +10 -2
  23. package/dist/agent.js.map +1 -1
  24. package/dist/audit-cli.d.ts.map +1 -1
  25. package/dist/audit-cli.js +4 -2
  26. package/dist/audit-cli.js.map +1 -1
  27. package/dist/audit-events.d.ts.map +1 -1
  28. package/dist/audit-events.js +53 -3
  29. package/dist/audit-events.js.map +1 -1
  30. package/dist/audit-trail.d.ts.map +1 -1
  31. package/dist/audit-trail.js +17 -8
  32. package/dist/audit-trail.js.map +1 -1
  33. package/dist/channels/discord/attachments.d.ts.map +1 -1
  34. package/dist/channels/discord/attachments.js +14 -7
  35. package/dist/channels/discord/attachments.js.map +1 -1
  36. package/dist/channels/discord/debounce.d.ts +9 -0
  37. package/dist/channels/discord/debounce.d.ts.map +1 -0
  38. package/dist/channels/discord/debounce.js +20 -0
  39. package/dist/channels/discord/debounce.js.map +1 -0
  40. package/dist/channels/discord/delivery.d.ts +4 -1
  41. package/dist/channels/discord/delivery.d.ts.map +1 -1
  42. package/dist/channels/discord/delivery.js +19 -3
  43. package/dist/channels/discord/delivery.js.map +1 -1
  44. package/dist/channels/discord/human-delay.d.ts +16 -0
  45. package/dist/channels/discord/human-delay.d.ts.map +1 -0
  46. package/dist/channels/discord/human-delay.js +29 -0
  47. package/dist/channels/discord/human-delay.js.map +1 -0
  48. package/dist/channels/discord/inbound.d.ts +4 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +45 -4
  51. package/dist/channels/discord/inbound.js.map +1 -1
  52. package/dist/channels/discord/mentions.d.ts.map +1 -1
  53. package/dist/channels/discord/mentions.js +16 -4
  54. package/dist/channels/discord/mentions.js.map +1 -1
  55. package/dist/channels/discord/presence.d.ts +33 -0
  56. package/dist/channels/discord/presence.d.ts.map +1 -0
  57. package/dist/channels/discord/presence.js +111 -0
  58. package/dist/channels/discord/presence.js.map +1 -0
  59. package/dist/channels/discord/rate-limiter.d.ts +14 -0
  60. package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
  61. package/dist/channels/discord/rate-limiter.js +49 -0
  62. package/dist/channels/discord/rate-limiter.js.map +1 -0
  63. package/dist/channels/discord/reactions.d.ts +38 -0
  64. package/dist/channels/discord/reactions.d.ts.map +1 -0
  65. package/dist/channels/discord/reactions.js +151 -0
  66. package/dist/channels/discord/reactions.js.map +1 -0
  67. package/dist/channels/discord/runtime.d.ts +6 -3
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +621 -125
  70. package/dist/channels/discord/runtime.js.map +1 -1
  71. package/dist/channels/discord/stream.d.ts +4 -1
  72. package/dist/channels/discord/stream.d.ts.map +1 -1
  73. package/dist/channels/discord/stream.js +16 -8
  74. package/dist/channels/discord/stream.js.map +1 -1
  75. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  76. package/dist/channels/discord/tool-actions.js +24 -12
  77. package/dist/channels/discord/tool-actions.js.map +1 -1
  78. package/dist/channels/discord/typing.d.ts +15 -0
  79. package/dist/channels/discord/typing.d.ts.map +1 -0
  80. package/dist/channels/discord/typing.js +106 -0
  81. package/dist/channels/discord/typing.js.map +1 -0
  82. package/dist/chunk.d.ts.map +1 -1
  83. package/dist/chunk.js +4 -2
  84. package/dist/chunk.js.map +1 -1
  85. package/dist/cli.js +47 -22
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +19 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +103 -18
  90. package/dist/config.js.map +1 -1
  91. package/dist/container-runner.d.ts.map +1 -1
  92. package/dist/container-runner.js +58 -26
  93. package/dist/container-runner.js.map +1 -1
  94. package/dist/container-setup.d.ts.map +1 -1
  95. package/dist/container-setup.js +10 -9
  96. package/dist/container-setup.js.map +1 -1
  97. package/dist/conversation.d.ts +2 -2
  98. package/dist/conversation.d.ts.map +1 -1
  99. package/dist/conversation.js +1 -1
  100. package/dist/conversation.js.map +1 -1
  101. package/dist/db.d.ts +118 -2
  102. package/dist/db.d.ts.map +1 -1
  103. package/dist/db.js +1568 -50
  104. package/dist/db.js.map +1 -1
  105. package/dist/delegation-manager.d.ts.map +1 -1
  106. package/dist/delegation-manager.js +3 -2
  107. package/dist/delegation-manager.js.map +1 -1
  108. package/dist/gateway-client.d.ts +2 -2
  109. package/dist/gateway-client.d.ts.map +1 -1
  110. package/dist/gateway-client.js +10 -4
  111. package/dist/gateway-client.js.map +1 -1
  112. package/dist/gateway-service.d.ts +3 -3
  113. package/dist/gateway-service.d.ts.map +1 -1
  114. package/dist/gateway-service.js +563 -73
  115. package/dist/gateway-service.js.map +1 -1
  116. package/dist/gateway-types.d.ts +24 -0
  117. package/dist/gateway-types.d.ts.map +1 -1
  118. package/dist/gateway-types.js.map +1 -1
  119. package/dist/gateway.js +179 -24
  120. package/dist/gateway.js.map +1 -1
  121. package/dist/health.d.ts.map +1 -1
  122. package/dist/health.js +20 -10
  123. package/dist/health.js.map +1 -1
  124. package/dist/heartbeat.d.ts +4 -0
  125. package/dist/heartbeat.d.ts.map +1 -1
  126. package/dist/heartbeat.js +48 -20
  127. package/dist/heartbeat.js.map +1 -1
  128. package/dist/hybridai-bots.d.ts.map +1 -1
  129. package/dist/hybridai-bots.js +4 -2
  130. package/dist/hybridai-bots.js.map +1 -1
  131. package/dist/instruction-approval-audit.d.ts.map +1 -1
  132. package/dist/instruction-approval-audit.js.map +1 -1
  133. package/dist/instruction-integrity.d.ts.map +1 -1
  134. package/dist/instruction-integrity.js +8 -2
  135. package/dist/instruction-integrity.js.map +1 -1
  136. package/dist/ipc.d.ts.map +1 -1
  137. package/dist/ipc.js +6 -1
  138. package/dist/ipc.js.map +1 -1
  139. package/dist/logger.js.map +1 -1
  140. package/dist/memory-consolidation.d.ts +17 -0
  141. package/dist/memory-consolidation.d.ts.map +1 -0
  142. package/dist/memory-consolidation.js +25 -0
  143. package/dist/memory-consolidation.js.map +1 -0
  144. package/dist/memory-service.d.ts +200 -0
  145. package/dist/memory-service.d.ts.map +1 -0
  146. package/dist/memory-service.js +294 -0
  147. package/dist/memory-service.js.map +1 -0
  148. package/dist/mount-security.d.ts.map +1 -1
  149. package/dist/mount-security.js +31 -7
  150. package/dist/mount-security.js.map +1 -1
  151. package/dist/observability-ingest.d.ts.map +1 -1
  152. package/dist/observability-ingest.js +32 -11
  153. package/dist/observability-ingest.js.map +1 -1
  154. package/dist/onboarding.d.ts.map +1 -1
  155. package/dist/onboarding.js +32 -9
  156. package/dist/onboarding.js.map +1 -1
  157. package/dist/proactive-policy.d.ts.map +1 -1
  158. package/dist/proactive-policy.js +2 -1
  159. package/dist/proactive-policy.js.map +1 -1
  160. package/dist/prompt-hooks.d.ts.map +1 -1
  161. package/dist/prompt-hooks.js +9 -7
  162. package/dist/prompt-hooks.js.map +1 -1
  163. package/dist/runtime-config.d.ts +98 -1
  164. package/dist/runtime-config.d.ts.map +1 -1
  165. package/dist/runtime-config.js +477 -23
  166. package/dist/runtime-config.js.map +1 -1
  167. package/dist/scheduled-task-runner.d.ts +1 -0
  168. package/dist/scheduled-task-runner.d.ts.map +1 -1
  169. package/dist/scheduled-task-runner.js +29 -10
  170. package/dist/scheduled-task-runner.js.map +1 -1
  171. package/dist/scheduler.d.ts +43 -4
  172. package/dist/scheduler.d.ts.map +1 -1
  173. package/dist/scheduler.js +530 -56
  174. package/dist/scheduler.js.map +1 -1
  175. package/dist/session-export.d.ts +26 -0
  176. package/dist/session-export.d.ts.map +1 -0
  177. package/dist/session-export.js +149 -0
  178. package/dist/session-export.js.map +1 -0
  179. package/dist/session-maintenance.d.ts.map +1 -1
  180. package/dist/session-maintenance.js +75 -13
  181. package/dist/session-maintenance.js.map +1 -1
  182. package/dist/session-transcripts.d.ts.map +1 -1
  183. package/dist/session-transcripts.js.map +1 -1
  184. package/dist/side-effects.d.ts.map +1 -1
  185. package/dist/side-effects.js +14 -2
  186. package/dist/side-effects.js.map +1 -1
  187. package/dist/skills-guard.d.ts.map +1 -1
  188. package/dist/skills-guard.js +893 -130
  189. package/dist/skills-guard.js.map +1 -1
  190. package/dist/skills.d.ts +5 -0
  191. package/dist/skills.d.ts.map +1 -1
  192. package/dist/skills.js +29 -15
  193. package/dist/skills.js.map +1 -1
  194. package/dist/token-efficiency.d.ts.map +1 -1
  195. package/dist/token-efficiency.js.map +1 -1
  196. package/dist/tui.js +92 -11
  197. package/dist/tui.js.map +1 -1
  198. package/dist/types.d.ts +146 -0
  199. package/dist/types.d.ts.map +1 -1
  200. package/dist/types.js +24 -1
  201. package/dist/types.js.map +1 -1
  202. package/dist/update.d.ts.map +1 -1
  203. package/dist/update.js +42 -14
  204. package/dist/update.js.map +1 -1
  205. package/dist/workspace.d.ts.map +1 -1
  206. package/dist/workspace.js +49 -9
  207. package/dist/workspace.js.map +1 -1
  208. package/docs/chat.html +9 -3
  209. package/docs/index.html +37 -13
  210. package/package.json +8 -2
  211. package/src/agent.ts +16 -3
  212. package/src/audit-cli.ts +44 -16
  213. package/src/audit-events.ts +69 -5
  214. package/src/audit-trail.ts +41 -15
  215. package/src/channels/discord/attachments.ts +81 -27
  216. package/src/channels/discord/debounce.ts +25 -0
  217. package/src/channels/discord/delivery.ts +57 -13
  218. package/src/channels/discord/human-delay.ts +48 -0
  219. package/src/channels/discord/inbound.ts +66 -7
  220. package/src/channels/discord/mentions.ts +42 -18
  221. package/src/channels/discord/presence.ts +148 -0
  222. package/src/channels/discord/rate-limiter.ts +58 -0
  223. package/src/channels/discord/reactions.ts +211 -0
  224. package/src/channels/discord/runtime.ts +1048 -182
  225. package/src/channels/discord/stream.ts +73 -27
  226. package/src/channels/discord/tool-actions.ts +78 -37
  227. package/src/channels/discord/typing.ts +140 -0
  228. package/src/chunk.ts +12 -4
  229. package/src/cli.ts +141 -56
  230. package/src/config.ts +192 -34
  231. package/src/container-runner.ts +132 -42
  232. package/src/container-setup.ts +57 -22
  233. package/src/conversation.ts +9 -7
  234. package/src/db.ts +2217 -84
  235. package/src/delegation-manager.ts +6 -2
  236. package/src/gateway-client.ts +41 -17
  237. package/src/gateway-service.ts +1019 -201
  238. package/src/gateway-types.ts +33 -0
  239. package/src/gateway.ts +321 -48
  240. package/src/health.ts +66 -26
  241. package/src/heartbeat.ts +84 -22
  242. package/src/hybridai-bots.ts +14 -5
  243. package/src/instruction-approval-audit.ts +4 -1
  244. package/src/instruction-integrity.ts +30 -9
  245. package/src/ipc.ts +23 -5
  246. package/src/logger.ts +4 -1
  247. package/src/memory-consolidation.ts +41 -0
  248. package/src/memory-service.ts +606 -0
  249. package/src/mount-security.ts +58 -13
  250. package/src/observability-ingest.ts +134 -35
  251. package/src/onboarding.ts +126 -35
  252. package/src/proactive-policy.ts +3 -1
  253. package/src/prompt-hooks.ts +40 -17
  254. package/src/runtime-config.ts +1114 -99
  255. package/src/scheduled-task-runner.ts +63 -11
  256. package/src/scheduler.ts +683 -60
  257. package/src/session-export.ts +196 -0
  258. package/src/session-maintenance.ts +125 -22
  259. package/src/session-transcripts.ts +12 -3
  260. package/src/side-effects.ts +28 -5
  261. package/src/skills-guard.ts +1067 -219
  262. package/src/skills.ts +163 -65
  263. package/src/token-efficiency.ts +31 -9
  264. package/src/tui.ts +166 -25
  265. package/src/types.ts +195 -2
  266. package/src/update.ts +79 -23
  267. package/src/workspace.ts +63 -11
  268. package/tests/approval-policy.test.ts +224 -0
  269. package/tests/discord.basic.test.ts +82 -2
  270. package/tests/discord.human-presence.test.ts +85 -0
  271. package/tests/gateway-service.media-routing.test.ts +8 -2
  272. package/tests/memory-service.test.ts +1114 -0
  273. package/tests/token-efficiency.basic.test.ts +8 -2
  274. package/vitest.e2e.config.ts +3 -1
  275. package/vitest.integration.config.ts +3 -1
  276. package/vitest.live.config.ts +3 -1
  277. package/vitest.unit.config.ts +9 -0
@@ -0,0 +1,1303 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { URL } from 'node:url';
5
+
6
+ import type { ChatMessage } from './types.js';
7
+
8
+ export type ApprovalTier = 'green' | 'yellow' | 'red';
9
+
10
+ export type ApprovalDecision =
11
+ | 'auto'
12
+ | 'implicit'
13
+ | 'approved_once'
14
+ | 'approved_session'
15
+ | 'approved_agent'
16
+ | 'promoted'
17
+ | 'required'
18
+ | 'denied';
19
+
20
+ export interface ApprovalPolicyRule {
21
+ pattern?: string;
22
+ paths?: string[];
23
+ tools?: string[];
24
+ }
25
+
26
+ export interface ApprovalPolicyConfig {
27
+ pinnedRed: ApprovalPolicyRule[];
28
+ workspaceFence: boolean;
29
+ maxPendingApprovals: number;
30
+ approvalTimeoutSecs: number;
31
+ audit: {
32
+ logAllRed: boolean;
33
+ logDenials: boolean;
34
+ };
35
+ }
36
+
37
+ interface ClassifiedAction {
38
+ tier: ApprovalTier;
39
+ actionKey: string;
40
+ intent: string;
41
+ consequenceIfDenied: string;
42
+ reason: string;
43
+ commandPreview: string;
44
+ pathHints: string[];
45
+ hostHints: string[];
46
+ writeIntent: boolean;
47
+ promotableRed: boolean;
48
+ stickyYellow: boolean;
49
+ }
50
+
51
+ interface PendingApproval {
52
+ id: string;
53
+ fingerprint: string;
54
+ actionKey: string;
55
+ toolName: string;
56
+ intent: string;
57
+ consequenceIfDenied: string;
58
+ reason: string;
59
+ commandPreview: string;
60
+ createdAtMs: number;
61
+ expiresAtMs: number;
62
+ originalPrompt: string;
63
+ pinned: boolean;
64
+ }
65
+
66
+ export interface ApprovalPrelude {
67
+ immediateMessage?: string;
68
+ replayPrompt?: string;
69
+ approvalMode?: 'once' | 'session' | 'agent';
70
+ approvedRequestId?: string;
71
+ }
72
+
73
+ export interface ToolApprovalEvaluation {
74
+ baseTier: ApprovalTier;
75
+ tier: ApprovalTier;
76
+ decision: ApprovalDecision;
77
+ actionKey: string;
78
+ fingerprint: string;
79
+ requestId?: string;
80
+ intent: string;
81
+ consequenceIfDenied: string;
82
+ reason: string;
83
+ commandPreview: string;
84
+ pinned: boolean;
85
+ implicitDelayMs?: number;
86
+ hostHints: string[];
87
+ }
88
+
89
+ const POLICY_PATH = path.posix.join('/workspace', '.hybridclaw', 'policy.yaml');
90
+ const TRUST_STORE_PATH = path.posix.join(
91
+ '/workspace',
92
+ '.hybridclaw',
93
+ 'approval-trust.json',
94
+ );
95
+ const YELLOW_IMPLICIT_DELAY_MS = 5_000;
96
+ const YELLOW_IMPLICIT_DELAY_SECS = Math.max(
97
+ 1,
98
+ Math.round(YELLOW_IMPLICIT_DELAY_MS / 1_000),
99
+ );
100
+ const MAX_PROMPT_CHARS = 1_200;
101
+ const MAX_COMMAND_PREVIEW_CHARS = 160;
102
+
103
+ const DEFAULT_POLICY: ApprovalPolicyConfig = {
104
+ pinnedRed: [
105
+ { pattern: 'rm\\s+-rf\\s+/' },
106
+ { paths: ['~/.ssh/**', '/etc/**', '.env*'] },
107
+ { tools: ['force_push'] },
108
+ ],
109
+ workspaceFence: true,
110
+ maxPendingApprovals: 3,
111
+ approvalTimeoutSecs: 120,
112
+ audit: {
113
+ logAllRed: true,
114
+ logDenials: true,
115
+ },
116
+ };
117
+
118
+ const CRITICAL_BASH_RE =
119
+ /\b(sudo|mkfs(?:\.[a-z0-9_+-]+)?|shutdown|reboot|poweroff)\b|:\(\)\s*\{.*\};\s*:|\bchmod\s+777\b|\bcurl\b[^\n|]*\|\s*(sh|bash|zsh)\b|\bwget\b[^\n|]*\|\s*(sh|bash|zsh)\b/i;
120
+ const FORCE_PUSH_RE = /\bgit\s+push\s+--force(?:-with-lease)?\b/i;
121
+ const DELETE_RE = /\brm\s+-[^\n;|&]*\b|\bfind\b[^\n]*\s-delete\b/i;
122
+ const WRITE_INTENT_RE =
123
+ /\b(mkdir|touch|mv|cp|chmod|chown|tee)\b|(^|[^>])>>?[^>]|sed\s+-i|perl\s+-pi/i;
124
+ const INSTALL_RE = /\b(npm|pnpm|yarn|bun)\s+(install|add)\b/i;
125
+ const GIT_WRITE_RE =
126
+ /\bgit\s+(add|commit|checkout\s+-b|branch|merge|rebase|tag)\b/i;
127
+ const UNKNOWN_SCRIPT_RE =
128
+ /(^|\s)(\.[/\\][^\s]+|bash\s+[^\s]+\.sh|zsh\s+[^\s]+\.sh|sh\s+[^\s]+\.sh)(\s|$)/i;
129
+ const READ_ONLY_BASH_RE =
130
+ /^\s*(ls|pwd|cat|head|tail|wc|rg|grep|find|git\s+(status|log|diff|show)|npm\s+test|pnpm\s+test|yarn\s+test|vitest|pytest|phpunit|node\s+--version|npm\s+--version|pnpm\s+--version|yarn\s+--version)\b/i;
131
+ const NETWORK_COMMAND_RE = /\b(curl|wget|http|https|ssh|scp)\b/i;
132
+ const ABS_PATH_RE = /(^|\s)(\/[^\s"'`;,|&()<>]+)/g;
133
+ const URL_RE = /https?:\/\/[^\s"'`<>]+/gi;
134
+ const HOST_RE =
135
+ /\b(?:ssh|scp)\s+[^\s@]*@?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(?::\S+)?/g;
136
+ const APPROVE_RE =
137
+ /^(?:\/?(?:approve|yes|y))(?:\s+([a-f0-9-]{6,64}))?(?:\s+(for\s+session|session|always|for\s+agent|agent))?$/i;
138
+ const DENY_RE = /^(?:\/?(?:deny|reject|skip|no|n))(?:\s+([a-f0-9-]{6,64}))?$/i;
139
+ const MENU_SELECTION_RE = /^([1-4])(?:\s+([a-f0-9-]{6,64}))?$/i;
140
+
141
+ function normalizeText(value: unknown): string {
142
+ return String(value || '')
143
+ .replace(/\s+/g, ' ')
144
+ .trim();
145
+ }
146
+
147
+ function normalizePrompt(value: string): string {
148
+ return normalizeText(value).slice(0, MAX_PROMPT_CHARS);
149
+ }
150
+
151
+ function normalizePreview(value: string): string {
152
+ const clean = normalizeText(value);
153
+ if (!clean) return '(no command preview)';
154
+ return clean.length > MAX_COMMAND_PREVIEW_CHARS
155
+ ? `${clean.slice(0, MAX_COMMAND_PREVIEW_CHARS - 1)}...`
156
+ : clean;
157
+ }
158
+
159
+ function stableHash(input: string): string {
160
+ return createHash('sha256').update(input).digest('hex').slice(0, 16);
161
+ }
162
+
163
+ function parseJsonObject(raw: string): Record<string, unknown> {
164
+ try {
165
+ const parsed = JSON.parse(raw) as unknown;
166
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
167
+ return parsed as Record<string, unknown>;
168
+ }
169
+ } catch {
170
+ // ignore
171
+ }
172
+ return {};
173
+ }
174
+
175
+ function parseInlineList(raw: string): string[] {
176
+ const trimmed = raw.trim();
177
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return [];
178
+ const inner = trimmed.slice(1, -1).trim();
179
+ if (!inner) return [];
180
+ return inner
181
+ .split(',')
182
+ .map((item) => item.trim().replace(/^['"]|['"]$/g, ''))
183
+ .filter(Boolean);
184
+ }
185
+
186
+ function parseBool(raw: string | undefined, fallback: boolean): boolean {
187
+ if (!raw) return fallback;
188
+ const normalized = raw.trim().toLowerCase();
189
+ if (normalized === 'true') return true;
190
+ if (normalized === 'false') return false;
191
+ return fallback;
192
+ }
193
+
194
+ function parseIntStrict(raw: string | undefined, fallback: number): number {
195
+ if (!raw) return fallback;
196
+ const parsed = Number.parseInt(raw.trim(), 10);
197
+ return Number.isFinite(parsed) ? parsed : fallback;
198
+ }
199
+
200
+ function globPatternToRegExp(pattern: string): RegExp {
201
+ const escaped = pattern
202
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
203
+ .replace(/\*\*/g, '::DOUBLE_STAR::')
204
+ .replace(/\*/g, '[^/]*')
205
+ .replace(/::DOUBLE_STAR::/g, '.*');
206
+ return new RegExp(`^${escaped}$`, 'i');
207
+ }
208
+
209
+ function normalizePathValue(rawPath: string): string {
210
+ const value = rawPath.trim().replace(/\\/g, '/');
211
+ const withoutWorkspace = value.startsWith('/workspace/')
212
+ ? value.slice('/workspace/'.length)
213
+ : value;
214
+ return withoutWorkspace.replace(/^\.\/+/, '').replace(/^\/+/, '');
215
+ }
216
+
217
+ function matchesPathPattern(candidatePath: string, pattern: string): boolean {
218
+ const normalizedCandidate = normalizePathValue(candidatePath);
219
+ const normalizedPattern = pattern.trim().replace(/\\/g, '/');
220
+ if (!normalizedPattern) return false;
221
+
222
+ // Relative patterns (e.g. ".env*") should match both root and any nested path.
223
+ if (
224
+ !normalizedPattern.startsWith('/') &&
225
+ !normalizedPattern.startsWith('~/')
226
+ ) {
227
+ const relRe = globPatternToRegExp(normalizedPattern.replace(/^\.\//, ''));
228
+ if (relRe.test(normalizedCandidate)) return true;
229
+ const basename = path.posix.basename(normalizedCandidate);
230
+ if (relRe.test(basename)) return true;
231
+ return false;
232
+ }
233
+
234
+ const absoluteCandidate = candidatePath.trim().replace(/\\/g, '/');
235
+ const absoluteRe = globPatternToRegExp(normalizedPattern);
236
+ return absoluteRe.test(absoluteCandidate);
237
+ }
238
+
239
+ function parsePolicyYaml(raw: string): Partial<ApprovalPolicyConfig> {
240
+ const policy: Partial<ApprovalPolicyConfig> = {};
241
+ const lines = raw.split(/\r?\n/);
242
+
243
+ let section: 'approval' | 'audit' | '' = '';
244
+ let inPinnedRed = false;
245
+ let pinnedRuleIndent = -1;
246
+ let currentRule: ApprovalPolicyRule | null = null;
247
+ let currentListKey: 'tools' | 'paths' | null = null;
248
+ const pinnedRules: ApprovalPolicyRule[] = [];
249
+
250
+ const flushRule = (): void => {
251
+ if (!currentRule) return;
252
+ const hasContent = Boolean(
253
+ currentRule.pattern?.trim() ||
254
+ (Array.isArray(currentRule.paths) && currentRule.paths.length > 0) ||
255
+ (Array.isArray(currentRule.tools) && currentRule.tools.length > 0),
256
+ );
257
+ if (hasContent) pinnedRules.push(currentRule);
258
+ currentRule = null;
259
+ currentListKey = null;
260
+ };
261
+
262
+ const applyRuleField = (
263
+ rule: ApprovalPolicyRule,
264
+ key: string,
265
+ rawValue: string,
266
+ ): void => {
267
+ const value = rawValue.trim();
268
+ if (key === 'pattern') {
269
+ rule.pattern = value.replace(/^['"]|['"]$/g, '');
270
+ return;
271
+ }
272
+ if (key === 'tools' || key === 'paths') {
273
+ const parsed = parseInlineList(value);
274
+ if (parsed.length > 0) {
275
+ if (key === 'tools') rule.tools = parsed;
276
+ if (key === 'paths') rule.paths = parsed;
277
+ currentListKey = null;
278
+ } else {
279
+ if (key === 'tools') rule.tools = [];
280
+ if (key === 'paths') rule.paths = [];
281
+ currentListKey = key;
282
+ }
283
+ }
284
+ };
285
+
286
+ for (const rawLine of lines) {
287
+ const noComment = rawLine.replace(/\s+#.*$/, '');
288
+ if (!noComment.trim()) continue;
289
+ const indent = noComment.match(/^ */)?.[0].length || 0;
290
+ const line = noComment.trim();
291
+
292
+ if (line === 'approval:') {
293
+ flushRule();
294
+ section = 'approval';
295
+ inPinnedRed = false;
296
+ continue;
297
+ }
298
+ if (line === 'audit:') {
299
+ flushRule();
300
+ section = 'audit';
301
+ inPinnedRed = false;
302
+ continue;
303
+ }
304
+ if (section === '' && line.endsWith(':')) continue;
305
+
306
+ if (section === 'approval') {
307
+ if (line === 'pinned_red:') {
308
+ flushRule();
309
+ inPinnedRed = true;
310
+ pinnedRuleIndent = -1;
311
+ continue;
312
+ }
313
+ if (inPinnedRed && line.startsWith('-')) {
314
+ if (pinnedRuleIndent < 0) pinnedRuleIndent = indent;
315
+ if (indent <= pinnedRuleIndent) {
316
+ flushRule();
317
+ currentRule = {};
318
+ const rest = line.slice(1).trim();
319
+ if (!rest) continue;
320
+ const kv = rest.match(/^([a-zA-Z_]+)\s*:\s*(.*)$/);
321
+ if (!kv) continue;
322
+ applyRuleField(currentRule, kv[1], kv[2]);
323
+ continue;
324
+ }
325
+ }
326
+ if (inPinnedRed && currentRule) {
327
+ if (line.startsWith('-') && currentListKey) {
328
+ const item = line
329
+ .slice(1)
330
+ .trim()
331
+ .replace(/^['"]|['"]$/g, '');
332
+ if (item) {
333
+ if (currentListKey === 'tools') {
334
+ currentRule.tools = [...(currentRule.tools || []), item];
335
+ } else {
336
+ currentRule.paths = [...(currentRule.paths || []), item];
337
+ }
338
+ }
339
+ continue;
340
+ }
341
+ const kv = line.match(/^([a-zA-Z_]+)\s*:\s*(.*)$/);
342
+ if (kv) {
343
+ applyRuleField(currentRule, kv[1], kv[2]);
344
+ continue;
345
+ }
346
+ }
347
+ if (inPinnedRed && indent <= pinnedRuleIndent && !line.startsWith('-')) {
348
+ flushRule();
349
+ inPinnedRed = false;
350
+ }
351
+ const simpleKv = line.match(/^([a-zA-Z_]+)\s*:\s*(.*)$/);
352
+ if (!simpleKv) continue;
353
+ const [, key, rawValue] = simpleKv;
354
+ if (key === 'workspace_fence') {
355
+ policy.workspaceFence = parseBool(
356
+ rawValue,
357
+ DEFAULT_POLICY.workspaceFence,
358
+ );
359
+ } else if (key === 'max_pending_approvals') {
360
+ policy.maxPendingApprovals = Math.max(
361
+ 1,
362
+ parseIntStrict(rawValue, DEFAULT_POLICY.maxPendingApprovals),
363
+ );
364
+ } else if (key === 'approval_timeout_secs') {
365
+ policy.approvalTimeoutSecs = Math.max(
366
+ 5,
367
+ parseIntStrict(rawValue, DEFAULT_POLICY.approvalTimeoutSecs),
368
+ );
369
+ }
370
+ continue;
371
+ }
372
+
373
+ if (section === 'audit') {
374
+ const kv = line.match(/^([a-zA-Z_]+)\s*:\s*(.*)$/);
375
+ if (!kv) continue;
376
+ const [, key, rawValue] = kv;
377
+ const audit = policy.audit || {
378
+ logAllRed: DEFAULT_POLICY.audit.logAllRed,
379
+ logDenials: DEFAULT_POLICY.audit.logDenials,
380
+ };
381
+ if (key === 'log_all_red') {
382
+ audit.logAllRed = parseBool(rawValue, DEFAULT_POLICY.audit.logAllRed);
383
+ } else if (key === 'log_denials') {
384
+ audit.logDenials = parseBool(rawValue, DEFAULT_POLICY.audit.logDenials);
385
+ }
386
+ policy.audit = audit;
387
+ }
388
+ }
389
+
390
+ flushRule();
391
+ if (pinnedRules.length > 0) policy.pinnedRed = pinnedRules;
392
+ return policy;
393
+ }
394
+
395
+ function loadPolicyFromDisk(policyPath: string): ApprovalPolicyConfig {
396
+ let filePolicy: Partial<ApprovalPolicyConfig> = {};
397
+ try {
398
+ if (fs.existsSync(policyPath)) {
399
+ const raw = fs.readFileSync(policyPath, 'utf-8');
400
+ filePolicy = parsePolicyYaml(raw);
401
+ }
402
+ } catch {
403
+ filePolicy = {};
404
+ }
405
+
406
+ return {
407
+ pinnedRed:
408
+ Array.isArray(filePolicy.pinnedRed) && filePolicy.pinnedRed.length > 0
409
+ ? filePolicy.pinnedRed
410
+ : DEFAULT_POLICY.pinnedRed,
411
+ workspaceFence:
412
+ typeof filePolicy.workspaceFence === 'boolean'
413
+ ? filePolicy.workspaceFence
414
+ : DEFAULT_POLICY.workspaceFence,
415
+ maxPendingApprovals:
416
+ typeof filePolicy.maxPendingApprovals === 'number'
417
+ ? Math.max(1, filePolicy.maxPendingApprovals)
418
+ : DEFAULT_POLICY.maxPendingApprovals,
419
+ approvalTimeoutSecs:
420
+ typeof filePolicy.approvalTimeoutSecs === 'number'
421
+ ? Math.max(5, filePolicy.approvalTimeoutSecs)
422
+ : DEFAULT_POLICY.approvalTimeoutSecs,
423
+ audit: {
424
+ logAllRed:
425
+ typeof filePolicy.audit?.logAllRed === 'boolean'
426
+ ? filePolicy.audit.logAllRed
427
+ : DEFAULT_POLICY.audit.logAllRed,
428
+ logDenials:
429
+ typeof filePolicy.audit?.logDenials === 'boolean'
430
+ ? filePolicy.audit.logDenials
431
+ : DEFAULT_POLICY.audit.logDenials,
432
+ },
433
+ };
434
+ }
435
+
436
+ function latestUserMessageText(messages: ChatMessage[]): string {
437
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
438
+ if (messages[i].role !== 'user') continue;
439
+ const content = messages[i].content;
440
+ if (typeof content === 'string') return normalizePrompt(content);
441
+ if (!Array.isArray(content)) continue;
442
+ const textParts: string[] = [];
443
+ for (const part of content) {
444
+ if (!part || typeof part !== 'object') continue;
445
+ if (part.type !== 'text') continue;
446
+ if (typeof part.text !== 'string') continue;
447
+ const trimmed = part.text.trim();
448
+ if (trimmed) textParts.push(trimmed);
449
+ }
450
+ if (textParts.length > 0) {
451
+ return normalizePrompt(textParts.join('\n'));
452
+ }
453
+ }
454
+ return '';
455
+ }
456
+
457
+ function extractHostsFromUrlLikeText(input: string): string[] {
458
+ const hosts = new Set<string>();
459
+ for (const match of input.matchAll(URL_RE)) {
460
+ const raw = match[0];
461
+ try {
462
+ const parsed = new URL(raw);
463
+ if (parsed.hostname) hosts.add(parsed.hostname.toLowerCase());
464
+ } catch {
465
+ // ignore
466
+ }
467
+ }
468
+ for (const match of input.matchAll(HOST_RE)) {
469
+ const host = String(match[1] || '')
470
+ .trim()
471
+ .toLowerCase();
472
+ if (host) hosts.add(host);
473
+ }
474
+ return [...hosts];
475
+ }
476
+
477
+ function extractAbsolutePaths(input: string): string[] {
478
+ const paths = new Set<string>();
479
+ for (const match of input.matchAll(ABS_PATH_RE)) {
480
+ const candidate = String(match[2] || '').trim();
481
+ if (!candidate) continue;
482
+ paths.add(candidate);
483
+ }
484
+ return [...paths];
485
+ }
486
+
487
+ function primaryPathKey(rawPath: string): string {
488
+ const normalized = normalizePathValue(rawPath);
489
+ if (!normalized) return 'root';
490
+ const [first] = normalized.split('/');
491
+ return first || 'root';
492
+ }
493
+
494
+ function parseModeFromApproveMatch(
495
+ match: RegExpMatchArray | null,
496
+ ): 'once' | 'session' | 'agent' {
497
+ const scope = String(match?.[2] || '').toLowerCase();
498
+ if (scope.includes('agent')) return 'agent';
499
+ if (scope.includes('session') || scope.includes('always')) return 'session';
500
+ return 'once';
501
+ }
502
+
503
+ function parseApprovalUserResponse(input: string): {
504
+ kind: 'approve' | 'deny';
505
+ mode?: 'once' | 'session' | 'agent';
506
+ requestId: string;
507
+ } | null {
508
+ const menuMatch = input.match(MENU_SELECTION_RE);
509
+ if (menuMatch) {
510
+ const requestId = String(menuMatch[2] || '').trim();
511
+ const selection = menuMatch[1];
512
+ if (selection === '1') return { kind: 'approve', mode: 'once', requestId };
513
+ if (selection === '2')
514
+ return { kind: 'approve', mode: 'session', requestId };
515
+ if (selection === '3') return { kind: 'approve', mode: 'agent', requestId };
516
+ return { kind: 'deny', requestId };
517
+ }
518
+
519
+ const approveMatch = input.match(APPROVE_RE);
520
+ if (approveMatch) {
521
+ return {
522
+ kind: 'approve',
523
+ mode: parseModeFromApproveMatch(approveMatch),
524
+ requestId: String(approveMatch[1] || '').trim(),
525
+ };
526
+ }
527
+
528
+ const denyMatch = input.match(DENY_RE);
529
+ if (denyMatch) {
530
+ return {
531
+ kind: 'deny',
532
+ requestId: String(denyMatch[1] || '').trim(),
533
+ };
534
+ }
535
+
536
+ return null;
537
+ }
538
+
539
+ interface PersistedApprovalTrustStore {
540
+ version: 1;
541
+ trustedActions: string[];
542
+ trustedFingerprints: string[];
543
+ updatedAt: string;
544
+ }
545
+
546
+ function parsePersistedTrustStore(
547
+ raw: string,
548
+ ): PersistedApprovalTrustStore | null {
549
+ try {
550
+ const parsed = JSON.parse(raw) as unknown;
551
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
552
+ return null;
553
+ const record = parsed as Record<string, unknown>;
554
+ const trustedActions = Array.isArray(record.trustedActions)
555
+ ? record.trustedActions
556
+ .map((value) => String(value || '').trim())
557
+ .filter(Boolean)
558
+ : [];
559
+ const trustedFingerprints = Array.isArray(record.trustedFingerprints)
560
+ ? record.trustedFingerprints
561
+ .map((value) => String(value || '').trim())
562
+ .filter(Boolean)
563
+ : [];
564
+ return {
565
+ version: 1,
566
+ trustedActions,
567
+ trustedFingerprints,
568
+ updatedAt:
569
+ typeof record.updatedAt === 'string'
570
+ ? record.updatedAt
571
+ : new Date().toISOString(),
572
+ };
573
+ } catch {
574
+ return null;
575
+ }
576
+ }
577
+
578
+ export class TrustedCoworkerApprovalRuntime {
579
+ private readonly policyPath: string;
580
+ private readonly trustStorePath: string;
581
+ private loadedPolicy: ApprovalPolicyConfig = DEFAULT_POLICY;
582
+ private policyMtimeMs = -1;
583
+ private readonly pending = new Map<string, PendingApproval>();
584
+ private readonly actionExecutionCounts = new Map<string, number>();
585
+ private readonly explicitApprovalCounts = new Map<string, number>();
586
+ private readonly oneShotFingerprints = new Set<string>();
587
+ private readonly sessionTrustedActions = new Set<string>();
588
+ private readonly agentTrustedActions = new Set<string>();
589
+ private readonly agentTrustedFingerprints = new Set<string>();
590
+ private readonly seenNetworkHosts = new Set<string>();
591
+
592
+ constructor(policyPath = POLICY_PATH, trustStorePath = TRUST_STORE_PATH) {
593
+ this.policyPath = policyPath;
594
+ this.trustStorePath = trustStorePath;
595
+ this.reloadPolicyIfNeeded(true);
596
+ this.loadAgentTrustStore();
597
+ }
598
+
599
+ reloadPolicyIfNeeded(force = false): ApprovalPolicyConfig {
600
+ let mtimeMs = -1;
601
+ try {
602
+ if (fs.existsSync(this.policyPath)) {
603
+ mtimeMs = fs.statSync(this.policyPath).mtimeMs;
604
+ }
605
+ } catch {
606
+ mtimeMs = -1;
607
+ }
608
+ if (force || mtimeMs !== this.policyMtimeMs) {
609
+ this.loadedPolicy = loadPolicyFromDisk(this.policyPath);
610
+ this.policyMtimeMs = mtimeMs;
611
+ }
612
+ return this.loadedPolicy;
613
+ }
614
+
615
+ private loadAgentTrustStore(): void {
616
+ this.agentTrustedActions.clear();
617
+ this.agentTrustedFingerprints.clear();
618
+ try {
619
+ if (!fs.existsSync(this.trustStorePath)) return;
620
+ const raw = fs.readFileSync(this.trustStorePath, 'utf-8');
621
+ const parsed = parsePersistedTrustStore(raw);
622
+ if (!parsed) return;
623
+ for (const actionKey of parsed.trustedActions) {
624
+ this.agentTrustedActions.add(actionKey);
625
+ }
626
+ for (const fingerprint of parsed.trustedFingerprints) {
627
+ this.agentTrustedFingerprints.add(fingerprint);
628
+ }
629
+ } catch {
630
+ // ignore malformed trust state; session trust still applies
631
+ }
632
+ }
633
+
634
+ private persistAgentTrustStore(): void {
635
+ const payload: PersistedApprovalTrustStore = {
636
+ version: 1,
637
+ trustedActions: [...this.agentTrustedActions].sort(),
638
+ trustedFingerprints: [...this.agentTrustedFingerprints].sort(),
639
+ updatedAt: new Date().toISOString(),
640
+ };
641
+ try {
642
+ const dir = path.posix.dirname(this.trustStorePath);
643
+ fs.mkdirSync(dir, { recursive: true });
644
+ const tmpPath = `${this.trustStorePath}.tmp`;
645
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
646
+ fs.renameSync(tmpPath, this.trustStorePath);
647
+ } catch {
648
+ // ignore persistence failures and continue with in-memory trust
649
+ }
650
+ }
651
+
652
+ handleApprovalResponse(messages: ChatMessage[]): ApprovalPrelude | null {
653
+ this.reloadPolicyIfNeeded();
654
+ this.cleanupExpiredPending();
655
+
656
+ const latest = latestUserMessageText(messages);
657
+ if (!latest) return null;
658
+
659
+ const parsedResponse = parseApprovalUserResponse(latest);
660
+ if (!parsedResponse) return null;
661
+ if (this.pending.size === 0) {
662
+ return {
663
+ immediateMessage: 'There is no pending approval request right now.',
664
+ };
665
+ }
666
+
667
+ const target = this.resolvePendingTarget(parsedResponse.requestId);
668
+ if (!target) {
669
+ return {
670
+ immediateMessage: `No pending approval found for id "${parsedResponse.requestId}".`,
671
+ };
672
+ }
673
+
674
+ if (parsedResponse.kind === 'deny') {
675
+ this.pending.delete(target.id);
676
+ return {
677
+ immediateMessage: `Skipped \`${target.intent}\`. I will continue without that action.`,
678
+ };
679
+ }
680
+
681
+ const requestedMode = parsedResponse.mode || 'once';
682
+ let mode: 'once' | 'session' | 'agent' = requestedMode;
683
+ this.pending.delete(target.id);
684
+ if (requestedMode === 'session') {
685
+ if (target.pinned) {
686
+ // Pinned-red actions are never session-trusted. Approve only this single run.
687
+ this.oneShotFingerprints.add(target.fingerprint);
688
+ mode = 'once';
689
+ } else {
690
+ this.sessionTrustedActions.add(target.actionKey);
691
+ this.sessionTrustedActions.add(target.fingerprint);
692
+ }
693
+ } else if (requestedMode === 'agent') {
694
+ if (target.pinned) {
695
+ // Pinned-red actions are never promoted to durable trust.
696
+ this.oneShotFingerprints.add(target.fingerprint);
697
+ mode = 'once';
698
+ } else {
699
+ this.agentTrustedActions.add(target.actionKey);
700
+ this.agentTrustedFingerprints.add(target.fingerprint);
701
+ this.persistAgentTrustStore();
702
+ }
703
+ } else {
704
+ this.oneShotFingerprints.add(target.fingerprint);
705
+ }
706
+ this.bumpCount(this.explicitApprovalCounts, target.actionKey);
707
+
708
+ const modeSummary =
709
+ mode === 'session'
710
+ ? 'session trust'
711
+ : mode === 'agent'
712
+ ? 'agent trust'
713
+ : 'once';
714
+ const replayPrompt = normalizePrompt(target.originalPrompt);
715
+ return {
716
+ replayPrompt: replayPrompt || undefined,
717
+ approvalMode: mode,
718
+ approvedRequestId: target.id,
719
+ immediateMessage: replayPrompt
720
+ ? undefined
721
+ : `Approved \`${target.intent}\` (${modeSummary}).`,
722
+ };
723
+ }
724
+
725
+ evaluateToolCall(params: {
726
+ toolName: string;
727
+ argsJson: string;
728
+ latestUserPrompt: string;
729
+ }): ToolApprovalEvaluation {
730
+ this.reloadPolicyIfNeeded();
731
+ this.cleanupExpiredPending();
732
+ const args = parseJsonObject(params.argsJson);
733
+ const classified = this.classifyAction(params.toolName, args);
734
+
735
+ const fingerprint = stableHash(
736
+ [
737
+ params.toolName,
738
+ classified.actionKey,
739
+ normalizePreview(classified.commandPreview),
740
+ normalizeText(JSON.stringify(args)),
741
+ ].join('|'),
742
+ );
743
+
744
+ const pinnedByPolicy = this.isPinnedRed({
745
+ toolName: params.toolName,
746
+ preview: classified.commandPreview,
747
+ pathHints: classified.pathHints,
748
+ args,
749
+ });
750
+ const baseTier: ApprovalTier =
751
+ pinnedByPolicy || classified.tier === 'red' ? 'red' : classified.tier;
752
+
753
+ let tier: ApprovalTier = baseTier;
754
+ let decision: ApprovalDecision = 'auto';
755
+
756
+ if (baseTier === 'red') {
757
+ const oneShotApproved = this.oneShotFingerprints.has(fingerprint);
758
+ const sessionApproved =
759
+ !pinnedByPolicy &&
760
+ (this.sessionTrustedActions.has(classified.actionKey) ||
761
+ this.sessionTrustedActions.has(fingerprint));
762
+ const agentApproved =
763
+ !pinnedByPolicy &&
764
+ (this.agentTrustedActions.has(classified.actionKey) ||
765
+ this.agentTrustedFingerprints.has(fingerprint));
766
+ const promotable =
767
+ !pinnedByPolicy &&
768
+ classified.promotableRed &&
769
+ (this.explicitApprovalCounts.get(classified.actionKey) || 0) > 0;
770
+
771
+ if (oneShotApproved) {
772
+ this.oneShotFingerprints.delete(fingerprint);
773
+ tier = pinnedByPolicy ? 'red' : 'yellow';
774
+ decision = 'approved_once';
775
+ } else if (sessionApproved) {
776
+ tier = 'yellow';
777
+ decision = 'approved_session';
778
+ } else if (agentApproved) {
779
+ tier = 'yellow';
780
+ decision = 'approved_agent';
781
+ } else if (promotable) {
782
+ tier = 'yellow';
783
+ decision = 'promoted';
784
+ } else {
785
+ if (this.pending.size >= this.loadedPolicy.maxPendingApprovals) {
786
+ return {
787
+ baseTier,
788
+ tier: 'red',
789
+ decision: 'denied',
790
+ actionKey: classified.actionKey,
791
+ fingerprint,
792
+ intent: classified.intent,
793
+ consequenceIfDenied:
794
+ 'If this is denied, I will continue with non-destructive alternatives only.',
795
+ reason: `Approval queue is full (${this.loadedPolicy.maxPendingApprovals} pending).`,
796
+ commandPreview: classified.commandPreview,
797
+ pinned: pinnedByPolicy,
798
+ hostHints: classified.hostHints,
799
+ };
800
+ }
801
+ const request = this.getOrCreatePending({
802
+ fingerprint,
803
+ actionKey: classified.actionKey,
804
+ toolName: params.toolName,
805
+ intent: classified.intent,
806
+ consequenceIfDenied: classified.consequenceIfDenied,
807
+ reason: classified.reason,
808
+ commandPreview: classified.commandPreview,
809
+ originalPrompt: params.latestUserPrompt,
810
+ pinned: pinnedByPolicy,
811
+ });
812
+ return {
813
+ baseTier,
814
+ tier: 'red',
815
+ decision: 'required',
816
+ actionKey: classified.actionKey,
817
+ fingerprint,
818
+ requestId: request.id,
819
+ intent: classified.intent,
820
+ consequenceIfDenied: classified.consequenceIfDenied,
821
+ reason: classified.reason,
822
+ commandPreview: classified.commandPreview,
823
+ pinned: pinnedByPolicy,
824
+ hostHints: classified.hostHints,
825
+ };
826
+ }
827
+ }
828
+
829
+ if (tier === 'yellow') {
830
+ const executions =
831
+ this.actionExecutionCounts.get(classified.actionKey) || 0;
832
+ if (!classified.stickyYellow && executions >= 1) {
833
+ tier = 'green';
834
+ if (decision === 'auto') decision = 'promoted';
835
+ } else if (decision === 'auto') {
836
+ decision = 'implicit';
837
+ }
838
+ }
839
+
840
+ if (tier === 'green' && decision === 'auto') {
841
+ decision = 'auto';
842
+ }
843
+
844
+ return {
845
+ baseTier,
846
+ tier,
847
+ decision,
848
+ actionKey: classified.actionKey,
849
+ fingerprint,
850
+ intent: classified.intent,
851
+ consequenceIfDenied: classified.consequenceIfDenied,
852
+ reason: classified.reason,
853
+ commandPreview: classified.commandPreview,
854
+ pinned: pinnedByPolicy,
855
+ implicitDelayMs: tier === 'yellow' ? YELLOW_IMPLICIT_DELAY_MS : undefined,
856
+ hostHints: classified.hostHints,
857
+ };
858
+ }
859
+
860
+ afterToolExecution(
861
+ evaluation: ToolApprovalEvaluation,
862
+ succeeded: boolean,
863
+ ): void {
864
+ if (!succeeded) return;
865
+ this.bumpCount(this.actionExecutionCounts, evaluation.actionKey);
866
+ if (evaluation.hostHints.length > 0) {
867
+ for (const host of evaluation.hostHints) {
868
+ this.seenNetworkHosts.add(host.toLowerCase());
869
+ }
870
+ }
871
+ }
872
+
873
+ formatYellowNarration(evaluation: ToolApprovalEvaluation): string {
874
+ return `${evaluation.intent}. Waiting ${YELLOW_IMPLICIT_DELAY_SECS}s for interruption before running.`;
875
+ }
876
+
877
+ formatApprovalRequest(evaluation: ToolApprovalEvaluation): string {
878
+ const expiresIn = this.loadedPolicy.approvalTimeoutSecs;
879
+ const requestLabel = evaluation.requestId
880
+ ? `Approval ID: ${evaluation.requestId}`
881
+ : '';
882
+ const trustHint = evaluation.pinned
883
+ ? 'This action is pinned sensitive, so session/agent trust is disabled.'
884
+ : 'Reply `yes for session` (or `2`) to trust this action for this session, or `yes for agent` (or `3`) to trust it for this agent.';
885
+ return [
886
+ `I need your approval before I ${evaluation.intent.toLowerCase()}.`,
887
+ `Why: ${evaluation.reason}`,
888
+ `If you skip this, ${evaluation.consequenceIfDenied.charAt(0).toLowerCase()}${evaluation.consequenceIfDenied.slice(1)}`,
889
+ requestLabel,
890
+ `Reply \`yes\` (or \`1\`) to approve once, or \`no\` (or \`4\`) to deny.`,
891
+ trustHint,
892
+ `Approval expires in ${expiresIn}s.`,
893
+ ]
894
+ .filter(Boolean)
895
+ .join('\n');
896
+ }
897
+
898
+ private getOrCreatePending(
899
+ input: Omit<PendingApproval, 'id' | 'createdAtMs' | 'expiresAtMs'>,
900
+ ): PendingApproval {
901
+ for (const pending of this.pending.values()) {
902
+ if (pending.fingerprint === input.fingerprint) return pending;
903
+ }
904
+ const createdAtMs = Date.now();
905
+ const pending: PendingApproval = {
906
+ ...input,
907
+ id: randomUUID().slice(0, 8),
908
+ createdAtMs,
909
+ expiresAtMs: createdAtMs + this.loadedPolicy.approvalTimeoutSecs * 1_000,
910
+ };
911
+ this.pending.set(pending.id, pending);
912
+ return pending;
913
+ }
914
+
915
+ private resolvePendingTarget(requestedId: string): PendingApproval | null {
916
+ if (requestedId) {
917
+ const direct = this.pending.get(requestedId);
918
+ if (direct) return direct;
919
+ return null;
920
+ }
921
+ let latest: PendingApproval | null = null;
922
+ for (const pending of this.pending.values()) {
923
+ if (!latest || pending.createdAtMs > latest.createdAtMs) latest = pending;
924
+ }
925
+ return latest;
926
+ }
927
+
928
+ private cleanupExpiredPending(): void {
929
+ const now = Date.now();
930
+ for (const [id, pending] of this.pending.entries()) {
931
+ if (pending.expiresAtMs <= now) {
932
+ this.pending.delete(id);
933
+ }
934
+ }
935
+ }
936
+
937
+ private bumpCount(map: Map<string, number>, key: string): void {
938
+ map.set(key, (map.get(key) || 0) + 1);
939
+ }
940
+
941
+ private classifyAction(
942
+ toolName: string,
943
+ args: Record<string, unknown>,
944
+ ): ClassifiedAction {
945
+ const lowerTool = toolName.toLowerCase();
946
+
947
+ if (
948
+ lowerTool === 'read' ||
949
+ lowerTool === 'glob' ||
950
+ lowerTool === 'grep' ||
951
+ lowerTool === 'session_search'
952
+ ) {
953
+ return {
954
+ tier: 'green',
955
+ actionKey: lowerTool,
956
+ intent: `run ${toolName}`,
957
+ consequenceIfDenied: 'I will continue without this lookup.',
958
+ reason: 'this is a read-only operation',
959
+ commandPreview: normalizePreview(JSON.stringify(args)),
960
+ pathHints: [],
961
+ hostHints: [],
962
+ writeIntent: false,
963
+ promotableRed: false,
964
+ stickyYellow: false,
965
+ };
966
+ }
967
+
968
+ if (lowerTool === 'delete') {
969
+ const rawPath = normalizeText(args.path);
970
+ const key = rawPath
971
+ ? `delete:${primaryPathKey(rawPath)}`
972
+ : 'delete:unknown';
973
+ const promotable = /(node_modules|dist|build|coverage|\.cache)/i.test(
974
+ rawPath,
975
+ );
976
+ return {
977
+ tier: 'red',
978
+ actionKey: key,
979
+ intent: `delete \`${rawPath || '(unknown path)'}\``,
980
+ consequenceIfDenied: 'the file will remain unchanged.',
981
+ reason: 'deletion is destructive',
982
+ commandPreview: normalizePreview(rawPath),
983
+ pathHints: rawPath ? [rawPath] : [],
984
+ hostHints: [],
985
+ writeIntent: true,
986
+ promotableRed: promotable,
987
+ stickyYellow: promotable,
988
+ };
989
+ }
990
+
991
+ if (
992
+ lowerTool === 'write' ||
993
+ lowerTool === 'edit' ||
994
+ lowerTool === 'memory'
995
+ ) {
996
+ const rawPath = normalizeText(
997
+ args.path || args.file_path || args.target || args.action,
998
+ );
999
+ const keyBase =
1000
+ lowerTool === 'memory'
1001
+ ? 'memory'
1002
+ : `${lowerTool}:${primaryPathKey(rawPath || 'workspace')}`;
1003
+ return {
1004
+ tier: 'yellow',
1005
+ actionKey: keyBase,
1006
+ intent:
1007
+ lowerTool === 'memory'
1008
+ ? 'update durable memory'
1009
+ : `${lowerTool === 'write' ? 'write' : 'edit'} \`${rawPath || '(unknown path)'}\``,
1010
+ consequenceIfDenied:
1011
+ lowerTool === 'memory'
1012
+ ? 'new memory will not be persisted.'
1013
+ : 'the file will stay unchanged.',
1014
+ reason: 'this modifies project files',
1015
+ commandPreview: normalizePreview(rawPath || JSON.stringify(args)),
1016
+ pathHints: rawPath ? [rawPath] : [],
1017
+ hostHints: [],
1018
+ writeIntent: true,
1019
+ promotableRed: false,
1020
+ stickyYellow: false,
1021
+ };
1022
+ }
1023
+
1024
+ if (lowerTool === 'web_fetch' || lowerTool === 'browser_navigate') {
1025
+ const rawUrl = normalizeText(args.url);
1026
+ const hosts = extractHostsFromUrlLikeText(rawUrl);
1027
+ const primaryHost = hosts[0] || 'unknown-host';
1028
+ const unseen = hosts.filter((host) => !this.seenNetworkHosts.has(host));
1029
+ return {
1030
+ tier: unseen.length > 0 ? 'red' : 'yellow',
1031
+ actionKey: `network:${primaryHost}`,
1032
+ intent: `access ${primaryHost}`,
1033
+ consequenceIfDenied:
1034
+ 'I will avoid contacting that host and use existing local context only.',
1035
+ reason:
1036
+ unseen.length > 0
1037
+ ? 'this would contact a new external host'
1038
+ : 'this is an external network action',
1039
+ commandPreview: normalizePreview(rawUrl),
1040
+ pathHints: [],
1041
+ hostHints: hosts,
1042
+ writeIntent: false,
1043
+ promotableRed: unseen.length > 0,
1044
+ stickyYellow: true,
1045
+ };
1046
+ }
1047
+
1048
+ if (
1049
+ lowerTool.startsWith('browser_') ||
1050
+ lowerTool === 'vision_analyze' ||
1051
+ lowerTool === 'image'
1052
+ ) {
1053
+ return {
1054
+ tier: 'yellow',
1055
+ actionKey: lowerTool,
1056
+ intent: `run ${toolName}`,
1057
+ consequenceIfDenied:
1058
+ 'I will continue without browser/vision interaction.',
1059
+ reason: 'this action interacts with external runtime state',
1060
+ commandPreview: normalizePreview(JSON.stringify(args)),
1061
+ pathHints: [],
1062
+ hostHints: [],
1063
+ writeIntent: false,
1064
+ promotableRed: false,
1065
+ stickyYellow: false,
1066
+ };
1067
+ }
1068
+
1069
+ if (lowerTool === 'bash') {
1070
+ return this.classifyBashAction(args);
1071
+ }
1072
+
1073
+ return {
1074
+ tier: 'yellow',
1075
+ actionKey: lowerTool,
1076
+ intent: `run ${toolName}`,
1077
+ consequenceIfDenied: 'I will continue without this action.',
1078
+ reason: 'this action may have side effects',
1079
+ commandPreview: normalizePreview(JSON.stringify(args)),
1080
+ pathHints: [],
1081
+ hostHints: [],
1082
+ writeIntent: false,
1083
+ promotableRed: false,
1084
+ stickyYellow: false,
1085
+ };
1086
+ }
1087
+
1088
+ private classifyBashAction(args: Record<string, unknown>): ClassifiedAction {
1089
+ const command = normalizeText(args.command);
1090
+ const lower = command.toLowerCase();
1091
+ const hosts = extractHostsFromUrlLikeText(command);
1092
+ const unseenHosts = hosts.filter(
1093
+ (host) => !this.seenNetworkHosts.has(host),
1094
+ );
1095
+ const absPaths = extractAbsolutePaths(command);
1096
+ const writeIntent =
1097
+ WRITE_INTENT_RE.test(command) ||
1098
+ DELETE_RE.test(command) ||
1099
+ INSTALL_RE.test(command) ||
1100
+ GIT_WRITE_RE.test(command);
1101
+
1102
+ if (CRITICAL_BASH_RE.test(command) || FORCE_PUSH_RE.test(command)) {
1103
+ return {
1104
+ tier: 'red',
1105
+ actionKey: 'bash:critical',
1106
+ intent: `run \`${normalizePreview(command)}\``,
1107
+ consequenceIfDenied:
1108
+ 'I will not execute that command and will propose a safer alternative.',
1109
+ reason: 'the command is high-risk or security-sensitive',
1110
+ commandPreview: normalizePreview(command),
1111
+ pathHints: absPaths,
1112
+ hostHints: hosts,
1113
+ writeIntent,
1114
+ promotableRed: false,
1115
+ stickyYellow: true,
1116
+ };
1117
+ }
1118
+
1119
+ if (this.loadedPolicy.workspaceFence && writeIntent) {
1120
+ const outsideWorkspace = absPaths.find(
1121
+ (entry) =>
1122
+ !entry.startsWith('/workspace') && !entry.startsWith('/dev/null'),
1123
+ );
1124
+ if (outsideWorkspace) {
1125
+ return {
1126
+ tier: 'red',
1127
+ actionKey: 'bash:workspace-fence',
1128
+ intent: `write outside workspace (\`${outsideWorkspace}\`)`,
1129
+ consequenceIfDenied: 'writes outside the workspace will be skipped.',
1130
+ reason: 'workspace fence blocks writes outside /workspace',
1131
+ commandPreview: normalizePreview(command),
1132
+ pathHints: absPaths,
1133
+ hostHints: hosts,
1134
+ writeIntent,
1135
+ promotableRed: false,
1136
+ stickyYellow: true,
1137
+ };
1138
+ }
1139
+ }
1140
+
1141
+ if (DELETE_RE.test(command)) {
1142
+ const promotable = /(node_modules|dist|build|coverage|\.cache)/i.test(
1143
+ lower,
1144
+ );
1145
+ return {
1146
+ tier: 'red',
1147
+ actionKey: promotable ? 'bash:delete-cache' : 'bash:delete',
1148
+ intent: `run destructive command \`${normalizePreview(command)}\``,
1149
+ consequenceIfDenied: 'I will continue without deleting files.',
1150
+ reason: 'the command deletes files',
1151
+ commandPreview: normalizePreview(command),
1152
+ pathHints: absPaths,
1153
+ hostHints: hosts,
1154
+ writeIntent: true,
1155
+ promotableRed: promotable,
1156
+ stickyYellow: promotable,
1157
+ };
1158
+ }
1159
+
1160
+ if (UNKNOWN_SCRIPT_RE.test(command)) {
1161
+ return {
1162
+ tier: 'red',
1163
+ actionKey: 'bash:script',
1164
+ intent: `run script \`${normalizePreview(command)}\``,
1165
+ consequenceIfDenied: 'I will avoid executing unknown scripts.',
1166
+ reason: 'script execution is treated as high risk',
1167
+ commandPreview: normalizePreview(command),
1168
+ pathHints: absPaths,
1169
+ hostHints: hosts,
1170
+ writeIntent,
1171
+ promotableRed: false,
1172
+ stickyYellow: true,
1173
+ };
1174
+ }
1175
+
1176
+ if (unseenHosts.length > 0 && NETWORK_COMMAND_RE.test(command)) {
1177
+ return {
1178
+ tier: 'red',
1179
+ actionKey: `bash:network:${unseenHosts[0]}`,
1180
+ intent: `contact new host ${unseenHosts[0]}`,
1181
+ consequenceIfDenied: 'I will keep the task local and avoid that host.',
1182
+ reason: 'the command reaches a new network host',
1183
+ commandPreview: normalizePreview(command),
1184
+ pathHints: absPaths,
1185
+ hostHints: hosts,
1186
+ writeIntent,
1187
+ promotableRed: true,
1188
+ stickyYellow: true,
1189
+ };
1190
+ }
1191
+
1192
+ if (INSTALL_RE.test(command)) {
1193
+ return {
1194
+ tier: 'yellow',
1195
+ actionKey: 'bash:install-deps',
1196
+ intent: `install dependencies with \`${normalizePreview(command)}\``,
1197
+ consequenceIfDenied: 'dependency installation will be skipped.',
1198
+ reason: 'this changes the local dependency state',
1199
+ commandPreview: normalizePreview(command),
1200
+ pathHints: absPaths,
1201
+ hostHints: hosts,
1202
+ writeIntent: true,
1203
+ promotableRed: false,
1204
+ stickyYellow: false,
1205
+ };
1206
+ }
1207
+
1208
+ if (GIT_WRITE_RE.test(command) || WRITE_INTENT_RE.test(command)) {
1209
+ return {
1210
+ tier: 'yellow',
1211
+ actionKey: 'bash:write-op',
1212
+ intent: `run mutating command \`${normalizePreview(command)}\``,
1213
+ consequenceIfDenied: 'I will continue without mutating the workspace.',
1214
+ reason: 'this command has write side effects',
1215
+ commandPreview: normalizePreview(command),
1216
+ pathHints: absPaths,
1217
+ hostHints: hosts,
1218
+ writeIntent: true,
1219
+ promotableRed: false,
1220
+ stickyYellow: false,
1221
+ };
1222
+ }
1223
+
1224
+ if (READ_ONLY_BASH_RE.test(command)) {
1225
+ return {
1226
+ tier: 'green',
1227
+ actionKey: 'bash:read-only',
1228
+ intent: `run read-only command \`${normalizePreview(command)}\``,
1229
+ consequenceIfDenied: 'I will continue without that check.',
1230
+ reason: 'this command is read-only',
1231
+ commandPreview: normalizePreview(command),
1232
+ pathHints: absPaths,
1233
+ hostHints: hosts,
1234
+ writeIntent: false,
1235
+ promotableRed: false,
1236
+ stickyYellow: false,
1237
+ };
1238
+ }
1239
+
1240
+ return {
1241
+ tier: 'yellow',
1242
+ actionKey: 'bash:other',
1243
+ intent: `run shell command \`${normalizePreview(command)}\``,
1244
+ consequenceIfDenied: 'I will continue without running that command.',
1245
+ reason: 'this command may change local state',
1246
+ commandPreview: normalizePreview(command),
1247
+ pathHints: absPaths,
1248
+ hostHints: hosts,
1249
+ writeIntent,
1250
+ promotableRed: false,
1251
+ stickyYellow: false,
1252
+ };
1253
+ }
1254
+
1255
+ private isPinnedRed(input: {
1256
+ toolName: string;
1257
+ preview: string;
1258
+ pathHints: string[];
1259
+ args: Record<string, unknown>;
1260
+ }): boolean {
1261
+ const fullText =
1262
+ `${input.toolName} ${input.preview} ${normalizeText(JSON.stringify(input.args))}`.toLowerCase();
1263
+
1264
+ // Hard-coded pinned path safety net.
1265
+ const hardPinnedPaths = ['.env*', '/etc/**', '~/.ssh/**'];
1266
+ for (const pathHint of input.pathHints) {
1267
+ if (
1268
+ hardPinnedPaths.some((pattern) => matchesPathPattern(pathHint, pattern))
1269
+ )
1270
+ return true;
1271
+ }
1272
+ if (fullText.includes('git push --force')) return true;
1273
+
1274
+ for (const rule of this.loadedPolicy.pinnedRed) {
1275
+ if (
1276
+ Array.isArray(rule.tools) &&
1277
+ rule.tools.some(
1278
+ (tool) => tool.toLowerCase() === input.toolName.toLowerCase(),
1279
+ )
1280
+ ) {
1281
+ return true;
1282
+ }
1283
+ if (rule.pattern) {
1284
+ try {
1285
+ const re = new RegExp(rule.pattern, 'i');
1286
+ if (re.test(fullText)) return true;
1287
+ } catch {
1288
+ // ignore invalid policy regex
1289
+ }
1290
+ }
1291
+ if (Array.isArray(rule.paths) && rule.paths.length > 0) {
1292
+ for (const pathHint of input.pathHints) {
1293
+ if (
1294
+ rule.paths.some((pattern) => matchesPathPattern(pathHint, pattern))
1295
+ ) {
1296
+ return true;
1297
+ }
1298
+ }
1299
+ }
1300
+ }
1301
+ return false;
1302
+ }
1303
+ }