@librechat/agents 3.1.77 → 3.1.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/common/enum.cjs +54 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +155 -4
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
- package/dist/cjs/main.cjs +90 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
- package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +27 -0
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/messages/recency.cjs +99 -0
- package/dist/cjs/messages/recency.cjs.map +1 -0
- package/dist/cjs/run.cjs +30 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +100 -6
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +635 -23
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
- package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
- package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
- package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
- package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
- package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
- package/dist/cjs/tools/local/attachments.cjs +183 -0
- package/dist/cjs/tools/local/attachments.cjs.map +1 -0
- package/dist/cjs/tools/local/bashAst.cjs +129 -0
- package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
- package/dist/cjs/tools/local/editStrategies.cjs +188 -0
- package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
- package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
- package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
- package/dist/cjs/tools/local/textEncoding.cjs +30 -0
- package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
- package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
- package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +31 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +53 -1
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +156 -5
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
- package/dist/esm/main.mjs +17 -2
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/anthropicToolCache.mjs +99 -0
- package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +26 -1
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/messages/recency.mjs +97 -0
- package/dist/esm/messages/recency.mjs.map +1 -0
- package/dist/esm/run.mjs +30 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +100 -6
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +635 -23
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
- package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
- package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
- package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
- package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
- package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
- package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
- package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
- package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
- package/dist/esm/tools/local/attachments.mjs +180 -0
- package/dist/esm/tools/local/attachments.mjs.map +1 -0
- package/dist/esm/tools/local/bashAst.mjs +126 -0
- package/dist/esm/tools/local/bashAst.mjs.map +1 -0
- package/dist/esm/tools/local/editStrategies.mjs +185 -0
- package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
- package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
- package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
- package/dist/esm/tools/local/textEncoding.mjs +27 -0
- package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
- package/dist/esm/tools/local/workspaceFS.mjs +49 -0
- package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +31 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +39 -1
- package/dist/types/graphs/Graph.d.ts +34 -0
- package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
- package/dist/types/hooks/index.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/messages/anthropicToolCache.d.ts +51 -0
- package/dist/types/messages/index.d.ts +2 -0
- package/dist/types/messages/prune.d.ts +11 -0
- package/dist/types/messages/recency.d.ts +64 -0
- package/dist/types/run.d.ts +21 -0
- package/dist/types/tools/ToolNode.d.ts +145 -2
- package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
- package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
- package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
- package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
- package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
- package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
- package/dist/types/tools/local/attachments.d.ts +84 -0
- package/dist/types/tools/local/bashAst.d.ts +11 -0
- package/dist/types/tools/local/editStrategies.d.ts +28 -0
- package/dist/types/tools/local/index.d.ts +12 -0
- package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
- package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
- package/dist/types/tools/local/textEncoding.d.ts +21 -0
- package/dist/types/tools/local/workspaceFS.d.ts +49 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +29 -0
- package/dist/types/types/hitl.d.ts +56 -27
- package/dist/types/types/run.d.ts +8 -1
- package/dist/types/types/summarize.d.ts +30 -0
- package/dist/types/types/tools.d.ts +341 -6
- package/package.json +21 -2
- package/src/common/enum.ts +54 -0
- package/src/graphs/Graph.ts +173 -6
- package/src/hooks/__tests__/compactHooks.test.ts +38 -2
- package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
- package/src/hooks/createWorkspacePolicyHook.ts +355 -0
- package/src/hooks/index.ts +6 -0
- package/src/index.ts +1 -0
- package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
- package/src/messages/__tests__/recency.test.ts +267 -0
- package/src/messages/anthropicToolCache.ts +116 -0
- package/src/messages/index.ts +2 -0
- package/src/messages/prune.ts +27 -1
- package/src/messages/recency.ts +155 -0
- package/src/run.ts +31 -0
- package/src/scripts/compare_pi_vs_ours.ts +840 -0
- package/src/scripts/local_engine.ts +166 -0
- package/src/scripts/local_engine_checkpointer.ts +205 -0
- package/src/scripts/local_engine_compile.ts +263 -0
- package/src/scripts/local_engine_hooks.ts +226 -0
- package/src/scripts/local_engine_image.ts +201 -0
- package/src/scripts/local_engine_ptc.ts +151 -0
- package/src/scripts/local_engine_workspace.ts +258 -0
- package/src/scripts/subagent-configurable-inheritance.ts +252 -0
- package/src/scripts/summarization-recency.ts +462 -0
- package/src/specs/prune.test.ts +39 -0
- package/src/summarization/__tests__/node.test.ts +499 -3
- package/src/summarization/node.ts +124 -7
- package/src/tools/ToolNode.ts +769 -20
- package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
- package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
- package/src/tools/__tests__/SubagentExecutor.test.ts +148 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
- package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
- package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
- package/src/tools/__tests__/directToolHooks.test.ts +411 -0
- package/src/tools/__tests__/localToolNames.test.ts +73 -0
- package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
- package/src/tools/local/CompileCheckTool.ts +278 -0
- package/src/tools/local/FileCheckpointer.ts +93 -0
- package/src/tools/local/LocalCodingTools.ts +1342 -0
- package/src/tools/local/LocalExecutionEngine.ts +1329 -0
- package/src/tools/local/LocalExecutionTools.ts +167 -0
- package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
- package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
- package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
- package/src/tools/local/attachments.ts +251 -0
- package/src/tools/local/bashAst.ts +151 -0
- package/src/tools/local/editStrategies.ts +188 -0
- package/src/tools/local/index.ts +12 -0
- package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
- package/src/tools/local/syntaxCheck.ts +243 -0
- package/src/tools/local/textEncoding.ts +37 -0
- package/src/tools/local/workspaceFS.ts +89 -0
- package/src/tools/subagent/SubagentExecutor.ts +60 -0
- package/src/types/hitl.ts +56 -27
- package/src/types/run.ts +12 -1
- package/src/types/summarize.ts +31 -0
- 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
|
+
}
|