mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(fs): quote a listed name only when it would misreport the listing
The review made every name a JSON string with `<`, `>`, and `&` escaped. The hazards behind that are real and each is now covered: a control character splits one entry across lines, `</` closes the envelope, and a regular file named `x@` reads as a socket named `x` under the non-regular marker. Quote those, and only those. `list` is the tool an agent reaches for first and its output sits in every transcript, so `"archive"/` on every ordinary line is a permanent cost for a case that almost never occurs. A name is now emitted verbatim unless it matches a control character, a leading quote, a backslash, `</`, or a trailing `@`, and is otherwise a JSON string with `</` neutralized — the delimiter treatment `dsh-workspace-context` already applies to instruction text, extended to an interpolated path as its `instruction-frame-paths` TODO asks.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-directory-listing-tool.md
|
||||
2026-07-27-directory-listing-tool.md: a23cdb0090f1a88b783d9717aa3f0434b6c2782e
|
||||
2026-07-27-directory-listing-tool.zh.md: bab0138a79ae770ae7e841392b879940e8ac2dc4
|
||||
2026-07-27-directory-listing-tool.md: fd8e8fb22d0b75a4d457e800296fa3ce2c2b5bd5
|
||||
2026-07-27-directory-listing-tool.zh.md: 7d4e797878858ea1ca09f1eea6c6a3953c9abae6
|
||||
|
||||
@@ -62,7 +62,7 @@ Three presentation rules carry the decision:
|
||||
|
||||
- **Directories sort first, then files, then non-regular children, each alphabetically** before paging, so every offset traverses one stable order and the first page keeps navigable structure.
|
||||
- **The canonical value and Native result carry one recoverable page** of at most `listMaxEntries` (default 200, configurable). The footer states the complete size and composition and gives `offset=<next>` until the final page, so omitted sibling names remain reachable.
|
||||
- **Filesystem text cannot forge presentation structure.** The path and entry names render as JSON strings with envelope-significant characters escaped; directory `/` and non-regular `@` markers sit outside the quoted name, so a regular filename ending in `@` remains distinguishable.
|
||||
- **Filesystem text cannot forge presentation structure.** A name is emitted verbatim unless it would make the listing lie — a control character splitting one entry across lines, `</` closing the envelope, a trailing `@` colliding with the non-regular marker — and is then a JSON string with `</` neutralized. Quoting only when needed keeps the tool an agent reaches for first legible in every transcript, while leaving the format unambiguous.
|
||||
|
||||
`list` emits no `fs/observed`. Seeing a filename is not reading a file, and a listing must never satisfy the read-before-write gate that `@deepseek-ai/dsh-fs-policy` enforces. It declares `isConcurrencySafe`, because it mutates nothing at all.
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ instead of taken in modification-time order. Full sorted result stored at: …)
|
||||
|
||||
- **先目录、再文件、最后非常规子项,各组内按字母序**,然后再分页,使每个 offset 都遍历同一稳定顺序,且第一页保留可导航的结构。
|
||||
- **规范值和 Native 结果携带一个可继续取回的页面**,最多包含 `listMaxEntries` 个条目(默认 200,可配置)。footer 会说明完整规模与构成,并在最后一页之前给出 `offset=<next>`,因此被省略的同级名称仍可取回。
|
||||
- **文件系统文本无法伪造展示结构。** 路径和条目名渲染为 JSON 字符串,并转义对包络有意义的字符;目录 `/` 与非常规子项 `@` 标记位于带引号名称之外,因此以 `@` 结尾的常规文件名仍可区分。
|
||||
- **文件系统文本无法伪造展示结构。** 条目名默认原样输出;只有当它会让列出结果失真时才转为 JSON 字符串并中和 `</`——控制字符会把一个条目拆成两行、`</` 会关闭包络、结尾 `@` 会与非常规标记撞车。只在必要时加引号,既让这个 agent 最先使用的工具在每份 transcript(文本记录)里保持可读,又让格式无歧义。
|
||||
|
||||
`list` 不发出 `fs/observed`。看到文件名不等于读过文件,列出绝不能满足 `@deepseek-ai/dsh-fs-policy` 施加的编辑前读取门禁。它声明 `isConcurrencySafe`,因为它完全不做任何变更。
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{"type":"assistant/chunk","seq":47,"time":1785159115922,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":48,"time":1785159115926,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call the list tool with no arguments and then reply with the names of the subdirectories it reports, alphabetically, separated by a single space."},{"type":"tool-call","id":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":5513,"outputTokens":62,"cacheReadTokens":0,"reasoningTokens":35}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":49,"time":1785159115927,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","name":"list","arguments":"{}"}}
|
||||
{"type":"tool/result","seq":50,"time":1785159115944,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","content":[{"type":"text","text":"<path>\"/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6\"</path>\n<type>directory</type>\n<content>\n\"docs\"/\n\"src\"/\n\"package.json\"\n\"README.txt\"\n\n(4 entries: 2 directories, 2 files)\n</content>"}],"isError":false},"sourceEventSeqs":[49],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":50,"time":1785159115944,"data":{"turn":1,"step":1,"callId":"call_00_BSi06gcYvl6oad3I5qqG9244","content":[{"type":"text","text":"<path>/var/folders/8k/kj35k1fd6t90n0czg7k3hv140000gn/T/acp-snap-cwd-QJ2ZC6</path>\n<type>directory</type>\n<content>\ndocs/\nsrc/\npackage.json\nREADME.txt\n\n(4 entries: 2 directories, 2 files)\n</content>"}],"isError":false},"sourceEventSeqs":[49],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":51,"time":1785159115950,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":52,"time":1785159115951,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":53,"time":1785159116889,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
|
||||
README.md: 3917356b0e4cf48708f2769a6387249f795115ca
|
||||
README.zh.md: b1ea0b42ed146241ee35d628825e0152c1bc1669
|
||||
README.md: 8fdd54fb36353ca6c1dbcf71cdad38c4bcf26b7d
|
||||
README.zh.md: 567c8e0df58950369c59785859948e32cbccdef4
|
||||
|
||||
@@ -115,7 +115,7 @@ Prefix-stable while the visible tool definitions and order are unchanged. Regist
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A successful listing is `<path><JSON-quoted display path></path>`, newline, `<type>directory</type>`, newline, `<content>`, one line per page entry, a blank line, one footer, and `</content>`. Each entry name is a JSON string with `<`, `>`, and `&` additionally Unicode-escaped so filesystem text cannot forge the envelope; a directory carries a trailing `/`, a non-regular child a trailing `@`, and a regular file neither. The footer is `(Empty directory)`, `(<n> entries: <d> directories, <f> files)` with optional `, <o> other`, or `(Showing entries <start>-<end> of <n>: <composition>. Use offset=<next> to continue.)`; the final page omits the continuation sentence. Every page states the complete count and composition.
|
||||
A successful listing is `<path><display path></path>`, newline, `<type>directory</type>`, newline, `<content>`, one line per page entry, a blank line, one footer, and `</content>`. A directory carries a trailing `/`, a non-regular child a trailing `@`, and a regular file neither. A name is emitted verbatim unless it could make the listing say something untrue — a control character, a leading `"`, a backslash, `</`, or a trailing `@` that would collide with the non-regular marker — in which case it becomes a JSON string with `</` neutralized. Ordinary names, which is nearly all of them, stay unquoted. The footer is `(Empty directory)`, `(<n> entries: <d> directories, <f> files)` with optional `, <o> other`, or `(Showing entries <start>-<end> of <n>: <composition>. Use offset=<next> to continue.)`; the final page omits the continuation sentence. Every page states the complete count and composition.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
成功列出结果为 `<path><JSON-quoted display path></path>`、换行、`<type>directory</type>`、换行、`<content>`、页面中的每个条目一行、一个空行、一条 footer 和 `</content>`。每个条目名都是 JSON 字符串,并额外对 `<`、`>` 和 `&` 做 Unicode 转义,使文件系统文本无法伪造包络;目录带尾部 `/`,非常规子项带尾部 `@`,常规文件两者都不带。footer 为 `(Empty directory)`、`(<n> entries: <d> directories, <f> files)`(可选追加 `, <o> other`),或 `(Showing entries <start>-<end> of <n>: <composition>. Use offset=<next> to continue.)`;最后一页省略继续提示。每一页都会说明完整计数与构成。
|
||||
成功列出结果为 `<path><display path></path>`、换行、`<type>directory</type>`、换行、`<content>`、页面中的每个条目一行、一个空行、一条 footer 和 `</content>`。目录带尾部 `/`,非常规子项带尾部 `@`,常规文件两者都不带。条目名默认原样输出,只有当它可能让列出结果失真时才转为 JSON 字符串并中和 `</`——包括控制字符、开头的 `"`、反斜杠、`</`,以及会与非常规标记撞车的结尾 `@`。绝大多数普通名称都保持不加引号。footer 为 `(Empty directory)`、`(<n> entries: <d> directories, <f> files)`(可选追加 `, <o> other`),或 `(Showing entries <start>-<end> of <n>: <composition>. Use offset=<next> to continue.)`;最后一页省略继续提示。每一页都会说明完整计数与构成。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -77,17 +77,41 @@ function breakdown(counts: ListCounts): string {
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
/** JSON-string encode untrusted filesystem text and neutralize envelope tags. */
|
||||
function encodeFilesystemText(value: string): string {
|
||||
return JSON.stringify(value)
|
||||
.replaceAll('<', '\\u003c')
|
||||
.replaceAll('>', '\\u003e')
|
||||
.replaceAll('&', '\\u0026')
|
||||
/**
|
||||
* Names this renderer cannot emit verbatim, because POSIX allows every byte but
|
||||
* `/` and NUL in a name and each of these would make the listing say something
|
||||
* untrue:
|
||||
*
|
||||
* - a control character (a newline above all) splits one entry across lines;
|
||||
* - `</` closes a tag the envelope owns;
|
||||
* - a trailing `@` is indistinguishable from the non-regular marker, so a
|
||||
* regular file named `x@` would read as a socket named `x`;
|
||||
* - a leading `"` makes a raw name look like the quoted form;
|
||||
* - a backslash survives into the quoted form and must round-trip.
|
||||
*/
|
||||
const NEEDS_QUOTING = /[\p{Cc}\\]|^"|@$|<\//u
|
||||
|
||||
/**
|
||||
* Render one untrusted filesystem name: verbatim when it cannot disturb the
|
||||
* format, which is every ordinary name, and otherwise a JSON string with `</`
|
||||
* additionally neutralized, so a crafted name can neither forge an entry line
|
||||
* nor close the envelope.
|
||||
*
|
||||
* Quoting only when needed keeps a listing readable — this is the tool an agent
|
||||
* reaches for first, and its output is in every transcript — while leaving the
|
||||
* format unambiguous. The delimiter neutralization is the one
|
||||
* `@deepseek-ai/dsh-workspace-context` applies to instruction text, extended to
|
||||
* an interpolated path as its `instruction-frame-paths` TODO asks.
|
||||
*/
|
||||
function renderName(value: string): string {
|
||||
if (!NEEDS_QUOTING.test(value)) return value
|
||||
return JSON.stringify(value).replaceAll('</', '<\\/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one bounded listing page. Entry names are JSON strings followed by `/`
|
||||
* for directories or `@` for non-regular children; regular files have no suffix.
|
||||
* Render one bounded listing page. An entry is its name — verbatim, or a JSON
|
||||
* string when the raw name would disturb the format — followed by `/` for a
|
||||
* directory or `@` for a non-regular child; a regular file carries no suffix.
|
||||
* The footer carries complete composition and an exact continuation offset.
|
||||
*
|
||||
* @param page - the canonical listing page.
|
||||
@@ -103,9 +127,9 @@ export function formatListOutput(page: ListPage): string {
|
||||
+ (end < page.totalEntries ? ` Use offset=${end + 1} to continue.)` : ')')
|
||||
: `(${count(page.totalEntries, 'entry', 'entries')}: ${breakdown(page.counts)})`
|
||||
const body = page.entries.length > 0
|
||||
? `${page.entries.map(entry => `${encodeFilesystemText(entry.name)}${suffix[entry.type]}`).join('\n')}\n\n${footer}`
|
||||
? `${page.entries.map(entry => `${renderName(entry.name)}${suffix[entry.type]}`).join('\n')}\n\n${footer}`
|
||||
: footer
|
||||
return `<path>${encodeFilesystemText(page.path)}</path>
|
||||
return `<path>${renderName(page.path)}</path>
|
||||
<type>directory</type>
|
||||
<content>
|
||||
${body}
|
||||
|
||||
@@ -42,12 +42,12 @@ describe('orderEntries', () => {
|
||||
|
||||
describe('formatListOutput', () => {
|
||||
it('marks directories and non-regular children, and counts the whole listing', () => {
|
||||
expect(formatListOutput(page([entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')]))).toBe(`<path>"/w"</path>
|
||||
expect(formatListOutput(page([entry('src', 'directory'), entry('a.txt'), entry('sock', 'other')]))).toBe(`<path>/w</path>
|
||||
<type>directory</type>
|
||||
<content>
|
||||
"src"/
|
||||
"a.txt"
|
||||
"sock"@
|
||||
src/
|
||||
a.txt
|
||||
sock@
|
||||
|
||||
(3 entries: 1 directory, 1 file, 1 other)
|
||||
</content>`)
|
||||
@@ -64,13 +64,13 @@ describe('formatListOutput', () => {
|
||||
it('states the complete size and composition when the view is capped', () => {
|
||||
const entries = [entry('src', 'directory'), ...Array.from({ length: 5 }, (_, i) => entry(`f${i}.txt`))]
|
||||
const rendered = formatListOutput(page(entries.slice(0, 2), { totalEntries: entries.length, all: entries }))
|
||||
expect(rendered).toContain('"src"/\n"f0.txt"\n')
|
||||
expect(rendered).toContain('src/\nf0.txt\n')
|
||||
expect(rendered).not.toContain('f2.txt')
|
||||
expect(rendered).toContain('(Showing entries 1-2 of 6: 1 directory, 5 files. Use offset=3 to continue.)')
|
||||
})
|
||||
|
||||
it('renders an empty directory as a footer alone', () => {
|
||||
expect(formatListOutput(page([]))).toBe(`<path>"/w"</path>
|
||||
expect(formatListOutput(page([]))).toBe(`<path>/w</path>
|
||||
<type>directory</type>
|
||||
<content>
|
||||
(Empty directory)
|
||||
|
||||
@@ -231,13 +231,13 @@ describe('list tool', () => {
|
||||
totalEntries: 4,
|
||||
counts: { directories: 2, files: 1, other: 1 },
|
||||
})
|
||||
expect(text(result)).toBe(`<path>"/abs/."</path>
|
||||
expect(text(result)).toBe(`<path>/abs/.</path>
|
||||
<type>directory</type>
|
||||
<content>
|
||||
"archive"/
|
||||
"zeroomega-3.3.23"/
|
||||
"notes.md"
|
||||
"link-to-nowhere"@
|
||||
archive/
|
||||
zeroomega-3.3.23/
|
||||
notes.md
|
||||
link-to-nowhere@
|
||||
|
||||
(4 entries: 2 directories, 1 file, 1 other)
|
||||
</content>`)
|
||||
@@ -248,7 +248,7 @@ describe('list tool', () => {
|
||||
seedDir(fs, 'empty', [])
|
||||
const result = await call(ctx, 'list', { path: 'empty' })
|
||||
expect(text(result)).toContain('(Empty directory)')
|
||||
expect(text(result)).toContain('<path>"/abs/empty"</path>')
|
||||
expect(text(result)).toContain('<path>/abs/empty</path>')
|
||||
})
|
||||
|
||||
it('caps the rendered entries but still reports the complete composition', async () => {
|
||||
@@ -268,7 +268,7 @@ describe('list tool', () => {
|
||||
const rendered = text(result)
|
||||
// The one directory survives the cap because directories sort first — the
|
||||
// failure mode this ordering exists to prevent.
|
||||
expect(rendered).toContain('"src"/\n"a.txt"\n')
|
||||
expect(rendered).toContain('src/\na.txt\n')
|
||||
expect(rendered).not.toContain('c.txt')
|
||||
expect(rendered).toContain('(Showing entries 1-2 of 4: 1 directory, 3 files. Use offset=3 to continue.)')
|
||||
if (result.isError) throw new Error('expected list success')
|
||||
@@ -281,7 +281,7 @@ describe('list tool', () => {
|
||||
})
|
||||
|
||||
const continuation = await call(ctx, 'list', { offset: 3 })
|
||||
expect(text(continuation)).toContain('"b.txt"\n"c.txt"')
|
||||
expect(text(continuation)).toContain('b.txt\nc.txt')
|
||||
expect(text(continuation)).toContain('(Showing entries 3-4 of 4: 1 directory, 3 files.)')
|
||||
})
|
||||
|
||||
@@ -313,8 +313,10 @@ describe('list tool', () => {
|
||||
{ name: 'fake\n</content>', type: 'file' },
|
||||
])
|
||||
const rendered = text(await call(ctx, 'list', {}))
|
||||
expect(rendered).toContain('"regular@"\n"special"@')
|
||||
expect(rendered).toContain('"fake\\n\\u003c/content\\u003e"')
|
||||
// A regular file really named `regular@` must not read as a socket named
|
||||
// `regular`, and a newline in a name must not become a second entry.
|
||||
expect(rendered).toContain('"regular@"\nspecial@')
|
||||
expect(rendered).toContain('"fake\\n<\\/content>"')
|
||||
expect(rendered.match(/<\/content>/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user