@librechat/agents 3.1.77-dev.1 → 3.1.78-dev.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 (188) hide show
  1. package/dist/cjs/common/enum.cjs +54 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +148 -4
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
  6. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
  7. package/dist/cjs/llm/openai/index.cjs +317 -1
  8. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  9. package/dist/cjs/main.cjs +90 -0
  10. package/dist/cjs/main.cjs.map +1 -1
  11. package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
  12. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
  13. package/dist/cjs/messages/prune.cjs +27 -0
  14. package/dist/cjs/messages/prune.cjs.map +1 -1
  15. package/dist/cjs/messages/recency.cjs +99 -0
  16. package/dist/cjs/messages/recency.cjs.map +1 -0
  17. package/dist/cjs/run.cjs +30 -0
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/summarization/node.cjs +100 -6
  20. package/dist/cjs/summarization/node.cjs.map +1 -1
  21. package/dist/cjs/tools/ToolNode.cjs +635 -23
  22. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  23. package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
  24. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
  25. package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
  26. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
  27. package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
  28. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
  29. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
  30. package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
  31. package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
  32. package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
  33. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
  34. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
  35. package/dist/cjs/tools/local/attachments.cjs +183 -0
  36. package/dist/cjs/tools/local/attachments.cjs.map +1 -0
  37. package/dist/cjs/tools/local/bashAst.cjs +129 -0
  38. package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
  39. package/dist/cjs/tools/local/editStrategies.cjs +188 -0
  40. package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
  41. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
  42. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
  43. package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
  44. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
  45. package/dist/cjs/tools/local/textEncoding.cjs +30 -0
  46. package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
  47. package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
  48. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  50. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  51. package/dist/esm/common/enum.mjs +53 -1
  52. package/dist/esm/common/enum.mjs.map +1 -1
  53. package/dist/esm/graphs/Graph.mjs +149 -5
  54. package/dist/esm/graphs/Graph.mjs.map +1 -1
  55. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
  56. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
  57. package/dist/esm/llm/openai/index.mjs +318 -2
  58. package/dist/esm/llm/openai/index.mjs.map +1 -1
  59. package/dist/esm/main.mjs +17 -2
  60. package/dist/esm/main.mjs.map +1 -1
  61. package/dist/esm/messages/anthropicToolCache.mjs +99 -0
  62. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
  63. package/dist/esm/messages/prune.mjs +26 -1
  64. package/dist/esm/messages/prune.mjs.map +1 -1
  65. package/dist/esm/messages/recency.mjs +97 -0
  66. package/dist/esm/messages/recency.mjs.map +1 -0
  67. package/dist/esm/run.mjs +30 -0
  68. package/dist/esm/run.mjs.map +1 -1
  69. package/dist/esm/summarization/node.mjs +100 -6
  70. package/dist/esm/summarization/node.mjs.map +1 -1
  71. package/dist/esm/tools/ToolNode.mjs +635 -23
  72. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  73. package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
  74. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
  75. package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
  76. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
  77. package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
  78. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
  79. package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
  80. package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
  81. package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
  82. package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
  83. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
  84. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
  85. package/dist/esm/tools/local/attachments.mjs +180 -0
  86. package/dist/esm/tools/local/attachments.mjs.map +1 -0
  87. package/dist/esm/tools/local/bashAst.mjs +126 -0
  88. package/dist/esm/tools/local/bashAst.mjs.map +1 -0
  89. package/dist/esm/tools/local/editStrategies.mjs +185 -0
  90. package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
  91. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
  92. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
  93. package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
  94. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
  95. package/dist/esm/tools/local/textEncoding.mjs +27 -0
  96. package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
  97. package/dist/esm/tools/local/workspaceFS.mjs +49 -0
  98. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
  99. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  100. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  101. package/dist/types/common/enum.d.ts +39 -1
  102. package/dist/types/graphs/Graph.d.ts +34 -0
  103. package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
  104. package/dist/types/hooks/index.d.ts +2 -0
  105. package/dist/types/index.d.ts +1 -0
  106. package/dist/types/llm/openai/index.d.ts +17 -0
  107. package/dist/types/messages/anthropicToolCache.d.ts +51 -0
  108. package/dist/types/messages/index.d.ts +2 -0
  109. package/dist/types/messages/prune.d.ts +11 -0
  110. package/dist/types/messages/recency.d.ts +64 -0
  111. package/dist/types/run.d.ts +21 -0
  112. package/dist/types/tools/ToolNode.d.ts +145 -2
  113. package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
  114. package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
  115. package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
  116. package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
  117. package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
  118. package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
  119. package/dist/types/tools/local/attachments.d.ts +84 -0
  120. package/dist/types/tools/local/bashAst.d.ts +11 -0
  121. package/dist/types/tools/local/editStrategies.d.ts +28 -0
  122. package/dist/types/tools/local/index.d.ts +12 -0
  123. package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
  124. package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
  125. package/dist/types/tools/local/textEncoding.d.ts +21 -0
  126. package/dist/types/tools/local/workspaceFS.d.ts +49 -0
  127. package/dist/types/types/hitl.d.ts +56 -27
  128. package/dist/types/types/run.d.ts +8 -1
  129. package/dist/types/types/summarize.d.ts +30 -0
  130. package/dist/types/types/tools.d.ts +341 -6
  131. package/package.json +21 -2
  132. package/src/common/enum.ts +54 -0
  133. package/src/graphs/Graph.ts +164 -6
  134. package/src/hooks/__tests__/compactHooks.test.ts +38 -2
  135. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
  136. package/src/hooks/createWorkspacePolicyHook.ts +355 -0
  137. package/src/hooks/index.ts +6 -0
  138. package/src/index.ts +1 -0
  139. package/src/llm/openai/deepseek.test.ts +479 -0
  140. package/src/llm/openai/index.ts +484 -1
  141. package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
  142. package/src/messages/__tests__/recency.test.ts +267 -0
  143. package/src/messages/anthropicToolCache.ts +116 -0
  144. package/src/messages/index.ts +2 -0
  145. package/src/messages/prune.ts +27 -1
  146. package/src/messages/recency.ts +155 -0
  147. package/src/run.ts +31 -0
  148. package/src/scripts/compare_pi_vs_ours.ts +840 -0
  149. package/src/scripts/local_engine.ts +166 -0
  150. package/src/scripts/local_engine_checkpointer.ts +205 -0
  151. package/src/scripts/local_engine_compile.ts +263 -0
  152. package/src/scripts/local_engine_hooks.ts +226 -0
  153. package/src/scripts/local_engine_image.ts +201 -0
  154. package/src/scripts/local_engine_ptc.ts +151 -0
  155. package/src/scripts/local_engine_workspace.ts +258 -0
  156. package/src/scripts/summarization-recency.ts +462 -0
  157. package/src/specs/prune.test.ts +39 -0
  158. package/src/summarization/__tests__/node.test.ts +499 -3
  159. package/src/summarization/node.ts +124 -7
  160. package/src/tools/ToolNode.ts +769 -20
  161. package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
  162. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
  163. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
  164. package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
  165. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
  166. package/src/tools/__tests__/directToolHooks.test.ts +411 -0
  167. package/src/tools/__tests__/localToolNames.test.ts +73 -0
  168. package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
  169. package/src/tools/local/CompileCheckTool.ts +278 -0
  170. package/src/tools/local/FileCheckpointer.ts +93 -0
  171. package/src/tools/local/LocalCodingTools.ts +1342 -0
  172. package/src/tools/local/LocalExecutionEngine.ts +1329 -0
  173. package/src/tools/local/LocalExecutionTools.ts +167 -0
  174. package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
  175. package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
  176. package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
  177. package/src/tools/local/attachments.ts +251 -0
  178. package/src/tools/local/bashAst.ts +151 -0
  179. package/src/tools/local/editStrategies.ts +188 -0
  180. package/src/tools/local/index.ts +12 -0
  181. package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
  182. package/src/tools/local/syntaxCheck.ts +243 -0
  183. package/src/tools/local/textEncoding.ts +37 -0
  184. package/src/tools/local/workspaceFS.ts +89 -0
  185. package/src/types/hitl.ts +56 -27
  186. package/src/types/run.ts +12 -1
  187. package/src/types/summarize.ts +31 -0
  188. package/src/types/tools.ts +359 -7
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { applyEdit, locateEdit } from '../editStrategies';
3
+
4
+ /**
5
+ * Focused unit tests for the fuzzy edit-matching chain. The chain is
6
+ * what determines whether `edit_file` succeeds or silently corrupts a
7
+ * file when the model's `oldString` doesn't byte-match the on-disk
8
+ * content (different indentation, trailing whitespace, etc.). Pinning
9
+ * the strategy boundaries here so a regression in any one strategy
10
+ * surfaces fast.
11
+ *
12
+ * Order of strategies (defined in editStrategies.ts):
13
+ * 1. exact
14
+ * 2. line-trimmed
15
+ * 3. whitespace-normalized
16
+ * 4. indentation-flexible
17
+ */
18
+
19
+ describe('editStrategies › locateEdit', () => {
20
+ it('returns an exact-strategy match for a literal byte-equal substring', () => {
21
+ const source = 'one\ntwo\nthree\n';
22
+ const m = locateEdit(source, 'two');
23
+ expect(m).not.toBeNull();
24
+ expect(m?.strategy).toBe('exact');
25
+ expect(applyEdit(source, m!, '2')).toBe('one\n2\nthree\n');
26
+ });
27
+
28
+ it('matches at the very start of the source', () => {
29
+ const source = 'first\nsecond\n';
30
+ const m = locateEdit(source, 'first');
31
+ expect(m).not.toBeNull();
32
+ expect(m?.strategy).toBe('exact');
33
+ expect(m?.start).toBe(0);
34
+ });
35
+
36
+ it('matches at the very end of the source (no trailing newline)', () => {
37
+ const source = 'a\nb\nlast';
38
+ const m = locateEdit(source, 'last');
39
+ expect(m).not.toBeNull();
40
+ expect(m?.strategy).toBe('exact');
41
+ expect(applyEdit(source, m!, 'LAST')).toBe('a\nb\nLAST');
42
+ });
43
+
44
+ it('falls back to line-trimmed when the model lost trailing whitespace inside a multi-line needle', () => {
45
+ // Source has trailing tab on the second line; needle has none.
46
+ // Multi-line needle so the exact strategy can't match (the source
47
+ // line literally has the tab). Line-trimmed compares trimEnd vs
48
+ // trimEnd and lets it through.
49
+ const source = 'fn foo() {\n return 42\t\n}\n';
50
+ const m = locateEdit(source, 'fn foo() {\n return 42\n}');
51
+ expect(m).not.toBeNull();
52
+ expect(['line-trimmed', 'whitespace-normalized']).toContain(m?.strategy);
53
+ });
54
+
55
+ it('falls back to whitespace-normalized when interior whitespace differs', () => {
56
+ // Source uses tabs, needle uses spaces.
57
+ const source = 'if\t(x)\t{\n\treturn\ty;\n}';
58
+ const m = locateEdit(source, 'if (x) {\n return y;\n}');
59
+ expect(m).not.toBeNull();
60
+ expect(['whitespace-normalized', 'line-trimmed', 'exact']).toContain(
61
+ m?.strategy
62
+ );
63
+ });
64
+
65
+ it('falls back to indentation-flexible when block leading indent differs', () => {
66
+ // Source is indented by 4 spaces, needle by 2 — semantically the
67
+ // same block, lexically different indent.
68
+ const source = ' function go() {\n return 1;\n }\n';
69
+ const m = locateEdit(source, ' function go() {\n return 1;\n }');
70
+ expect(m).not.toBeNull();
71
+ // Any of the looser strategies could legitimately win here; what
72
+ // matters is that the chain finds *a* match instead of giving up.
73
+ expect(m?.strategy).not.toBe('exact');
74
+ });
75
+
76
+ it('returns null when nothing in the chain matches', () => {
77
+ const source = 'alpha\nbeta\ngamma\n';
78
+ const m = locateEdit(source, 'this string is nowhere in the source');
79
+ expect(m).toBeNull();
80
+ });
81
+
82
+ it('rejects exact-strategy match when oldString appears more than once (ambiguous)', () => {
83
+ // The exact strategy explicitly returns null on >1 hit so an
84
+ // ambiguous edit can't silently pick the wrong span. Pinning
85
+ // because the audit-of-audit (follow-up F2) flagged that this
86
+ // boundary was claimed in the commit message but not actually
87
+ // covered by a test.
88
+ //
89
+ // (Whether the *looser* strategies — line-trimmed,
90
+ // whitespace-normalized, indentation-flexible — should also fail
91
+ // closed on multi-match is a separate design call: today they
92
+ // reject duplicate matches at their OWN granularity but the
93
+ // chain may then fall through to a strategy that picks one
94
+ // unambiguously. Out of scope for this test.)
95
+ const source = 'foo bar\nfoo bar\nbaz';
96
+ expect(locateEdit(source, 'foo bar')).toBeNull();
97
+ });
98
+
99
+ it('returns null on empty oldString (no anchor to locate)', () => {
100
+ const source = 'a\nb\n';
101
+ const m = locateEdit(source, '');
102
+ expect(m).toBeNull();
103
+ });
104
+
105
+ it('handles multi-line needles spanning a blank line', () => {
106
+ const source = 'before\n\nafter\n';
107
+ const m = locateEdit(source, 'before\n\nafter');
108
+ expect(m).not.toBeNull();
109
+ expect(m?.strategy).toBe('exact');
110
+ });
111
+
112
+ it('handles unicode content (emoji, combining characters)', () => {
113
+ const source = 'header\n // ✅ done — café\nfooter\n';
114
+ const m = locateEdit(source, ' // ✅ done — café');
115
+ expect(m).not.toBeNull();
116
+ expect(m?.strategy).toBe('exact');
117
+ });
118
+ });
119
+
120
+ describe('editStrategies › applyEdit', () => {
121
+ it('produces the new source by splicing newString into the matched span', () => {
122
+ const source = 'aaa BAR ccc';
123
+ const m = locateEdit(source, 'BAR');
124
+ expect(m).not.toBeNull();
125
+ expect(applyEdit(source, m!, 'baz')).toBe('aaa baz ccc');
126
+ });
127
+
128
+ it('is a no-op when newString equals the matched span', () => {
129
+ const source = 'x y z';
130
+ const m = locateEdit(source, 'y');
131
+ expect(m).not.toBeNull();
132
+ expect(applyEdit(source, m!, 'y')).toBe('x y z');
133
+ });
134
+ });
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Detects whether a file on disk is an LLM-renderable attachment
3
+ * (image / PDF) and produces the LangChain `MessageContentComplex[]`
4
+ * payload a `ToolMessage` needs to actually surface those bytes to
5
+ * the vision-capable model.
6
+ *
7
+ * Same approach as LibreChat's `api/server/utils/files.js`: sniff the
8
+ * magic bytes (NOT the extension) so a mislabelled `.png` that's
9
+ * really a binary blob doesn't get embedded as an image. Inlined for
10
+ * the five formats we actually care about (PNG / JPEG / GIF / WebP /
11
+ * PDF) instead of pulling the ESM-only `file-type` package — keeps
12
+ * the test setup CJS-clean.
13
+ *
14
+ * Provider compatibility:
15
+ * - Anthropic: tool_result content arrays accept `image` / `image_url`
16
+ * blocks; LangChain's anthropic adapter at
17
+ * `node_modules/@langchain/anthropic/dist/utils/message_inputs.js`
18
+ * converts them to native `image` source blocks.
19
+ * - OpenAI Chat Completions: image_url blocks in tool messages are
20
+ * accepted on vision-capable models.
21
+ * - OpenAI Responses API: tool messages are flattened to plain text;
22
+ * image_url blocks degrade to a JSON description (still useful as
23
+ * a textual hint to the model).
24
+ * - Google: image blocks in tool responses are accepted on Gemini
25
+ * vision models.
26
+ *
27
+ * Configuration:
28
+ * - `local.attachReadAttachments` (default `'images-only'`) controls
29
+ * which file kinds are returned as inline attachments. Other kinds
30
+ * fall through to the existing binary-stub path.
31
+ * - `local.maxAttachmentBytes` (default 5 MB) caps the pre-encoding
32
+ * size; oversize attachments degrade to a stub describing the
33
+ * refusal so the model isn't surprised.
34
+ */
35
+
36
+ import { open as fsOpen, readFile as fsReadFile } from 'fs/promises';
37
+ import type { WorkspaceFS } from './workspaceFS';
38
+
39
+ /**
40
+ * Magic-byte sniff for the small set of image/PDF formats we care
41
+ * about. We avoided pulling in `file-type` (ESM-only, awkward under
42
+ * ts-jest) since the universe of attachments we want to embed is
43
+ * tiny: PNG, JPEG, GIF, WebP, PDF. All have well-known signatures in
44
+ * the first 12 bytes.
45
+ *
46
+ * Returns `undefined` on no match — caller treats as text/unknown.
47
+ */
48
+ function sniffMime(buffer: Buffer): string | undefined {
49
+ if (buffer.length < 4) return undefined;
50
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
51
+ if (
52
+ buffer.length >= 8 &&
53
+ buffer[0] === 0x89 &&
54
+ buffer[1] === 0x50 &&
55
+ buffer[2] === 0x4e &&
56
+ buffer[3] === 0x47 &&
57
+ buffer[4] === 0x0d &&
58
+ buffer[5] === 0x0a &&
59
+ buffer[6] === 0x1a &&
60
+ buffer[7] === 0x0a
61
+ ) {
62
+ return 'image/png';
63
+ }
64
+ // JPEG: FF D8 FF
65
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
66
+ return 'image/jpeg';
67
+ }
68
+ // GIF: "GIF87a" or "GIF89a"
69
+ if (
70
+ buffer.length >= 6 &&
71
+ buffer[0] === 0x47 &&
72
+ buffer[1] === 0x49 &&
73
+ buffer[2] === 0x46 &&
74
+ buffer[3] === 0x38 &&
75
+ (buffer[4] === 0x37 || buffer[4] === 0x39) &&
76
+ buffer[5] === 0x61
77
+ ) {
78
+ return 'image/gif';
79
+ }
80
+ // WebP: "RIFF" .... "WEBP"
81
+ if (
82
+ buffer.length >= 12 &&
83
+ buffer[0] === 0x52 &&
84
+ buffer[1] === 0x49 &&
85
+ buffer[2] === 0x46 &&
86
+ buffer[3] === 0x46 &&
87
+ buffer[8] === 0x57 &&
88
+ buffer[9] === 0x45 &&
89
+ buffer[10] === 0x42 &&
90
+ buffer[11] === 0x50
91
+ ) {
92
+ return 'image/webp';
93
+ }
94
+ // PDF: "%PDF-"
95
+ if (
96
+ buffer.length >= 5 &&
97
+ buffer[0] === 0x25 &&
98
+ buffer[1] === 0x50 &&
99
+ buffer[2] === 0x44 &&
100
+ buffer[3] === 0x46 &&
101
+ buffer[4] === 0x2d
102
+ ) {
103
+ return 'application/pdf';
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ const SUPPORTED_IMAGE_MIMES = new Set<string>([
109
+ 'image/png',
110
+ 'image/jpeg',
111
+ 'image/gif',
112
+ 'image/webp',
113
+ ]);
114
+
115
+ /** Mime types that get returned to the model as inline attachments. */
116
+ const SUPPORTED_ATTACHMENT_MIMES = new Set<string>([
117
+ ...SUPPORTED_IMAGE_MIMES,
118
+ 'application/pdf',
119
+ ]);
120
+
121
+ export type AttachmentMode = 'images-only' | 'images-and-pdf' | 'off';
122
+
123
+ export type Attachment =
124
+ | {
125
+ kind: 'image';
126
+ mime: string;
127
+ bytes: number;
128
+ dataUrl: string;
129
+ }
130
+ | {
131
+ kind: 'pdf';
132
+ mime: 'application/pdf';
133
+ bytes: number;
134
+ dataUrl: string;
135
+ }
136
+ | {
137
+ kind: 'binary';
138
+ mime: string;
139
+ bytes: number;
140
+ }
141
+ | {
142
+ kind: 'oversize';
143
+ mime: string;
144
+ bytes: number;
145
+ maxBytes: number;
146
+ }
147
+ | {
148
+ kind: 'text-or-unknown';
149
+ bytes: number;
150
+ };
151
+
152
+ export async function classifyAttachment(args: {
153
+ path: string;
154
+ bytes: number;
155
+ mode: AttachmentMode;
156
+ maxBytes: number;
157
+ /**
158
+ * WorkspaceFS to route I/O through — defaults to host fs/promises
159
+ * for backward compat. Manual review (finding F): without this
160
+ * routing, custom/remote FS implementations could either fail to
161
+ * embed valid attachments or accidentally read a host path with
162
+ * the same absolute name (since `read_file` itself does go through
163
+ * the configured WorkspaceFS).
164
+ */
165
+ fs?: WorkspaceFS;
166
+ }): Promise<Attachment> {
167
+ if (args.bytes === 0) {
168
+ return { kind: 'text-or-unknown', bytes: 0 };
169
+ }
170
+
171
+ // MIME sniffing only needs the first 12 bytes — read just the
172
+ // header so a 9 MB PNG (under the 10 MB read cap, over the 5 MB
173
+ // attachment cap) doesn't pull the whole buffer into memory before
174
+ // we discover it's oversize. Full read happens only when we're
175
+ // about to base64-embed.
176
+ const open = args.fs?.open ?? fsOpen;
177
+ const handle = await open(args.path, 'r');
178
+ const header = Buffer.alloc(12);
179
+ let mime: string | undefined;
180
+ try {
181
+ await handle.read(header, 0, 12, 0);
182
+ mime = sniffMime(header);
183
+ } finally {
184
+ await handle.close();
185
+ }
186
+
187
+ if (mime == null) {
188
+ return { kind: 'text-or-unknown', bytes: args.bytes };
189
+ }
190
+
191
+ const wantsImage =
192
+ args.mode === 'images-only' || args.mode === 'images-and-pdf';
193
+ const wantsPdf = args.mode === 'images-and-pdf';
194
+
195
+ const isImage = wantsImage && SUPPORTED_IMAGE_MIMES.has(mime);
196
+ const isPdf = wantsPdf && mime === 'application/pdf';
197
+
198
+ if (!isImage && !isPdf) {
199
+ // Both branches returned identical values pre-fix (audit-of-audit
200
+ // finding #3). The SUPPORTED_ATTACHMENT_MIMES check was dead code —
201
+ // collapsing to a single return.
202
+ return { kind: 'binary', mime, bytes: args.bytes };
203
+ }
204
+
205
+ if (args.bytes > args.maxBytes) {
206
+ return {
207
+ kind: 'oversize',
208
+ mime,
209
+ bytes: args.bytes,
210
+ maxBytes: args.maxBytes,
211
+ };
212
+ }
213
+
214
+ const readFile = args.fs?.readFile ?? fsReadFile;
215
+ const buffer = (await readFile(args.path)) as Buffer;
216
+ const base64 = buffer.toString('base64');
217
+ const dataUrl = `data:${mime};base64,${base64}`;
218
+
219
+ if (isImage) {
220
+ return { kind: 'image', mime, bytes: args.bytes, dataUrl };
221
+ }
222
+ return {
223
+ kind: 'pdf',
224
+ mime: 'application/pdf' as const,
225
+ bytes: args.bytes,
226
+ dataUrl,
227
+ };
228
+ }
229
+
230
+ /** Build the LangChain content array for an image attachment. */
231
+ export function imageAttachmentContent(
232
+ path: string,
233
+ attachment: Extract<Attachment, { kind: 'image' }>
234
+ ): Array<{
235
+ type: 'text' | 'image_url';
236
+ text?: string;
237
+ image_url?: { url: string };
238
+ }> {
239
+ return [
240
+ {
241
+ type: 'text',
242
+ text:
243
+ `Read ${path} (${attachment.mime}, ${attachment.bytes} bytes). ` +
244
+ 'The image is attached below for vision-capable models.',
245
+ },
246
+ {
247
+ type: 'image_url',
248
+ image_url: { url: attachment.dataUrl },
249
+ },
250
+ ];
251
+ }
@@ -0,0 +1,151 @@
1
+ import type * as t from '@/types';
2
+
3
+ export type BashAstFinding = {
4
+ code: string;
5
+ message: string;
6
+ severity: 'warn' | 'deny';
7
+ };
8
+
9
+ /**
10
+ * Categorical-hazard checks layered on top of the existing dangerous-
11
+ * command regex set. These match command-shape signatures that
12
+ * claude-code's tree-sitter AST validator catches via categorical
13
+ * deny-lists (command substitution, zsh-only privileged commands,
14
+ * /proc/<pid>/environ access, IFS injection, etc.).
15
+ *
16
+ * This is *not* a real AST parser. It is a deliberately conservative
17
+ * heuristic pass intended for the local engine's `bashAst: 'auto' |
18
+ * 'strict'` modes; a future PR can swap in a true tree-sitter-bash
19
+ * pass behind the same config field without changing the public API.
20
+ *
21
+ * `runBashAstChecks` runs on the *quote-stripped* command (so quoted
22
+ * strings inside the script don't generate false positives) and
23
+ * returns one finding per matched category.
24
+ */
25
+
26
+ const COMMAND_SUBSTITUTION_PATTERNS: { code: string; rx: RegExp }[] = [
27
+ { code: 'cmd-subst-dollar-paren', rx: /\$\(/ },
28
+ { code: 'cmd-subst-backtick', rx: /`[^`]*`/ },
29
+ { code: 'cmd-subst-process-sub', rx: /[<>]\(/ },
30
+ { code: 'cmd-subst-zsh-eq', rx: /(?:^|\s)=[A-Za-z_]/ },
31
+ ];
32
+
33
+ const ZSH_DANGEROUS_BUILTINS = [
34
+ 'zmodload',
35
+ 'emulate',
36
+ 'sysopen',
37
+ 'sysread',
38
+ 'syswrite',
39
+ 'ztcp',
40
+ 'zsocket',
41
+ 'zf_rm',
42
+ 'zselect',
43
+ ];
44
+
45
+ const STRICT_DENIED_BUILTINS = [
46
+ 'eval',
47
+ 'exec',
48
+ ];
49
+
50
+ function rxForBuiltin(name: string): RegExp {
51
+ return new RegExp(`\\b${name}\\b`);
52
+ }
53
+
54
+ const PROC_ENVIRON_RX = /\/proc\/(?:\d+|self|\$[A-Za-z_])\/environ\b/;
55
+ const IFS_INJECTION_RX = /\bIFS\s*=/;
56
+ const HEX_ESCAPE_OBFUSCATION_RX = /\\x[0-9a-fA-F]{2}/;
57
+ const SOURCE_FROM_VAR_RX = /(?:^|\s)(?:source|\.)\s+["']?\$[A-Za-z_]/;
58
+
59
+ export function runBashAstChecks(
60
+ command: string,
61
+ mode: t.LocalBashAstMode = 'off'
62
+ ): BashAstFinding[] {
63
+ if (mode === 'off') {
64
+ return [];
65
+ }
66
+ const findings: BashAstFinding[] = [];
67
+ const strict = mode === 'strict';
68
+
69
+ for (const { code, rx } of COMMAND_SUBSTITUTION_PATTERNS) {
70
+ if (rx.test(command)) {
71
+ findings.push({
72
+ code,
73
+ message:
74
+ 'Command substitution can mask intent and exfiltrate variables; not allowed under bashAst.',
75
+ severity: strict ? 'deny' : 'warn',
76
+ });
77
+ }
78
+ }
79
+
80
+ for (const builtin of ZSH_DANGEROUS_BUILTINS) {
81
+ if (rxForBuiltin(builtin).test(command)) {
82
+ findings.push({
83
+ code: `zsh-builtin-${builtin}`,
84
+ message: `Zsh privileged builtin "${builtin}" is denied.`,
85
+ severity: 'deny',
86
+ });
87
+ }
88
+ }
89
+
90
+ if (PROC_ENVIRON_RX.test(command)) {
91
+ findings.push({
92
+ code: 'proc-environ-read',
93
+ message: 'Reads from /proc/<pid>/environ are denied — leaks host secrets.',
94
+ severity: 'deny',
95
+ });
96
+ }
97
+
98
+ if (IFS_INJECTION_RX.test(command)) {
99
+ findings.push({
100
+ code: 'ifs-injection',
101
+ message: 'Inline IFS reassignment is suspicious; review the command.',
102
+ severity: strict ? 'deny' : 'warn',
103
+ });
104
+ }
105
+
106
+ if (HEX_ESCAPE_OBFUSCATION_RX.test(command)) {
107
+ findings.push({
108
+ code: 'hex-escape',
109
+ message: 'Hex-escaped bytes (\\xNN) often hide intent; review the command.',
110
+ severity: strict ? 'deny' : 'warn',
111
+ });
112
+ }
113
+
114
+ if (SOURCE_FROM_VAR_RX.test(command)) {
115
+ findings.push({
116
+ code: 'source-from-variable',
117
+ message: 'Sourcing a script from an unbound variable is denied.',
118
+ severity: 'deny',
119
+ });
120
+ }
121
+
122
+ if (strict) {
123
+ for (const builtin of STRICT_DENIED_BUILTINS) {
124
+ if (rxForBuiltin(builtin).test(command)) {
125
+ findings.push({
126
+ code: `strict-${builtin}`,
127
+ message: `In strict mode, "${builtin}" is denied.`,
128
+ severity: 'deny',
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ return findings;
135
+ }
136
+
137
+ export function bashAstFindingsToErrors(
138
+ findings: BashAstFinding[]
139
+ ): { errors: string[]; warnings: string[] } {
140
+ const errors: string[] = [];
141
+ const warnings: string[] = [];
142
+ for (const f of findings) {
143
+ const formatted = `[bashAst:${f.code}] ${f.message}`;
144
+ if (f.severity === 'deny') {
145
+ errors.push(formatted);
146
+ } else {
147
+ warnings.push(formatted);
148
+ }
149
+ }
150
+ return { errors, warnings };
151
+ }