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:
NI0317
2026-07-28 13:16:24 +08:00
parent 717852423f
commit 451c21a5b6
10 changed files with 61 additions and 35 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 影响

View File

@@ -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}

View File

@@ -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)

View File

@@ -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)
})