@animus-labs/cortex 0.2.0

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 (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/budget-guard.d.ts +75 -0
  4. package/dist/budget-guard.d.ts.map +1 -0
  5. package/dist/budget-guard.js +142 -0
  6. package/dist/budget-guard.js.map +1 -0
  7. package/dist/compaction/compaction.d.ts +99 -0
  8. package/dist/compaction/compaction.d.ts.map +1 -0
  9. package/dist/compaction/compaction.js +302 -0
  10. package/dist/compaction/compaction.js.map +1 -0
  11. package/dist/compaction/failsafe.d.ts +57 -0
  12. package/dist/compaction/failsafe.d.ts.map +1 -0
  13. package/dist/compaction/failsafe.js +135 -0
  14. package/dist/compaction/failsafe.js.map +1 -0
  15. package/dist/compaction/index.d.ts +381 -0
  16. package/dist/compaction/index.d.ts.map +1 -0
  17. package/dist/compaction/index.js +979 -0
  18. package/dist/compaction/index.js.map +1 -0
  19. package/dist/compaction/microcompaction.d.ts +219 -0
  20. package/dist/compaction/microcompaction.d.ts.map +1 -0
  21. package/dist/compaction/microcompaction.js +536 -0
  22. package/dist/compaction/microcompaction.js.map +1 -0
  23. package/dist/compaction/observational/buffering.d.ts +225 -0
  24. package/dist/compaction/observational/buffering.d.ts.map +1 -0
  25. package/dist/compaction/observational/buffering.js +354 -0
  26. package/dist/compaction/observational/buffering.js.map +1 -0
  27. package/dist/compaction/observational/constants.d.ts +70 -0
  28. package/dist/compaction/observational/constants.d.ts.map +1 -0
  29. package/dist/compaction/observational/constants.js +507 -0
  30. package/dist/compaction/observational/constants.js.map +1 -0
  31. package/dist/compaction/observational/index.d.ts +219 -0
  32. package/dist/compaction/observational/index.d.ts.map +1 -0
  33. package/dist/compaction/observational/index.js +641 -0
  34. package/dist/compaction/observational/index.js.map +1 -0
  35. package/dist/compaction/observational/observer.d.ts +97 -0
  36. package/dist/compaction/observational/observer.d.ts.map +1 -0
  37. package/dist/compaction/observational/observer.js +424 -0
  38. package/dist/compaction/observational/observer.js.map +1 -0
  39. package/dist/compaction/observational/recall-tool.d.ts +27 -0
  40. package/dist/compaction/observational/recall-tool.d.ts.map +1 -0
  41. package/dist/compaction/observational/recall-tool.js +93 -0
  42. package/dist/compaction/observational/recall-tool.js.map +1 -0
  43. package/dist/compaction/observational/reflector.d.ts +94 -0
  44. package/dist/compaction/observational/reflector.d.ts.map +1 -0
  45. package/dist/compaction/observational/reflector.js +167 -0
  46. package/dist/compaction/observational/reflector.js.map +1 -0
  47. package/dist/compaction/observational/types.d.ts +271 -0
  48. package/dist/compaction/observational/types.d.ts.map +1 -0
  49. package/dist/compaction/observational/types.js +15 -0
  50. package/dist/compaction/observational/types.js.map +1 -0
  51. package/dist/context-manager.d.ts +134 -0
  52. package/dist/context-manager.d.ts.map +1 -0
  53. package/dist/context-manager.js +170 -0
  54. package/dist/context-manager.js.map +1 -0
  55. package/dist/cortex-agent.d.ts +1020 -0
  56. package/dist/cortex-agent.d.ts.map +1 -0
  57. package/dist/cortex-agent.js +3589 -0
  58. package/dist/cortex-agent.js.map +1 -0
  59. package/dist/error-classifier.d.ts +48 -0
  60. package/dist/error-classifier.d.ts.map +1 -0
  61. package/dist/error-classifier.js +152 -0
  62. package/dist/error-classifier.js.map +1 -0
  63. package/dist/event-bridge.d.ts +166 -0
  64. package/dist/event-bridge.d.ts.map +1 -0
  65. package/dist/event-bridge.js +381 -0
  66. package/dist/event-bridge.js.map +1 -0
  67. package/dist/index.d.ts +55 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +57 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/mcp-client.d.ts +119 -0
  72. package/dist/mcp-client.d.ts.map +1 -0
  73. package/dist/mcp-client.js +474 -0
  74. package/dist/mcp-client.js.map +1 -0
  75. package/dist/model-wrapper.d.ts +58 -0
  76. package/dist/model-wrapper.d.ts.map +1 -0
  77. package/dist/model-wrapper.js +86 -0
  78. package/dist/model-wrapper.js.map +1 -0
  79. package/dist/noop-logger.d.ts +4 -0
  80. package/dist/noop-logger.d.ts.map +1 -0
  81. package/dist/noop-logger.js +8 -0
  82. package/dist/noop-logger.js.map +1 -0
  83. package/dist/prompt-diagnostics.d.ts +47 -0
  84. package/dist/prompt-diagnostics.d.ts.map +1 -0
  85. package/dist/prompt-diagnostics.js +230 -0
  86. package/dist/prompt-diagnostics.js.map +1 -0
  87. package/dist/provider-manager.d.ts +224 -0
  88. package/dist/provider-manager.d.ts.map +1 -0
  89. package/dist/provider-manager.js +563 -0
  90. package/dist/provider-manager.js.map +1 -0
  91. package/dist/provider-registry.d.ts +115 -0
  92. package/dist/provider-registry.d.ts.map +1 -0
  93. package/dist/provider-registry.js +305 -0
  94. package/dist/provider-registry.js.map +1 -0
  95. package/dist/schema-converter.d.ts +20 -0
  96. package/dist/schema-converter.d.ts.map +1 -0
  97. package/dist/schema-converter.js +48 -0
  98. package/dist/schema-converter.js.map +1 -0
  99. package/dist/skill-preprocessor.d.ts +46 -0
  100. package/dist/skill-preprocessor.d.ts.map +1 -0
  101. package/dist/skill-preprocessor.js +237 -0
  102. package/dist/skill-preprocessor.js.map +1 -0
  103. package/dist/skill-registry.d.ts +107 -0
  104. package/dist/skill-registry.d.ts.map +1 -0
  105. package/dist/skill-registry.js +330 -0
  106. package/dist/skill-registry.js.map +1 -0
  107. package/dist/skill-tool.d.ts +54 -0
  108. package/dist/skill-tool.d.ts.map +1 -0
  109. package/dist/skill-tool.js +88 -0
  110. package/dist/skill-tool.js.map +1 -0
  111. package/dist/sub-agent-manager.d.ts +90 -0
  112. package/dist/sub-agent-manager.d.ts.map +1 -0
  113. package/dist/sub-agent-manager.js +192 -0
  114. package/dist/sub-agent-manager.js.map +1 -0
  115. package/dist/token-estimator.d.ts +23 -0
  116. package/dist/token-estimator.d.ts.map +1 -0
  117. package/dist/token-estimator.js +27 -0
  118. package/dist/token-estimator.js.map +1 -0
  119. package/dist/tool-contract.d.ts +68 -0
  120. package/dist/tool-contract.d.ts.map +1 -0
  121. package/dist/tool-contract.js +35 -0
  122. package/dist/tool-contract.js.map +1 -0
  123. package/dist/tool-result-persistence.d.ts +89 -0
  124. package/dist/tool-result-persistence.d.ts.map +1 -0
  125. package/dist/tool-result-persistence.js +152 -0
  126. package/dist/tool-result-persistence.js.map +1 -0
  127. package/dist/tools/bash/index.d.ts +71 -0
  128. package/dist/tools/bash/index.d.ts.map +1 -0
  129. package/dist/tools/bash/index.js +485 -0
  130. package/dist/tools/bash/index.js.map +1 -0
  131. package/dist/tools/bash/interactive.d.ts +47 -0
  132. package/dist/tools/bash/interactive.d.ts.map +1 -0
  133. package/dist/tools/bash/interactive.js +262 -0
  134. package/dist/tools/bash/interactive.js.map +1 -0
  135. package/dist/tools/bash/safety.d.ts +149 -0
  136. package/dist/tools/bash/safety.d.ts.map +1 -0
  137. package/dist/tools/bash/safety.js +1116 -0
  138. package/dist/tools/bash/safety.js.map +1 -0
  139. package/dist/tools/edit.d.ts +57 -0
  140. package/dist/tools/edit.d.ts.map +1 -0
  141. package/dist/tools/edit.js +310 -0
  142. package/dist/tools/edit.js.map +1 -0
  143. package/dist/tools/glob.d.ts +34 -0
  144. package/dist/tools/glob.d.ts.map +1 -0
  145. package/dist/tools/glob.js +268 -0
  146. package/dist/tools/glob.js.map +1 -0
  147. package/dist/tools/grep.d.ts +53 -0
  148. package/dist/tools/grep.d.ts.map +1 -0
  149. package/dist/tools/grep.js +673 -0
  150. package/dist/tools/grep.js.map +1 -0
  151. package/dist/tools/index.d.ts +62 -0
  152. package/dist/tools/index.d.ts.map +1 -0
  153. package/dist/tools/index.js +52 -0
  154. package/dist/tools/index.js.map +1 -0
  155. package/dist/tools/read.d.ts +43 -0
  156. package/dist/tools/read.d.ts.map +1 -0
  157. package/dist/tools/read.js +459 -0
  158. package/dist/tools/read.js.map +1 -0
  159. package/dist/tools/runtime.d.ts +62 -0
  160. package/dist/tools/runtime.d.ts.map +1 -0
  161. package/dist/tools/runtime.js +116 -0
  162. package/dist/tools/runtime.js.map +1 -0
  163. package/dist/tools/shared/cwd-tracker.d.ts +32 -0
  164. package/dist/tools/shared/cwd-tracker.d.ts.map +1 -0
  165. package/dist/tools/shared/cwd-tracker.js +44 -0
  166. package/dist/tools/shared/cwd-tracker.js.map +1 -0
  167. package/dist/tools/shared/edit-history.d.ts +55 -0
  168. package/dist/tools/shared/edit-history.d.ts.map +1 -0
  169. package/dist/tools/shared/edit-history.js +72 -0
  170. package/dist/tools/shared/edit-history.js.map +1 -0
  171. package/dist/tools/shared/edit-matcher.d.ts +83 -0
  172. package/dist/tools/shared/edit-matcher.d.ts.map +1 -0
  173. package/dist/tools/shared/edit-matcher.js +359 -0
  174. package/dist/tools/shared/edit-matcher.js.map +1 -0
  175. package/dist/tools/shared/file-mutation-lock.d.ts +22 -0
  176. package/dist/tools/shared/file-mutation-lock.d.ts.map +1 -0
  177. package/dist/tools/shared/file-mutation-lock.js +35 -0
  178. package/dist/tools/shared/file-mutation-lock.js.map +1 -0
  179. package/dist/tools/shared/gitignore.d.ts +17 -0
  180. package/dist/tools/shared/gitignore.d.ts.map +1 -0
  181. package/dist/tools/shared/gitignore.js +59 -0
  182. package/dist/tools/shared/gitignore.js.map +1 -0
  183. package/dist/tools/shared/pdf-extractor.d.ts +96 -0
  184. package/dist/tools/shared/pdf-extractor.d.ts.map +1 -0
  185. package/dist/tools/shared/pdf-extractor.js +196 -0
  186. package/dist/tools/shared/pdf-extractor.js.map +1 -0
  187. package/dist/tools/shared/read-registry.d.ts +66 -0
  188. package/dist/tools/shared/read-registry.d.ts.map +1 -0
  189. package/dist/tools/shared/read-registry.js +65 -0
  190. package/dist/tools/shared/read-registry.js.map +1 -0
  191. package/dist/tools/shared/safe-env.d.ts +18 -0
  192. package/dist/tools/shared/safe-env.d.ts.map +1 -0
  193. package/dist/tools/shared/safe-env.js +70 -0
  194. package/dist/tools/shared/safe-env.js.map +1 -0
  195. package/dist/tools/sub-agent.d.ts +91 -0
  196. package/dist/tools/sub-agent.d.ts.map +1 -0
  197. package/dist/tools/sub-agent.js +89 -0
  198. package/dist/tools/sub-agent.js.map +1 -0
  199. package/dist/tools/task-output.d.ts +38 -0
  200. package/dist/tools/task-output.d.ts.map +1 -0
  201. package/dist/tools/task-output.js +186 -0
  202. package/dist/tools/task-output.js.map +1 -0
  203. package/dist/tools/tool-search/index.d.ts +40 -0
  204. package/dist/tools/tool-search/index.d.ts.map +1 -0
  205. package/dist/tools/tool-search/index.js +110 -0
  206. package/dist/tools/tool-search/index.js.map +1 -0
  207. package/dist/tools/tool-search/registry.d.ts +82 -0
  208. package/dist/tools/tool-search/registry.d.ts.map +1 -0
  209. package/dist/tools/tool-search/registry.js +238 -0
  210. package/dist/tools/tool-search/registry.js.map +1 -0
  211. package/dist/tools/undo-edit.d.ts +51 -0
  212. package/dist/tools/undo-edit.d.ts.map +1 -0
  213. package/dist/tools/undo-edit.js +231 -0
  214. package/dist/tools/undo-edit.js.map +1 -0
  215. package/dist/tools/web-fetch/cache.d.ts +49 -0
  216. package/dist/tools/web-fetch/cache.d.ts.map +1 -0
  217. package/dist/tools/web-fetch/cache.js +89 -0
  218. package/dist/tools/web-fetch/cache.js.map +1 -0
  219. package/dist/tools/web-fetch/index.d.ts +53 -0
  220. package/dist/tools/web-fetch/index.d.ts.map +1 -0
  221. package/dist/tools/web-fetch/index.js +513 -0
  222. package/dist/tools/web-fetch/index.js.map +1 -0
  223. package/dist/tools/write.d.ts +59 -0
  224. package/dist/tools/write.d.ts.map +1 -0
  225. package/dist/tools/write.js +316 -0
  226. package/dist/tools/write.js.map +1 -0
  227. package/dist/types.d.ts +881 -0
  228. package/dist/types.d.ts.map +1 -0
  229. package/dist/types.js +16 -0
  230. package/dist/types.js.map +1 -0
  231. package/dist/working-tags.d.ts +44 -0
  232. package/dist/working-tags.d.ts.map +1 -0
  233. package/dist/working-tags.js +103 -0
  234. package/dist/working-tags.js.map +1 -0
  235. package/package.json +87 -0
  236. package/src/budget-guard.ts +170 -0
  237. package/src/compaction/compaction.ts +386 -0
  238. package/src/compaction/failsafe.ts +185 -0
  239. package/src/compaction/index.ts +1199 -0
  240. package/src/compaction/microcompaction.ts +709 -0
  241. package/src/compaction/observational/buffering.ts +430 -0
  242. package/src/compaction/observational/constants.ts +532 -0
  243. package/src/compaction/observational/index.ts +837 -0
  244. package/src/compaction/observational/observer.ts +510 -0
  245. package/src/compaction/observational/recall-tool.ts +130 -0
  246. package/src/compaction/observational/reflector.ts +221 -0
  247. package/src/compaction/observational/types.ts +343 -0
  248. package/src/context-manager.ts +237 -0
  249. package/src/cortex-agent.ts +4297 -0
  250. package/src/error-classifier.ts +199 -0
  251. package/src/event-bridge.ts +508 -0
  252. package/src/index.ts +292 -0
  253. package/src/mcp-client.ts +582 -0
  254. package/src/model-wrapper.ts +128 -0
  255. package/src/noop-logger.ts +9 -0
  256. package/src/prompt-diagnostics.ts +296 -0
  257. package/src/provider-manager.ts +823 -0
  258. package/src/provider-registry.ts +386 -0
  259. package/src/schema-converter.ts +51 -0
  260. package/src/skill-preprocessor.ts +314 -0
  261. package/src/skill-registry.ts +378 -0
  262. package/src/skill-tool.ts +130 -0
  263. package/src/sub-agent-manager.ts +236 -0
  264. package/src/token-estimator.ts +26 -0
  265. package/src/tool-contract.ts +113 -0
  266. package/src/tool-result-persistence.ts +197 -0
  267. package/src/tools/bash/index.ts +633 -0
  268. package/src/tools/bash/interactive.ts +302 -0
  269. package/src/tools/bash/safety.ts +1297 -0
  270. package/src/tools/edit.ts +422 -0
  271. package/src/tools/glob.ts +330 -0
  272. package/src/tools/grep.ts +819 -0
  273. package/src/tools/index.ts +110 -0
  274. package/src/tools/read.ts +580 -0
  275. package/src/tools/runtime.ts +173 -0
  276. package/src/tools/shared/cwd-tracker.ts +50 -0
  277. package/src/tools/shared/edit-history.ts +96 -0
  278. package/src/tools/shared/edit-matcher.ts +457 -0
  279. package/src/tools/shared/file-mutation-lock.ts +40 -0
  280. package/src/tools/shared/gitignore.ts +61 -0
  281. package/src/tools/shared/pdf-extractor.ts +290 -0
  282. package/src/tools/shared/read-registry.ts +93 -0
  283. package/src/tools/shared/safe-env.ts +82 -0
  284. package/src/tools/sub-agent.ts +171 -0
  285. package/src/tools/task-output.ts +236 -0
  286. package/src/tools/tool-search/index.ts +167 -0
  287. package/src/tools/tool-search/registry.ts +278 -0
  288. package/src/tools/undo-edit.ts +314 -0
  289. package/src/tools/web-fetch/cache.ts +112 -0
  290. package/src/tools/web-fetch/index.ts +604 -0
  291. package/src/tools/write.ts +385 -0
  292. package/src/types.ts +1057 -0
  293. package/src/working-tags.ts +118 -0
@@ -0,0 +1,1297 @@
1
+ /**
2
+ * Bash tool safety layers.
3
+ *
4
+ * Seven layers of defense-in-depth for shell command execution, plus a
5
+ * final UX gate:
6
+ * 1. Environment variable stripping
7
+ * 2. Critical path protection
8
+ * 3. Command classification
9
+ * 4. Path validation for write commands
10
+ * 5. Obfuscation and injection detection
11
+ * 6. Script preflight
12
+ * 6.5. Interactive command detection (UX gate — prevents silent timeouts
13
+ * on editors, pagers, REPLs, and interactive DB clients)
14
+ * 7. Auto-mode classifier (utility model LLM call)
15
+ *
16
+ * Reference: docs/cortex/tools/bash.md (Safety Architecture)
17
+ */
18
+
19
+ import * as path from 'node:path';
20
+ import * as fs from 'node:fs';
21
+ import { buildSafeEnv as buildSafeEnvShared } from '../shared/safe-env.js';
22
+ import { checkInteractive } from './interactive.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Types
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export type CommandClassification =
29
+ | 'read'
30
+ | 'write'
31
+ | 'create'
32
+ | 'network'
33
+ | 'safe-stdin'
34
+ | 'unknown';
35
+
36
+ export interface SafetyCheckResult {
37
+ allowed: boolean;
38
+ reason?: string | undefined;
39
+ classification?: CommandClassification | undefined;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Layer 1: Environment Variable Security
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Build a safe environment for child processes by stripping dangerous variables.
48
+ * Adds CORTEX_SHELL=exec as a context marker.
49
+ *
50
+ * Delegates to the shared buildSafeEnv utility so that both the Bash tool
51
+ * and the MCP client use the same blocklist.
52
+ *
53
+ * @param parentEnv - The source environment (typically process.env)
54
+ * @param overrides - Optional env var overrides that bypass the blocklist
55
+ */
56
+ export function buildSafeEnv(
57
+ parentEnv: NodeJS.ProcessEnv,
58
+ overrides?: Record<string, string>,
59
+ ): Record<string, string> {
60
+ return buildSafeEnvShared(parentEnv, 'exec', overrides);
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Layer 2: Critical Path Protection
65
+ // ---------------------------------------------------------------------------
66
+
67
+ const UNIX_CRITICAL_PATHS = [
68
+ '/',
69
+ '/usr',
70
+ '/etc',
71
+ '/boot',
72
+ '/sbin',
73
+ '/var',
74
+ '/System',
75
+ '/proc',
76
+ '/sys',
77
+ ];
78
+
79
+ const MACOS_CRITICAL_PATHS = [
80
+ path.join(process.env['HOME'] ?? '', 'Library'),
81
+ ];
82
+
83
+ const WINDOWS_CRITICAL_PATHS = [
84
+ 'C:\\Windows',
85
+ 'C:\\Windows\\System32',
86
+ 'C:\\Program Files',
87
+ 'C:\\Program Files (x86)',
88
+ 'C:\\ProgramData',
89
+ ];
90
+
91
+ /**
92
+ * Check if a target path resolves to a critical system directory.
93
+ */
94
+ export function isCriticalPath(targetPath: string): boolean {
95
+ const resolved = path.resolve(targetPath);
96
+ const normalized = resolved.replace(/\\/g, '/').replace(/\/+$/, '');
97
+
98
+ const criticalPaths = process.platform === 'win32'
99
+ ? WINDOWS_CRITICAL_PATHS
100
+ : [...UNIX_CRITICAL_PATHS, ...(process.platform === 'darwin' ? MACOS_CRITICAL_PATHS : [])];
101
+
102
+ for (const cp of criticalPaths) {
103
+ const normalizedCp = cp.replace(/\\/g, '/').replace(/\/+$/, '');
104
+ if (normalized === normalizedCp || normalized.toLowerCase() === normalizedCp.toLowerCase()) {
105
+ return true;
106
+ }
107
+ }
108
+
109
+ // Check for Windows AppData
110
+ if (process.platform === 'win32') {
111
+ const userProfile = process.env['USERPROFILE'];
112
+ if (userProfile) {
113
+ const appDataPath = path.join(userProfile, 'AppData').replace(/\\/g, '/');
114
+ if (normalized.toLowerCase().startsWith(appDataPath.toLowerCase())) {
115
+ return true;
116
+ }
117
+ }
118
+ }
119
+
120
+ return false;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Layer 3: Command Classification
125
+ // ---------------------------------------------------------------------------
126
+
127
+ const UNIX_READ_COMMANDS = new Set([
128
+ 'cd', 'ls', 'find', 'cat', 'head', 'tail', 'sort', 'wc', 'diff',
129
+ 'grep', 'echo', 'pwd', 'env', 'which', 'file', 'stat', 'strings',
130
+ 'hexdump', 'less', 'more', 'tree',
131
+ ]);
132
+
133
+ const UNIX_WRITE_COMMANDS = new Set([
134
+ 'rm', 'rmdir', 'mv', 'cp', 'chmod', 'chown',
135
+ ]);
136
+
137
+ const UNIX_CREATE_COMMANDS = new Set([
138
+ 'mkdir', 'touch', 'tee',
139
+ ]);
140
+
141
+ const UNIX_NETWORK_COMMANDS = new Set([
142
+ 'curl', 'wget', 'ssh', 'scp', 'rsync', 'nc', 'nmap',
143
+ ]);
144
+
145
+ const UNIX_SAFE_STDIN_COMMANDS = new Set([
146
+ 'jq', 'cut', 'uniq', 'head', 'tail', 'tr', 'wc',
147
+ ]);
148
+
149
+ const PS_READ_COMMANDS = new Set([
150
+ 'get-content', 'get-childitem', 'get-item', 'get-location',
151
+ 'select-string', 'compare-object', 'test-path', 'get-process',
152
+ 'dir', 'type', 'where',
153
+ ]);
154
+
155
+ const PS_WRITE_COMMANDS = new Set([
156
+ 'remove-item', 'move-item', 'copy-item', 'set-content',
157
+ 'rename-item', 'set-itemproperty',
158
+ ]);
159
+
160
+ const PS_CREATE_COMMANDS = new Set([
161
+ 'new-item', 'out-file', 'add-content',
162
+ ]);
163
+
164
+ const PS_NETWORK_COMMANDS = new Set([
165
+ 'invoke-webrequest', 'invoke-restmethod', 'test-netconnection', 'ssh',
166
+ ]);
167
+
168
+ /**
169
+ * Git subcommands that are read-only.
170
+ */
171
+ const GIT_READ_SUBCOMMANDS = new Set([
172
+ 'status', 'log', 'diff', 'show', 'branch', 'tag', 'remote', 'stash',
173
+ 'blame', 'shortlog', 'describe', 'rev-parse', 'ls-files', 'ls-tree',
174
+ ]);
175
+
176
+ /**
177
+ * Safe-stdin denied flags per binary.
178
+ */
179
+ const SAFE_STDIN_DENIED_FLAGS: Record<string, Set<string>> = {
180
+ grep: new Set(['-r', '-R', '-d', '-f', '--recursive', '--dereference-recursive', '--directories', '--file', '--exclude-from']),
181
+ jq: new Set(['-f', '-L', '--from-file', '--library-path', '--argfile', '--rawfile', '--slurpfile']),
182
+ sort: new Set(['-o', '-T', '--output', '--temporary-directory', '--compress-program', '--files0-from', '--random-source']),
183
+ wc: new Set(['--files0-from']),
184
+ };
185
+
186
+ /**
187
+ * Split a command string on shell operators (; && || |) while respecting
188
+ * quoted strings. Returns the individual sub-commands.
189
+ */
190
+ export function splitOnShellOperators(command: string): string[] {
191
+ const subCommands: string[] = [];
192
+ let current = '';
193
+ let inSingle = false;
194
+ let inDouble = false;
195
+ let escaped = false;
196
+ let i = 0;
197
+
198
+ while (i < command.length) {
199
+ const ch = command[i]!;
200
+
201
+ if (escaped) {
202
+ current += ch;
203
+ escaped = false;
204
+ i++;
205
+ continue;
206
+ }
207
+
208
+ if (ch === '\\') {
209
+ escaped = true;
210
+ current += ch;
211
+ i++;
212
+ continue;
213
+ }
214
+
215
+ if (ch === "'" && !inDouble) {
216
+ inSingle = !inSingle;
217
+ current += ch;
218
+ i++;
219
+ continue;
220
+ }
221
+
222
+ if (ch === '"' && !inSingle) {
223
+ inDouble = !inDouble;
224
+ current += ch;
225
+ i++;
226
+ continue;
227
+ }
228
+
229
+ // Only split when outside quotes
230
+ if (!inSingle && !inDouble) {
231
+ // Check for && or ||
232
+ if ((ch === '&' && command[i + 1] === '&') || (ch === '|' && command[i + 1] === '|')) {
233
+ if (current.trim()) subCommands.push(current.trim());
234
+ current = '';
235
+ i += 2;
236
+ continue;
237
+ }
238
+
239
+ // Check for single pipe (not ||) or semicolon
240
+ if (ch === ';' || (ch === '|' && command[i + 1] !== '|')) {
241
+ if (current.trim()) subCommands.push(current.trim());
242
+ current = '';
243
+ i++;
244
+ continue;
245
+ }
246
+ }
247
+
248
+ current += ch;
249
+ i++;
250
+ }
251
+
252
+ if (current.trim()) subCommands.push(current.trim());
253
+
254
+ return subCommands;
255
+ }
256
+
257
+ /**
258
+ * Extract the command name from a single (non-compound) command string.
259
+ */
260
+ function extractCommandName(singleCommand: string): string {
261
+ const trimmed = singleCommand.trim();
262
+ // Handle 'sed -i' specifically
263
+ if (/^sed\s+.*-i/.test(trimmed)) return 'sed-i';
264
+
265
+ // Get the first token (the command name)
266
+ const tokens = trimmed.split(/\s+/);
267
+ return (tokens[0] ?? '').toLowerCase();
268
+ }
269
+
270
+ /**
271
+ * Risk ordering from lowest to highest. Used to pick the most dangerous
272
+ * classification when a compound command contains multiple sub-commands.
273
+ */
274
+ const CLASSIFICATION_RISK_ORDER: readonly CommandClassification[] = [
275
+ 'read',
276
+ 'safe-stdin',
277
+ 'create',
278
+ 'write',
279
+ 'network',
280
+ 'unknown',
281
+ ];
282
+
283
+ /**
284
+ * Return the higher-risk classification of two values.
285
+ */
286
+ function higherRisk(a: CommandClassification, b: CommandClassification): CommandClassification {
287
+ const aIdx = CLASSIFICATION_RISK_ORDER.indexOf(a);
288
+ const bIdx = CLASSIFICATION_RISK_ORDER.indexOf(b);
289
+ return aIdx >= bIdx ? a : b;
290
+ }
291
+
292
+ /**
293
+ * Classify a single (non-compound) command by its potential impact.
294
+ */
295
+ function classifySingleCommand(singleCommand: string): CommandClassification {
296
+ const cmdName = extractCommandName(singleCommand);
297
+ const isWindows = process.platform === 'win32';
298
+
299
+ if (isWindows) {
300
+ const psCmd = cmdName.toLowerCase();
301
+ if (PS_READ_COMMANDS.has(psCmd)) return 'read';
302
+ if (PS_WRITE_COMMANDS.has(psCmd)) return 'write';
303
+ if (PS_CREATE_COMMANDS.has(psCmd)) return 'create';
304
+ if (PS_NETWORK_COMMANDS.has(psCmd)) return 'network';
305
+ // Handle PS aliases
306
+ if (psCmd === 'curl' || psCmd === 'wget') return 'network';
307
+ return 'unknown';
308
+ }
309
+
310
+ // Unix
311
+ // Handle git subcommands
312
+ if (cmdName === 'git') {
313
+ const parts = singleCommand.trim().split(/\s+/);
314
+ const subcommand = parts[1]?.toLowerCase();
315
+ if (subcommand && GIT_READ_SUBCOMMANDS.has(subcommand)) return 'read';
316
+ return 'unknown';
317
+ }
318
+
319
+ // Handle sed -i (write)
320
+ if (cmdName === 'sed-i') return 'write';
321
+
322
+ if (UNIX_READ_COMMANDS.has(cmdName)) return 'read';
323
+ if (UNIX_WRITE_COMMANDS.has(cmdName)) return 'write';
324
+ if (UNIX_CREATE_COMMANDS.has(cmdName)) return 'create';
325
+ if (UNIX_NETWORK_COMMANDS.has(cmdName)) return 'network';
326
+
327
+ // Check safe-stdin
328
+ if (UNIX_SAFE_STDIN_COMMANDS.has(cmdName)) {
329
+ // Verify no denied flags and no file args
330
+ const tokens = singleCommand.trim().split(/\s+/);
331
+ const deniedFlags = SAFE_STDIN_DENIED_FLAGS[cmdName];
332
+ if (deniedFlags) {
333
+ for (const token of tokens.slice(1)) {
334
+ if (deniedFlags.has(token)) return 'unknown';
335
+ }
336
+ }
337
+ // Check for path-like positional arguments (simple heuristic)
338
+ const args = tokens.slice(1).filter((t) => !t.startsWith('-'));
339
+ const hasPathArgs = args.some((a) => a.includes('/') || a.includes('.'));
340
+ if (hasPathArgs) return 'unknown';
341
+
342
+ return 'safe-stdin';
343
+ }
344
+
345
+ return 'unknown';
346
+ }
347
+
348
+ /**
349
+ * Classify a command (potentially compound) by its potential impact.
350
+ * For compound commands, returns the highest-risk classification
351
+ * among all sub-commands.
352
+ */
353
+ export function classifyCommand(command: string): CommandClassification {
354
+ const subCommands = splitOnShellOperators(command);
355
+ if (subCommands.length === 0) return 'unknown';
356
+
357
+ let result: CommandClassification = classifySingleCommand(subCommands[0]!);
358
+ for (let i = 1; i < subCommands.length; i++) {
359
+ result = higherRisk(result, classifySingleCommand(subCommands[i]!));
360
+ }
361
+ return result;
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Layer 4: Path Validation
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /**
369
+ * Extract target paths from write/create commands in a single sub-command.
370
+ */
371
+ function extractWritePathsFromSingle(singleCommand: string): string[] {
372
+ const paths: string[] = [];
373
+ const tokens = singleCommand.trim().split(/\s+/);
374
+ const cmd = (tokens[0] ?? '').toLowerCase();
375
+
376
+ if (['rm', 'rmdir', 'mv', 'cp', 'touch', 'mkdir'].includes(cmd)) {
377
+ // Last argument(s) that aren't flags
378
+ for (let i = tokens.length - 1; i > 0; i--) {
379
+ const token = tokens[i]!;
380
+ if (!token.startsWith('-')) {
381
+ paths.push(token);
382
+ // For rm, rmdir, touch, mkdir - all non-flag args are targets
383
+ // For mv, cp - last arg is destination
384
+ if (['mv', 'cp'].includes(cmd)) break;
385
+ }
386
+ }
387
+ }
388
+
389
+ return paths;
390
+ }
391
+
392
+ /**
393
+ * Extract target paths from write/create commands.
394
+ * Returns the paths that would be modified by the command.
395
+ * Handles compound commands by extracting paths from all sub-commands.
396
+ */
397
+ export function extractWritePaths(command: string): string[] {
398
+ const subCommands = splitOnShellOperators(command);
399
+ const paths: string[] = [];
400
+ for (const sub of subCommands) {
401
+ paths.push(...extractWritePathsFromSingle(sub));
402
+ }
403
+ return paths;
404
+ }
405
+
406
+ /**
407
+ * Resolve a path, following symlinks when the target exists.
408
+ * Falls back to path.resolve() if the path does not yet exist.
409
+ */
410
+ function resolveWithSymlinks(targetPath: string): string {
411
+ try {
412
+ return fs.realpathSync(targetPath);
413
+ } catch {
414
+ // Path does not exist yet (e.g., mkdir for a new directory), fall back
415
+ return path.resolve(targetPath);
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Validate that write paths are within the allowed working directory.
421
+ */
422
+ export function validateWritePaths(
423
+ command: string,
424
+ workingDirectory: string,
425
+ currentCwd: string,
426
+ ): SafetyCheckResult {
427
+ const classification = classifyCommand(command);
428
+ if (classification !== 'write' && classification !== 'create') {
429
+ return { allowed: true, classification };
430
+ }
431
+
432
+ const writePaths = extractWritePaths(command);
433
+ for (const wp of writePaths) {
434
+ // Resolve relative to current CWD, then resolve symlinks
435
+ const rawResolved = path.resolve(currentCwd, wp);
436
+ const resolved = resolveWithSymlinks(rawResolved);
437
+
438
+ // Check critical paths
439
+ if (isCriticalPath(resolved)) {
440
+ return {
441
+ allowed: false,
442
+ reason: 'This command would modify a critical system directory. This cannot be auto-allowed.',
443
+ classification,
444
+ };
445
+ }
446
+ }
447
+
448
+ return { allowed: true, classification };
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Layer 5: Obfuscation and Injection Detection
453
+ // ---------------------------------------------------------------------------
454
+
455
+ /**
456
+ * Strip invisible Unicode characters that could be used for obfuscation.
457
+ */
458
+ export function stripInvisibleChars(command: string): string {
459
+ // Zero-width characters, BiDi markers, variation selectors, tag characters
460
+ return command.replace(
461
+ /[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD\u034F\u061C\u180E\u2060-\u2069\uFFF9-\uFFFB\u{E0001}-\u{E007F}\u{FE00}-\u{FE0F}]/gu,
462
+ '',
463
+ );
464
+ }
465
+
466
+ /**
467
+ * Safe URL allowlist for download-and-execute patterns.
468
+ */
469
+ const SAFE_DOWNLOAD_URLS: Array<{ host: string; pathPrefix?: string | undefined }> = [
470
+ { host: 'brew.sh' },
471
+ { host: 'get.pnpm.io' },
472
+ { host: 'bun.sh', pathPrefix: '/install' },
473
+ { host: 'sh.rustup.rs' },
474
+ { host: 'get.docker.com' },
475
+ { host: 'install.python-poetry.org' },
476
+ { host: 'raw.githubusercontent.com', pathPrefix: '/Homebrew/' },
477
+ { host: 'raw.githubusercontent.com', pathPrefix: '/nvm-sh/nvm/' },
478
+ ];
479
+
480
+ /**
481
+ * Check if a URL is in the safe download allowlist.
482
+ */
483
+ function isSafeDownloadUrl(url: string): boolean {
484
+ try {
485
+ const parsed = new URL(url);
486
+ // Reject URLs with credentials
487
+ if (parsed.username || parsed.password) return false;
488
+
489
+ const host = parsed.hostname.toLowerCase();
490
+ const pathname = parsed.pathname;
491
+
492
+ for (const entry of SAFE_DOWNLOAD_URLS) {
493
+ if (host === entry.host || host === `www.${entry.host}`) {
494
+ if (!entry.pathPrefix || pathname.startsWith(entry.pathPrefix)) {
495
+ return true;
496
+ }
497
+ }
498
+ }
499
+ } catch {
500
+ // Invalid URL
501
+ }
502
+ return false;
503
+ }
504
+
505
+ /**
506
+ * Extract URLs from a command string.
507
+ */
508
+ function extractUrls(command: string): string[] {
509
+ const urlRegex = /https?:\/\/[^\s'"]+/g;
510
+ return command.match(urlRegex) ?? [];
511
+ }
512
+
513
+ interface ObfuscationPattern {
514
+ pattern: RegExp;
515
+ description: string;
516
+ /** When true, only match against unquoted portions of the command. */
517
+ quoteAware?: boolean;
518
+ }
519
+
520
+ /**
521
+ * Unix obfuscation and injection patterns.
522
+ */
523
+ const UNIX_OBFUSCATION_PATTERNS: ObfuscationPattern[] = [
524
+ // Encoded execution
525
+ { pattern: /base64\s+(-d|--decode)\s*\|.*\b(ba)?sh\b/i, description: 'Base64 decode piped to shell' },
526
+ { pattern: /xxd\s+-r\s*\|.*\b(ba)?sh\b/i, description: 'Hex decode piped to shell' },
527
+ { pattern: /printf\s+.*\\x.*\|.*\b(ba)?sh\b/i, description: 'Printf escape sequences piped to shell' },
528
+ // Eval injection
529
+ { pattern: /\beval\s+.*(\$\(|`|base64|\\x|\\[0-7])/i, description: 'Eval with encoded/obfuscated input' },
530
+ // Heredoc execution
531
+ { pattern: /<<\s*['"]?\w+['"]?\s*\n.*\b(ba)?sh\b/is, description: 'Heredoc used to construct and execute commands' },
532
+ // Escape sequences
533
+ { pattern: /\$'\\[0-7]{3}.*\\[0-7]{3}'/, description: 'Bash octal escape sequences constructing commands' },
534
+ { pattern: /\$'\\x[0-9a-f]{2}.*\\x[0-9a-f]{2}'/i, description: 'Bash hex escape sequences constructing commands' },
535
+ // Polyglot injection
536
+ { pattern: /python[23]?\s+-c\s+.*(?:base64|eval|exec|__import__)/i, description: 'Python with obfuscation patterns' },
537
+ { pattern: /perl\s+-e\s+.*(?:eval|unpack|decode_base64)/i, description: 'Perl with obfuscation patterns' },
538
+ { pattern: /ruby\s+-e\s+.*(?:eval|Base64|decode64)/i, description: 'Ruby with obfuscation patterns' },
539
+ // Variable obfuscation
540
+ { pattern: /\w+=[^;]*;\s*\w+=[^;]*;\s*\$\{?\w+\}?\$\{?\w+\}?/i, description: 'Variable assignment chains constructing commands' },
541
+ // Process substitution with remote content
542
+ { pattern: /<\(.*(?:curl|wget|nc)\s+/i, description: 'Remote content via process substitution' },
543
+ // Shell metacharacters — uses quote-aware matching in checkObfuscation()
544
+ // so that legitimate regex patterns inside quotes (e.g., grep "foo\|bar") are not flagged.
545
+ { pattern: /\\[;&|]/, description: 'Backslash-escaped operators or whitespace', quoteAware: true },
546
+ { pattern: /[\u200B\u200C\u200D\uFEFF\u00A0]/, description: 'Unicode whitespace characters' },
547
+ { pattern: /[\x00-\x08\x0E-\x1F]/, description: 'Control characters in command' },
548
+ { pattern: /\w#\w/, description: 'Mid-word hash (potential comment injection)' },
549
+ { pattern: /['"]-+\w/, description: 'Obfuscated flags via quotes' },
550
+ // Structural
551
+ { pattern: /#.*['"].*\n/, description: 'Comment/quote desync pattern' },
552
+ { pattern: /'[^']*\n[^']*'/, description: 'Embedded newlines in single-quoted strings' },
553
+ { pattern: /[|;&]\s*$/, description: 'Incomplete command (trailing pipe or semicolon)' },
554
+ // NOTE: IFS manipulation and /proc access are handled by dedicated
555
+ // quote-aware validators below (checkIfsInjection, checkProcSysAccess).
556
+ ];
557
+
558
+ /**
559
+ * PowerShell obfuscation patterns.
560
+ */
561
+ const PS_OBFUSCATION_PATTERNS: ObfuscationPattern[] = [
562
+ { pattern: /-EncodedCommand\b/i, description: 'PowerShell encoded command' },
563
+ { pattern: /\[Convert\]::FromBase64String.*\|\s*iex/i, description: 'Base64 decode piped to Invoke-Expression' },
564
+ { pattern: /Invoke-Expression\s+.*(\+|\[char\]|\.Replace)/i, description: 'Invoke-Expression with constructed strings' },
565
+ { pattern: /Net\.WebClient.*DownloadString.*\|\s*iex/i, description: 'Download cradle piped to iex' },
566
+ { pattern: /Invoke-WebRequest.*\|\s*iex/i, description: 'Web request piped to Invoke-Expression' },
567
+ { pattern: /Start-Process.*-WindowStyle\s+Hidden/i, description: 'Hidden process execution' },
568
+ { pattern: /\[Reflection\.Assembly\]::Load/i, description: 'Reflection-based assembly loading' },
569
+ { pattern: /-ExecutionPolicy\s+Bypass/i, description: 'Execution policy bypass' },
570
+ ];
571
+
572
+ /**
573
+ * Strip the content of single-quoted and double-quoted strings from a command,
574
+ * preserving the quotes themselves. This lets obfuscation patterns check only
575
+ * the unquoted portions of a command so that legitimate regex syntax inside
576
+ * quotes (e.g., grep "foo\|bar") is not flagged as shell obfuscation.
577
+ */
578
+ function stripQuotedContent(command: string): string {
579
+ return command.replace(/"[^"]*"|'[^']*'/g, (match) => {
580
+ // Preserve the quote characters but empty the content
581
+ const quote = match[0];
582
+ return `${quote}${quote}`;
583
+ });
584
+ }
585
+
586
+ // ---------------------------------------------------------------------------
587
+ // Quote State Machine (shared utility for quote-aware validators)
588
+ // ---------------------------------------------------------------------------
589
+
590
+ /**
591
+ * Per-character quote context. Describes the quoting state at a given position.
592
+ */
593
+ export type QuoteContext = 'none' | 'single' | 'double' | 'backtick' | 'escaped';
594
+
595
+ /**
596
+ * Analyze the quoting context of each character in a shell command.
597
+ * Returns an array of QuoteContext values, one per character, indicating
598
+ * whether that position is inside single quotes, double quotes, backticks,
599
+ * escaped, or unquoted. Handles nested escapes correctly (e.g., `\"` inside
600
+ * double quotes keeps the next character as "double", not "escaped").
601
+ */
602
+ export function analyzeQuoteState(command: string): QuoteContext[] {
603
+ const states: QuoteContext[] = new Array(command.length);
604
+ let context: 'none' | 'single' | 'double' | 'backtick' = 'none';
605
+ // Track the context to return to when a backtick closes (for backtick-in-double-quote)
606
+ let returnContext: 'none' | 'double' = 'none';
607
+
608
+ for (let i = 0; i < command.length; i++) {
609
+ const ch = command[i]!;
610
+
611
+ if (context === 'single') {
612
+ // Inside single quotes, only a closing single quote ends the context.
613
+ // No escape processing at all inside single quotes.
614
+ if (ch === "'") {
615
+ states[i] = 'none';
616
+ context = 'none';
617
+ } else {
618
+ states[i] = 'single';
619
+ }
620
+ continue;
621
+ }
622
+
623
+ if (context === 'double') {
624
+ // Inside double quotes, backslash only escapes: $, `, ", \, and newline.
625
+ if (ch === '\\' && i + 1 < command.length) {
626
+ const next = command[i + 1]!;
627
+ if ('$`"\\'.includes(next) || next === '\n') {
628
+ states[i] = 'escaped';
629
+ states[i + 1] = 'escaped';
630
+ i++; // skip the escaped character
631
+ continue;
632
+ }
633
+ }
634
+ if (ch === '"') {
635
+ states[i] = 'none';
636
+ context = 'none';
637
+ } else if (ch === '`') {
638
+ // Backticks nest inside double quotes. Track return context so we
639
+ // resume double-quote context when the backtick closes.
640
+ states[i] = 'backtick';
641
+ returnContext = 'double';
642
+ context = 'backtick';
643
+ } else {
644
+ states[i] = 'double';
645
+ }
646
+ continue;
647
+ }
648
+
649
+ if (context === 'backtick') {
650
+ if (ch === '\\' && i + 1 < command.length) {
651
+ states[i] = 'escaped';
652
+ states[i + 1] = 'escaped';
653
+ i++;
654
+ continue;
655
+ }
656
+ if (ch === '`') {
657
+ states[i] = returnContext === 'double' ? 'double' : 'none';
658
+ context = returnContext;
659
+ returnContext = 'none';
660
+ } else {
661
+ states[i] = 'backtick';
662
+ }
663
+ continue;
664
+ }
665
+
666
+ // context === 'none' (unquoted)
667
+ if (ch === '\\' && i + 1 < command.length) {
668
+ states[i] = 'escaped';
669
+ states[i + 1] = 'escaped';
670
+ i++;
671
+ continue;
672
+ }
673
+
674
+ if (ch === "'") {
675
+ states[i] = 'single'; // the quote character itself is "in" single-quote context
676
+ context = 'single';
677
+ continue;
678
+ }
679
+
680
+ if (ch === '"') {
681
+ states[i] = 'double';
682
+ context = 'double';
683
+ continue;
684
+ }
685
+
686
+ if (ch === '`') {
687
+ states[i] = 'backtick';
688
+ context = 'backtick';
689
+ continue;
690
+ }
691
+
692
+ states[i] = 'none';
693
+ }
694
+
695
+ return states;
696
+ }
697
+
698
+ /**
699
+ * Extract the unquoted portions of a command using the quote state machine.
700
+ * Returns a string where quoted characters are replaced with spaces (preserving
701
+ * positions) so that regex matches on the result correspond to unquoted regions.
702
+ */
703
+ function getUnquotedText(command: string, states: QuoteContext[]): string {
704
+ const chars: string[] = [];
705
+ for (let i = 0; i < command.length; i++) {
706
+ chars.push(states[i] === 'none' ? command[i]! : ' ');
707
+ }
708
+ return chars.join('');
709
+ }
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // Validator 2: Enhanced IFS Injection
713
+ // ---------------------------------------------------------------------------
714
+
715
+ /**
716
+ * Detect IFS variable manipulation in unquoted context.
717
+ * `IFS=` inside quotes is harmless (just a string literal).
718
+ * Unquoted `IFS=` is a shell variable assignment that can enable attacks.
719
+ */
720
+ export function checkIfsInjection(command: string, states: QuoteContext[]): SafetyCheckResult {
721
+ const unquoted = getUnquotedText(command, states);
722
+ if (/\bIFS\s*=/.test(unquoted)) {
723
+ return {
724
+ allowed: false,
725
+ reason: 'Obfuscation pattern detected: IFS variable manipulation',
726
+ };
727
+ }
728
+ return { allowed: true };
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------
732
+ // Validator 3: Enhanced proc/sys Access
733
+ // ---------------------------------------------------------------------------
734
+
735
+ /**
736
+ * Sensitive paths under /proc and /sys that can be used for exfiltration
737
+ * or system introspection attacks.
738
+ */
739
+ const PROC_SYS_PATTERNS: RegExp[] = [
740
+ // /proc exfiltration vectors
741
+ /\/proc\/[^/]*\/environ/,
742
+ /\/proc\/[^/]*\/cmdline/,
743
+ /\/proc\/[^/]*\/maps/,
744
+ /\/proc\/[^/]*\/mem\b/,
745
+ /\/proc\/[^/]*\/fd\//,
746
+ /\/proc\/[^/]*\/exe\b/,
747
+ /\/proc\/[^/]*\/cwd\b/,
748
+ /\/proc\/[^/]*\/root\b/,
749
+ /\/proc\/[^/]*\/mountinfo/,
750
+ /\/proc\/[^/]*\/status/,
751
+ // /sys sensitive paths
752
+ /\/sys\/class\/net\b/,
753
+ /\/sys\/kernel\//,
754
+ /\/sys\/firmware\//,
755
+ /\/sys\/fs\/cgroup\//,
756
+ ];
757
+
758
+ /**
759
+ * Detect access to sensitive /proc and /sys paths in unquoted context.
760
+ * Quoted references (e.g., `echo "/proc/self/environ"`) are harmless string
761
+ * literals. Unquoted references indicate actual filesystem access attempts.
762
+ */
763
+ export function checkProcSysAccess(command: string, states: QuoteContext[]): SafetyCheckResult {
764
+ const unquoted = getUnquotedText(command, states);
765
+ for (const pattern of PROC_SYS_PATTERNS) {
766
+ if (pattern.test(unquoted)) {
767
+ return {
768
+ allowed: false,
769
+ reason: 'Obfuscation pattern detected: Access to sensitive /proc or /sys path',
770
+ };
771
+ }
772
+ }
773
+ return { allowed: true };
774
+ }
775
+
776
+ // ---------------------------------------------------------------------------
777
+ // Validator 4: jq system() Blocking
778
+ // ---------------------------------------------------------------------------
779
+
780
+ /**
781
+ * Detect jq command abuse: system() calls, @sh filter for shell injection,
782
+ * and -n with module imports that could load malicious jq modules.
783
+ */
784
+ export function checkJqAbuse(command: string): SafetyCheckResult {
785
+ // Only check commands that invoke jq
786
+ if (!/\bjq\b/.test(command)) {
787
+ return { allowed: true };
788
+ }
789
+
790
+ // Block jq filters containing system( -- executes shell commands from jq
791
+ // Use dotAll (s) flag so multi-line jq filters are caught
792
+ if (/\bjq\b.*\bsystem\s*\(/s.test(command)) {
793
+ return {
794
+ allowed: false,
795
+ reason: 'Obfuscation pattern detected: jq system() call can execute arbitrary shell commands',
796
+ };
797
+ }
798
+
799
+ // Block @sh filter used for shell injection
800
+ if (/\bjq\b.*@sh\b/s.test(command)) {
801
+ return {
802
+ allowed: false,
803
+ reason: 'Obfuscation pattern detected: jq @sh filter can be used for shell injection',
804
+ };
805
+ }
806
+
807
+ // Block jq -n with import/include (module loading)
808
+ if (/\bjq\b\s+.*-n\b.*\b(import|include)\b/s.test(command) || /\bjq\b\s+.*\b(import|include)\b.*-n\b/s.test(command)) {
809
+ return {
810
+ allowed: false,
811
+ reason: 'Obfuscation pattern detected: jq module import with -n flag',
812
+ };
813
+ }
814
+
815
+ return { allowed: true };
816
+ }
817
+
818
+ // ---------------------------------------------------------------------------
819
+ // Validator 5: ANSI-C Quoting Detection
820
+ // ---------------------------------------------------------------------------
821
+
822
+ /**
823
+ * Detect ANSI-C quoting ($'...') with hex or octal escape sequences that
824
+ * encode potentially dangerous content. Simple escapes like $'\n' and $'\t'
825
+ * are legitimate and allowed.
826
+ */
827
+ export function checkAnsiCQuoting(command: string): SafetyCheckResult {
828
+ // Match $'...' patterns. We need to find all ANSI-C quoted strings and
829
+ // check if they contain hex (\xHH) or octal (\0NNN or \NNN with 3 digits) escapes.
830
+ const ansiCPattern = /\$'([^'\\]*(?:\\.[^'\\]*)*)'/g;
831
+ let match: RegExpExecArray | null;
832
+
833
+ while ((match = ansiCPattern.exec(command)) !== null) {
834
+ const content = match[1] ?? '';
835
+
836
+ // Check for hex escapes (\xHH)
837
+ const hasHex = /\\x[0-9a-fA-F]{2}/.test(content);
838
+ // Check for octal escapes (\0NNN or \NNN where N are 3 octal digits)
839
+ const hasOctal = /\\0[0-7]{1,3}/.test(content) || /\\[1-3][0-7]{2}/.test(content);
840
+
841
+ if (hasHex || hasOctal) {
842
+ return {
843
+ allowed: false,
844
+ reason: 'Obfuscation pattern detected: ANSI-C quoting with hex/octal escapes can encode hidden commands',
845
+ };
846
+ }
847
+ }
848
+
849
+ return { allowed: true };
850
+ }
851
+
852
+ // ---------------------------------------------------------------------------
853
+ // Validator 6: Enhanced Heredoc Validation
854
+ // ---------------------------------------------------------------------------
855
+
856
+ /**
857
+ * Detect heredoc patterns and validate their content. Unquoted heredoc
858
+ * delimiters (<<EOF) allow variable expansion and command substitution in
859
+ * the body, which can be used for injection. Quoted delimiters (<<'EOF')
860
+ * are treated as literal text and are safe.
861
+ */
862
+ export function checkHeredocInjection(command: string): SafetyCheckResult {
863
+ // Match heredoc operators: <<[-]?DELIMITER or <<[-]?"DELIMITER" or <<[-]?'DELIMITER'
864
+ // We look for the delimiter, then try to find the body if it is inline (multi-line command).
865
+ const heredocPattern = /<<-?\s*(["']?)(\w+)\1/g;
866
+ let match: RegExpExecArray | null;
867
+
868
+ while ((match = heredocPattern.exec(command)) !== null) {
869
+ const quoteChar = match[1] ?? '';
870
+ const delimiter = match[2] ?? '';
871
+ const isQuoted = quoteChar !== '';
872
+
873
+ if (isQuoted || !delimiter) {
874
+ // Quoted heredocs are safe (no expansion), skip
875
+ continue;
876
+ }
877
+
878
+ // For unquoted heredocs, check if the body (text after the delimiter line
879
+ // and before the closing delimiter) contains injection patterns.
880
+ const afterMatch = command.substring(match.index + match[0].length);
881
+
882
+ // The body starts after a newline following the heredoc operator
883
+ const newlineIdx = afterMatch.indexOf('\n');
884
+ if (newlineIdx === -1) continue; // no body present in the command string
885
+
886
+ const bodyAndRest = afterMatch.substring(newlineIdx + 1);
887
+ const closingPattern = new RegExp(`^${delimiter}\\s*$`, 'm');
888
+ const closingMatch = closingPattern.exec(bodyAndRest);
889
+ const body = closingMatch ? bodyAndRest.substring(0, closingMatch.index) : bodyAndRest;
890
+
891
+ // Check the heredoc body for injection patterns
892
+ const injectionPatterns = [
893
+ /\$\(/, // command substitution
894
+ /`[^`]+`/, // backtick command substitution
895
+ /\$\{.*[^}]*\}/, // parameter expansion with manipulation
896
+ ];
897
+
898
+ for (const pattern of injectionPatterns) {
899
+ if (pattern.test(body)) {
900
+ return {
901
+ allowed: false,
902
+ reason: 'Obfuscation pattern detected: Unquoted heredoc with command substitution in body',
903
+ };
904
+ }
905
+ }
906
+ }
907
+
908
+ return { allowed: true };
909
+ }
910
+
911
+ // ---------------------------------------------------------------------------
912
+ // Validator 7: Brace Expansion Detection
913
+ // ---------------------------------------------------------------------------
914
+
915
+ /**
916
+ * Detect brace expansion patterns ({a,b} or {1..N}) in unquoted context
917
+ * that target suspicious paths or combine with dangerous commands.
918
+ */
919
+ export function checkBraceExpansion(command: string, states: QuoteContext[]): SafetyCheckResult {
920
+ const unquoted = getUnquotedText(command, states);
921
+
922
+ // Find {x,y} patterns in unquoted text. Only flag when combined with
923
+ // destructive commands or when referencing sensitive system paths.
924
+ // Benign patterns like `diff {old,new}/config.ts` should pass.
925
+ const commaExpansion = /\{[^}]*,[^}]*\}/g;
926
+ let match: RegExpExecArray | null;
927
+
928
+ // Extract leading command for context-aware decisions
929
+ const firstToken = unquoted.trim().split(/\s+/)[0] ?? '';
930
+ const destructiveCommands = ['rm', 'chmod', 'chown', 'mv', 'rmdir', 'dd', 'shred'];
931
+
932
+ while ((match = commaExpansion.exec(unquoted)) !== null) {
933
+ const content = match[0];
934
+ // Flag if expansion references sensitive paths
935
+ if (/\betc\b|passwd|shadow|authorized_keys|\bssh\b|\bproc\b|\bsys\b/.test(content)) {
936
+ return {
937
+ allowed: false,
938
+ reason: 'Obfuscation pattern detected: Brace expansion referencing sensitive paths',
939
+ };
940
+ }
941
+ // Flag if any element starts with absolute path AND command is destructive
942
+ if (/\{\/|,\s*\//.test(content) && destructiveCommands.includes(firstToken.toLowerCase())) {
943
+ return {
944
+ allowed: false,
945
+ reason: 'Obfuscation pattern detected: Brace expansion with absolute paths in destructive command',
946
+ };
947
+ }
948
+ }
949
+
950
+ // Check for range expansion {N..M} combined with dangerous commands.
951
+ // Look at the first token of the overall command to determine context.
952
+ const rangeExpansion = /\{[^}]*\.\.[^}]*\}/g;
953
+ if (rangeExpansion.test(unquoted)) {
954
+ // Extract the leading command name from the unquoted text
955
+ const firstToken = unquoted.trim().split(/\s+/)[0] ?? '';
956
+ const destructiveCommands = ['rm', 'chmod', 'chown', 'mv', 'cp'];
957
+ if (destructiveCommands.includes(firstToken.toLowerCase())) {
958
+ return {
959
+ allowed: false,
960
+ reason: 'Obfuscation pattern detected: Brace range expansion with destructive command',
961
+ };
962
+ }
963
+ }
964
+
965
+ return { allowed: true };
966
+ }
967
+
968
+ // ---------------------------------------------------------------------------
969
+ // Validator 8: Enhanced Escaped Character Detection
970
+ // ---------------------------------------------------------------------------
971
+
972
+ /**
973
+ * Detect escape chains and printf hex/octal patterns that can hide dangerous
974
+ * commands from string-level pattern matching.
975
+ */
976
+ export function checkEnhancedEscapes(command: string, states: QuoteContext[]): SafetyCheckResult {
977
+ // Detect double-backslash before shell operators in unquoted context.
978
+ // In "echo hello\\;rm", the state machine marks both backslashes as escaped
979
+ // (the first escapes the second), leaving ";" as unquoted (none). We look
980
+ // for an escaped pair where the raw characters are both backslashes, followed
981
+ // immediately by an unquoted shell operator.
982
+ const shellOps = new Set([';', '&', '|']);
983
+ for (let i = 0; i + 2 < command.length; i++) {
984
+ if (
985
+ states[i] === 'escaped' &&
986
+ states[i + 1] === 'escaped' &&
987
+ command[i] === '\\' &&
988
+ command[i + 1] === '\\' &&
989
+ states[i + 2] === 'none' &&
990
+ shellOps.has(command[i + 2]!)
991
+ ) {
992
+ return {
993
+ allowed: false,
994
+ reason: 'Obfuscation pattern detected: Double-escaped shell operator (live operator hidden behind escape chain)',
995
+ };
996
+ }
997
+ }
998
+
999
+ // Detect printf with hex/octal that spells dangerous commands.
1000
+ // We look for printf calls with multiple escape sequences.
1001
+ const printfMatch = command.match(/\bprintf\s+(['"])((?:\\x[0-9a-fA-F]{2}|\\[0-7]{3}){3,})\1/);
1002
+ if (printfMatch) {
1003
+ return {
1004
+ allowed: false,
1005
+ reason: 'Obfuscation pattern detected: printf with encoded character sequences',
1006
+ };
1007
+ }
1008
+
1009
+ // Also catch printf with %b and hex/octal in a variable
1010
+ if (/\bprintf\s+['"]?%b['"]?\s+.*(?:\\x[0-9a-fA-F]{2}|\\[0-7]{3}){3,}/.test(command)) {
1011
+ return {
1012
+ allowed: false,
1013
+ reason: 'Obfuscation pattern detected: printf %b with encoded character sequences',
1014
+ };
1015
+ }
1016
+
1017
+ return { allowed: true };
1018
+ }
1019
+
1020
+ // ---------------------------------------------------------------------------
1021
+ // Main obfuscation check
1022
+ // ---------------------------------------------------------------------------
1023
+
1024
+ /**
1025
+ * Check a command for obfuscation and injection patterns.
1026
+ */
1027
+ export function checkObfuscation(command: string): SafetyCheckResult {
1028
+ // Strip invisible characters first
1029
+ const cleaned = stripInvisibleChars(command);
1030
+
1031
+ // Check if the cleaned command differs significantly (invisible chars were present)
1032
+ if (cleaned.length < command.length) {
1033
+ return {
1034
+ allowed: false,
1035
+ reason: 'Command contains invisible Unicode characters that may be used for obfuscation.',
1036
+ };
1037
+ }
1038
+
1039
+ // Length check
1040
+ if (command.length > 10000) {
1041
+ return {
1042
+ allowed: false,
1043
+ reason: 'Command exceeds maximum length (10,000 characters).',
1044
+ };
1045
+ }
1046
+
1047
+ // Check download-and-execute pattern (curl | bash)
1048
+ const hasPipeToShell = /\|\s*(ba)?sh\b/i.test(command) || /\|\s*\bsh\b/.test(command);
1049
+ if (hasPipeToShell && /(curl|wget)\s+/i.test(command)) {
1050
+ const urls = extractUrls(command);
1051
+ if (urls.length === 1 && isSafeDownloadUrl(urls[0]!)) {
1052
+ // Safe URL, allow
1053
+ } else {
1054
+ return {
1055
+ allowed: false,
1056
+ reason: 'Download-and-execute pattern detected (curl/wget piped to shell). This requires explicit approval.',
1057
+ };
1058
+ }
1059
+ }
1060
+
1061
+ // Platform-specific patterns
1062
+ const patterns = process.platform === 'win32'
1063
+ ? PS_OBFUSCATION_PATTERNS
1064
+ : UNIX_OBFUSCATION_PATTERNS;
1065
+
1066
+ // Quote-stripped version for patterns where matches inside quoted strings
1067
+ // are benign (e.g., backslash-escaped operators in grep regex patterns).
1068
+ const unquotedCommand = stripQuotedContent(command);
1069
+
1070
+ for (const { pattern, description, quoteAware } of patterns) {
1071
+ // Quote-aware patterns only match against unquoted portions of the command.
1072
+ // e.g., "echo test\;rm -rf /" is obfuscation (unquoted), but
1073
+ // "grep 'foo\|bar'" is legitimate grep regex (inside quotes).
1074
+ const target = quoteAware ? unquotedCommand : command;
1075
+ if (pattern.test(target)) {
1076
+ return {
1077
+ allowed: false,
1078
+ reason: `Obfuscation pattern detected: ${description}`,
1079
+ };
1080
+ }
1081
+ }
1082
+
1083
+ // --- Enhanced validators (quote-state-machine-powered) ---
1084
+
1085
+ const quoteStates = analyzeQuoteState(command);
1086
+
1087
+ // Validator 2: Enhanced IFS injection (quote-aware)
1088
+ const ifsResult = checkIfsInjection(command, quoteStates);
1089
+ if (!ifsResult.allowed) return ifsResult;
1090
+
1091
+ // Validator 3: Enhanced proc/sys access (quote-aware)
1092
+ const procResult = checkProcSysAccess(command, quoteStates);
1093
+ if (!procResult.allowed) return procResult;
1094
+
1095
+ // Validator 4: jq system() blocking
1096
+ const jqResult = checkJqAbuse(command);
1097
+ if (!jqResult.allowed) return jqResult;
1098
+
1099
+ // Validator 5: ANSI-C quoting detection
1100
+ const ansiCResult = checkAnsiCQuoting(command);
1101
+ if (!ansiCResult.allowed) return ansiCResult;
1102
+
1103
+ // Validator 6: Enhanced heredoc validation
1104
+ const heredocResult = checkHeredocInjection(command);
1105
+ if (!heredocResult.allowed) return heredocResult;
1106
+
1107
+ // Validator 7: Brace expansion detection
1108
+ const braceResult = checkBraceExpansion(command, quoteStates);
1109
+ if (!braceResult.allowed) return braceResult;
1110
+
1111
+ // Validator 8: Enhanced escaped character detection
1112
+ const escapeResult = checkEnhancedEscapes(command, quoteStates);
1113
+ if (!escapeResult.allowed) return escapeResult;
1114
+
1115
+ return { allowed: true };
1116
+ }
1117
+
1118
+ // ---------------------------------------------------------------------------
1119
+ // Layer 6: Script Preflight
1120
+ // ---------------------------------------------------------------------------
1121
+
1122
+ /**
1123
+ * Check if a command is running a script file, and if so,
1124
+ * scan the script for shell syntax bleed.
1125
+ */
1126
+ export async function checkScriptPreflight(command: string, cwd: string): Promise<SafetyCheckResult> {
1127
+ // Detect script execution patterns
1128
+ const scriptPatterns = [
1129
+ /^python[23]?\s+(\S+)/i,
1130
+ /^node\s+(\S+)/i,
1131
+ /^ts-node\s+(\S+)/i,
1132
+ /^ruby\s+(\S+)/i,
1133
+ /^perl\s+(\S+)/i,
1134
+ ];
1135
+
1136
+ for (const pattern of scriptPatterns) {
1137
+ const match = command.match(pattern);
1138
+ if (!match?.[1]) continue;
1139
+
1140
+ const scriptPath = path.resolve(cwd, match[1]);
1141
+
1142
+ try {
1143
+ const content = await fs.promises.readFile(scriptPath, 'utf8');
1144
+ const firstLines = content.split('\n').slice(0, 10);
1145
+
1146
+ // Check for bare $VARS in Python/JS files
1147
+ const ext = path.extname(scriptPath).toLowerCase();
1148
+ if (['.py', '.js', '.ts', '.mjs', '.cjs'].includes(ext)) {
1149
+ for (const line of firstLines) {
1150
+ // Shell variable patterns that don't belong in Python/JS
1151
+ if (/^\s*\$[A-Z_]+\b/.test(line) && !/^\s*\/\//.test(line) && !/^\s*#/.test(line)) {
1152
+ return {
1153
+ allowed: false,
1154
+ reason: `Script ${scriptPath} contains shell variable syntax ($VAR) that may indicate shell syntax bleed.`,
1155
+ };
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ // Check for shell commands at start of script
1161
+ if (['.py', '.js', '.ts'].includes(ext)) {
1162
+ const firstLine = (firstLines[0] ?? '').trim();
1163
+ if (/^(cd|ls|cat|echo|export|source|alias)\s/.test(firstLine) && !firstLine.startsWith('#!')) {
1164
+ return {
1165
+ allowed: false,
1166
+ reason: `Script ${scriptPath} starts with shell commands, suggesting mixed file contexts.`,
1167
+ };
1168
+ }
1169
+ }
1170
+ } catch {
1171
+ // Can't read script file, skip check
1172
+ }
1173
+ }
1174
+
1175
+ return { allowed: true };
1176
+ }
1177
+
1178
+ // ---------------------------------------------------------------------------
1179
+ // Layer 7: Auto-Mode Classifier (Stub)
1180
+ // ---------------------------------------------------------------------------
1181
+
1182
+ /**
1183
+ * Auto-mode classifier that uses the utility model to classify whether
1184
+ * a command should be blocked in autonomous mode.
1185
+ *
1186
+ * The full implementation will:
1187
+ * 1. Fast check (256 max tokens): quick classification
1188
+ * 2. Full analysis (4096 max tokens): if fast check is uncertain
1189
+ *
1190
+ * Fail-safe behavior: when auto-approve mode is active (isAutoApprove=true)
1191
+ * but no classifier function is available, this layer BLOCKS the command.
1192
+ * When auto-approve is not active, the consumer's permission resolver
1193
+ * (beforeToolCall) has already approved, so this layer passes through.
1194
+ */
1195
+ export async function checkAutoModeClassifier(
1196
+ _command: string,
1197
+ _description: string | undefined,
1198
+ _utilityComplete?: ((context: unknown) => Promise<unknown>) | undefined,
1199
+ isAutoApprove?: boolean,
1200
+ ): Promise<SafetyCheckResult> {
1201
+ // When auto-approve is not active, the consumer's permission system has
1202
+ // already handled approval. Layer 7 is defense-in-depth for auto mode only.
1203
+ if (!isAutoApprove) {
1204
+ return { allowed: true };
1205
+ }
1206
+
1207
+ // Auto-approve is active but no classifier function is available.
1208
+ // Fail-safe: block until the classifier is fully implemented.
1209
+ if (!_utilityComplete) {
1210
+ return {
1211
+ allowed: false,
1212
+ reason: 'Auto-mode classifier not yet implemented. Command requires manual approval.',
1213
+ };
1214
+ }
1215
+
1216
+ // TODO: Full implementation will call utilityComplete for classification.
1217
+ // For now, block in auto-approve mode even with a utility model, since
1218
+ // the classification prompt/logic is not yet built.
1219
+ return {
1220
+ allowed: false,
1221
+ reason: 'Auto-mode classifier not yet implemented. Command requires manual approval.',
1222
+ };
1223
+ }
1224
+
1225
+ // ---------------------------------------------------------------------------
1226
+ // Composite safety check
1227
+ // ---------------------------------------------------------------------------
1228
+
1229
+ /**
1230
+ * Run all safety layers on a command.
1231
+ * Returns the first failure or { allowed: true } if all pass.
1232
+ */
1233
+ export async function runSafetyChecks(
1234
+ command: string,
1235
+ workingDirectory: string,
1236
+ currentCwd: string,
1237
+ options?: {
1238
+ utilityComplete?: ((context: unknown) => Promise<unknown>) | undefined;
1239
+ description?: string | undefined;
1240
+ /** Whether the consumer is in auto-approve mode. When true and no classifier is available, Layer 7 blocks. */
1241
+ isAutoApprove?: boolean | undefined;
1242
+ },
1243
+ ): Promise<SafetyCheckResult> {
1244
+ // Layer 2: Critical path protection
1245
+ // Check each sub-command independently for critical path access
1246
+ const subCommands = splitOnShellOperators(command);
1247
+ for (const sub of subCommands) {
1248
+ const subTokens = sub.split(/\s+/);
1249
+ for (const token of subTokens) {
1250
+ if (token.startsWith('/') || token.startsWith('~') || (process.platform === 'win32' && /^[A-Za-z]:\\/.test(token))) {
1251
+ if (isCriticalPath(token)) {
1252
+ const subClassification = classifySingleCommand(sub);
1253
+ if (subClassification === 'write' || subClassification === 'create' || subClassification === 'unknown') {
1254
+ return {
1255
+ allowed: false,
1256
+ reason: 'This command would modify a critical system directory. This cannot be auto-allowed.',
1257
+ classification: classifyCommand(command),
1258
+ };
1259
+ }
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+
1265
+ // Layer 4: Path validation for write commands (handles all sub-commands)
1266
+ const pathResult = validateWritePaths(command, workingDirectory, currentCwd);
1267
+ if (!pathResult.allowed) return pathResult;
1268
+
1269
+ // Layer 5: Obfuscation detection
1270
+ const obfuscationResult = checkObfuscation(command);
1271
+ if (!obfuscationResult.allowed) return obfuscationResult;
1272
+
1273
+ // Layer 6: Script preflight
1274
+ const scriptResult = await checkScriptPreflight(command, currentCwd);
1275
+ if (!scriptResult.allowed) return scriptResult;
1276
+
1277
+ // Layer 6.5: Interactive command detection (UX gate, not security).
1278
+ // Sits after all security layers so obviously-malicious commands are
1279
+ // blocked first; prevents silent timeout loss on editors, pagers, and
1280
+ // REPLs.
1281
+ const interactiveResult = checkInteractive(command);
1282
+ if (!interactiveResult.allowed) return interactiveResult;
1283
+
1284
+ // Layer 7: Auto-mode classifier
1285
+ const classifierResult = await checkAutoModeClassifier(
1286
+ command,
1287
+ options?.description,
1288
+ options?.utilityComplete,
1289
+ options?.isAutoApprove,
1290
+ );
1291
+ if (!classifierResult.allowed) return classifierResult;
1292
+
1293
+ return {
1294
+ allowed: true,
1295
+ classification: classifyCommand(command),
1296
+ };
1297
+ }