From 4816cd8848a52f73f5f054f761d3ec862cb87ade Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Tue, 13 Oct 2026 16:03:24 +0800 Subject: [PATCH 1/2] fix: MSYS compatibility boundaries and unconfined feedback --- CHANGELOG.md | 30 +++++++ README.md | 52 ++++++++--- README_EN.md | 72 +++++++++++---- package.json | 2 +- src/bash-render.js | 22 +++-- src/bash-tool.js | 33 +++++-- src/index.js | 26 +++--- src/shell.js | 28 +++--- tests/compatibility-regressions.mjs | 134 ++++++++++++++++++++++++++++ tests/runtime-preview.mjs | 80 +++++++++++++++++ tests/runtime-regressions.mjs | 26 ++++++ tests/smoke.mjs | 4 +- 12 files changed, 438 insertions(+), 71 deletions(-) create mode 100644 tests/compatibility-regressions.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8c1d0..64b1933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,36 @@ > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。 > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。 +## Unreleased — 2026-10-13 + +**类型**:fix(MSYS 参数/编码提示、源码路径边界与 unconfined 可观测性) / test / docs。 + +### MSYS 兼容边界尝试修复 + +- **范围**:从 [`6b26ded`](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/6b26ded6cc3a2a6113579149286047629b433c48) + 开出 `fix/msys-compatibility-boundaries`;不改 root PowerShell、market patch、生产 Profile、 + 安全默认或版本号。原生参数转换/混合编码/Windows shell confinement 不能在这层安全地普遍修好, + 因此修工具提示与可观测性,不全局 EXCL=*、不猜码、不将警示冒充沙箱修复。 +- **源码路径**:run_code 的完整 Unicode 字母/组合字符/数字和含 `#` 文件名字面量走共享挂载表; + 合法 EOF 行注释不再让整份重写弃权;tagged templates(含 String.raw、调用/索引 tag)保持原样, + 避免 raw 设备路径被错误转义;of/await/yield 等上下文词可作 tag 时保守不改。 + 未闭合/不确定正则与除法仍整份 no-op,未扩展插值或动态表达式猜测。 + 词法层仍无法识别非路径数据的业务用途,提示明确建议直接 Node fs/path 用 native/cwd-relative 路径。 +- **提示门控**:真实 prompt listener 读取 live codePaths;OFF 不再承诺程序字符串会被翻译, + ON 改为 best-effort 并指出模板、动态/非路径数据限制。关闭 codePaths 不改变工具路径字段翻译。 +- **原生互操作**:登记的 bash 描述无条件说明 `/all`/`/c` 转换、双斜杠/单命令 EXCL 前缀及其 + 对 `/c/...` 的影响;检查预期效果而非只看 exit0。说明 UTF-8 捕获与 CP936/GBK 不匹配, + 显式转码须在字节捕获前且保持退出状态;locale 与全流猜码不是普遍修复。 +- **unconfined 可见性**:执行句柄在等待 result() 前带 sandbox facts;前台正文、后台 outcome/ + 增量嵌入与超时转后台显示警示。后台登记输出联合新增可选 sandbox facts,指出计划执行边界, + 不把未启动的 job 宣称为已经执行。保留原请求 mode,不把 unconfined 当 denial/escalation, + 并明确不得通过 shell 绕过文件工具策略拒绝。双语说明区分文件读能力与 write/edit 围栏, + 删除“所有未来版本都无解”的过度结论。 +- **验证**:新增9项兼容回归和1项后台登记回归,普通全量 **166/166**;新增3项实际 Core + ToolRuntime/执行句柄/run_code transport 回归,完整无头 preview **17/17**。旧护栏保留;打包 dry-run、语法和 diff + 检查通过。新增 preview 使用实际宿主 API 与内存 subprocess,已有 preview 保留真实 Git Bash/ + PowerShell 进程验收;不等同于生产 GUI、真实 PTC 引擎或新 confinement 方案验证。 + ## v0.39.2 — 2026-10-13 **类型**:fix(route B 工具/预设生命周期、动态配置与客户端回归) / test。 diff --git a/README.md b/README.md index 040a973..4925d78 100644 --- a/README.md +++ b/README.md @@ -118,20 +118,19 @@ bundle patch(`cordis.patch.yml`)是**纯新增**(v0.39.0 起):只插入本插件 **Windows 宿主上的事实**:`read-only` / `workspace-write` 权限预设下的 **shell 命令**, 实际以**不受限(unconfined)方式执行**: -- **为什么**:MSYS2 运行时无法在 restricted-token 沙箱下启动——`msys-2.0.dll` - 初始化时创建的 cygheap 映射与信号管道,DACL 只含用户 SID;而 WRITE_RESTRICTED - 令牌的二次写检查要求 restricting-SID ACE → 初始化即死于 Win32 error 5 / - 0xC0000142,**任何版本、任何参数都无解**(cmd/pwsh 走匿名管道不受影响)。 - 这是 OS 层冲突,插件内没有真正的修法,只有两个选择:**如实标注的绕过**(本插件, - v0.13.0 起)或**直接拒绝执行**。选了前者,是因为装这个插件的目的就是用 Git Bash—— - 静默换 pwsh 顶上、或让每条命令都失败,都不如把真相摆在结果里。 -- **如何标注(绝不静默)**:每条此类结果的元数据携带 - `sandbox: { mode: "<请求的模式>", denied: false, enforcement: "unconfined" }`, - 并在每个进程首次受限调用时打印一行说明日志(`File sandbox enforcement is - partial on this host.`)。 -- **文件工具不受影响**:read / write / edit / glob / grep 的沙箱是**另一层** - (fs-tool policy),照常拦截工作区外访问——也就是说 `workspace-write` 下 - shell 能写出工作区、文件工具不能,**两者的边界并不一致**。 +- **为什么**:已验证的 Git Bash/MSYS 组合在现有 restricted-token 后端下初始化失败, + 历史复现为 `msys-2.0.dll` 的 cygheap/signal-pipe 对象权限与 restricting-SID + 二次写检查不兼容(Win32 error 5 / 0xC0000142)。当前没有提供可用的受限 Git Bash + 实现,不声称所有未来运行时或隔离方案都无解。本插件保留**如实标注的绕过**; + 没有静默回退 pwsh/cmd/WSL,本次兼容修复也没有恢复 shell 写入围栏。 +- **如何标注**:结果元数据保留 + `sandbox: { mode: "<请求的模式>", denied: false, enforcement: "unconfined" }`; + 工具描述、前台正文、后台登记及结果摘要、超时转后台反馈都明确警示 shell 写限制未生效。 + 后台登记标明的是计划采用的执行边界,不代表命令已经运行;实例首次调用另有日志。 +- **文件工具是另一层**:官方 fs-tool policy 对 write/edit 保留策略检查: + `read-only` 拒绝修改,`workspace-write` 只允许声明的工作区和平台临时可写根。 + read/glob/grep 等读取并非一律限制在工作区内。shell 进程不受这些工具检查约束, + **两者的边界并不一致**;绝不能用 shell 绕过文件工具已返回的策略拒绝。 - **可观察的对照**(相对 pwsh-sandbox 时代):会话进程的完整性级别不再被压低 (Low → Medium);`$env:TEMP` 不再被重定向到 per-session 隔离目录。 @@ -396,6 +395,31 @@ PTC/创造及用户自建模式一律覆盖。 都不再改写,文件工具收 Windows 路径。**bash 始终是 Git Bash,不受此开关影响**——它只决定 跨工具的路径方言。 +## 原生 Windows 程序与 run_code 的兼容边界 + +工具描述始终警示下面的参数、编码和安全限制;这些限制不随路径方言关闭而消失。 +插件**不会自动改写 command、全局关闭 MSYS 参数转换或猜测输出编码**。 + +- **斜杠开关**:MSYS 在启动原生 Windows 程序时可能把 `/all`、`/c` 当路径。 + 本机已验证 `ipconfig //all`、`cmd //c` 或单命令 + `MSYS2_ARG_CONV_EXCL='/all' ipconfig /all` 可保留开关。 + 排除值是前缀:排除 `/c` 也会影响真正的 `/c/...` 路径,该次原生调用应明确用 + Windows drive-letter 路径。不要全局设置 `MSYS2_ARG_CONV_EXCL='*'`。 + `cmd /c` 被转换后可能 exit0 却没有执行所需命令,应检查预期效果而非只看退出码。 +- **输出编码**:宿主按 UTF-8 解码;原生 Windows 程序可能输出 CP936/GBK 或其他代码页。 + 先使用程序自己的 UTF-8 输出选项,或在捕获前按已知编码显式转换字节,保持原退出状态。 + `LANG/LC_ALL` 不保证改变原生程序的代码页。不要全流默认 GBK 或依替换字符数自动猜码: + 同一 Bash 调用可能混合 UTF-8/GBK,已有乱码字符串也不能可靠恢复原字节。 +- **run_code**:`codePaths` 默认开,与 `posixPaths`/委托 gate 同时生效时,完整、可判定的 + 普通路径字面量按共享挂载表尽力转换,包括中文/组合字符/空格/`#` 文件名。 + EOF 行注释不再阻止转换;tagged template(包括 String.raw)保持原样,避免破坏 raw/tag 语义。 + 插值模板、转义路径、不确定的正则/除法语法仍可能不改;不确定时整份 no-op。 + 直接调用 Node fs/path 时优先明确的 Windows drive-letter 或 cwd-relative 路径; + 工具 path/file_path 字段仍走工具参数翻译。词法扫描不能识别路由、搜索数据或 shell + command 的用途;此类字符串不要依赖自动重写,必要时关闭 `codePaths`(提示同步标为 OFF)。 +- **沙箱**:参见上方安全章节。警示可见性得到修复,但 Windows shell 的 unconfined + 取舍未改变,文件工具的策略拒绝绝不授权 shell 重试。 + ## 与 dsh-better-sidebar 联动 装了 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar)(v0.15.2+) 时,Windows 上本插件会通过**它的官方设置补丁口**(运行时 `terminalShell` 设置,对方文档明示"settings-page overrides win for terminals opened afterwards")把它打开的终端 shell 指向 Git Bash——**UI 终端标签与模型侧 `terminal_*` 工具统一生效**,不改对方一行代码、新开的终端即生效。此外,**本插件的 bundle patch 还会给对方的行补上 `config.shell`**(启动期解析,连 **tab 标题也会显示 bash**;对方未安装时该行无害跳过)。规则: diff --git a/README_EN.md b/README_EN.md index 644a712..db23ab1 100644 --- a/README_EN.md +++ b/README_EN.md @@ -93,24 +93,26 @@ restricted tokens, workspace fencing, denial classification, identical to **On Windows**, shell commands under the `read-only` / `workspace-write` permission presets actually run **UNCONFINED**: -- **Why**: the MSYS2 runtime cannot start under the restricted-token sandbox — - `msys-2.0.dll` creates its cygheap mapping and signal pipe with DACLs naming - only the user SID, while a WRITE_RESTRICTED token's pass-2 write check - demands a restricting-SID ACE, so init dies with Win32 error 5 / 0xC0000142 - before argv ever runs — **every version, no workaround** (cmd/pwsh use - anonymous pipes and are unaffected). This is an OS-level conflict with no - in-plugin cure; the two options are **a labelled bypass** (this plugin, - since v0.13.0) or **refusing to run at all**. The former was chosen because - installing this plugin means wanting Git Bash — silently falling back to - pwsh, or failing every command, is worse than putting the truth in the result. -- **How it is labelled (never silent)**: every such result carries - `sandbox: { mode: "", denied: false, enforcement: "unconfined" }`, - plus a one-time notice per process (`File sandbox enforcement is partial on - this host.`). -- **File tools are NOT affected**: the read / write / edit / glob / grep - sandbox is a **separate layer** (the fs-tool policy) and still fences - out-of-workspace access. Under `workspace-write` the shell can write outside - the workspace while file tools cannot — **the two boundaries differ**. +- **Why**: tested Git Bash/MSYS combinations failed to initialize under the + existing restricted-token backend. Historical reproduction found cygheap/ + signal-pipe object permissions incompatible with the restricting-SID write + check (Win32 error 5 / 0xC0000142). No working confined Git Bash implementation + is provided here; this is not a claim that every future runtime or isolation + design is impossible. The plugin retains its **labelled bypass**, with no + silent fallback to pwsh/cmd/WSL. Compatibility fixes do not restore confinement. +- **How it is labelled**: results retain + `sandbox: { mode: "", denied: false, enforcement: "unconfined" }`. + The tool description, foreground text, initial background registration, + background outcome and timeout-promotion feedback warn that shell write + restrictions are not enforced. Initial registration describes the planned + boundary, not a claim that the command has already run; there is also a + one-time notice per executor instance. +- **Official file tools have a separate policy**: write/edit still reject + mutations under `read-only`; `workspace-write` checks the declared workspace + and platform writable temp roots. Reads such as read/glob/grep are not all + limited to the workspace. Shell processes are not governed by these tool + checks — **the two boundaries differ**. Never bypass a file-tool policy + denial through shell. - **Observable differences** vs the pwsh-sandbox era: the session process's integrity level is no longer lowered (Low → Medium), and `$env:TEMP` is no longer redirected to a per-session directory. @@ -125,6 +127,40 @@ official PowerShell executor was never touched by this plugin. The > The full analysis (the 0.6.0–0.13.1 failure history and the community's two > camps) is in the CHANGELOG under v0.13.2 and in the `src/shell.js` header. +## Native Windows and run_code compatibility boundaries + +The tool description always warns about these argument, encoding and security +boundaries, independently of the path-dialect switch. The plugin does not +silently rewrite command source, globally disable argument conversion or guess +output encodings. + +- **Slash options**: MSYS can treat native Windows `/all` and `/c` as paths. + Tested alternatives are `ipconfig //all`, `cmd //c`, or a per-command + `MSYS2_ARG_CONV_EXCL='/all' ipconfig /all`. Exclusions match prefixes: excluding + `/c` also excludes `/c/...` file paths, so explicitly use native drive-letter + paths for those arguments. Do not set `MSYS2_ARG_CONV_EXCL='*'` globally. + A converted `cmd /c` may exit0 without performing the intended action; verify + the expected effect, not only the exit code. +- **Output encodings**: the host decodes UTF-8. Native programs may emit CP936/GBK + or another code page. Prefer a program's UTF-8 option, or explicitly convert + known-encoding bytes before capture while preserving the original exit state. + Locale variables do not guarantee native UTF-8. Do not decode all Bash output + as GBK or infer encoding from replacement-character counts: one call can mix + UTF-8 and GBK, and an already-corrupted string cannot recover the original bytes. +- **run_code**: `codePaths` defaults ON and follows the total/delegate dialect + gates. Complete compatible plain path literals are rewritten best-effort with + the shared mount table, including Unicode letters/marks, spaces and `#` names. + Legal EOF line comments no longer prevent rewriting; tagged templates (such + as String.raw) retain their data and raw semantics. Interpolation, escapes, + uncertain regex/division syntax may remain unchanged; uncertainty abandons the + entire rewrite. Prefer native drive-letter or cwd-relative paths for direct + Node fs/path APIs; typed tool path arguments remain translated separately. + Lexical scanning cannot distinguish routes/search data/shell command strings + from paths. Do not rely on rewriting those values; disable `codePaths` when + needed (the model hint now accurately states OFF). +- **Security**: warnings are now visible, but Windows unconfined behavior is + unchanged. A file-tool policy denial never authorizes a shell retry. + ## Config The `gitbash-shell` row (its id has matched the settings namespace since v0.24.0; it was diff --git a/package.json b/package.json index 890833c..476fa8e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "icon.svg" ], "scripts": { - "test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs", + "test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs tests/compatibility-regressions.mjs", "test:preview": "node --import ./tests/host-loader.mjs --test tests/preview.mjs tests/bridge-preview.mjs tests/runtime-preview.mjs tests/host-preview.mjs", "version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json" }, diff --git a/src/bash-render.js b/src/bash-render.js index 2f8472e..fdafeb6 100644 --- a/src/bash-render.js +++ b/src/bash-render.js @@ -7,7 +7,7 @@ * here). Instead the plugin ships its OWN bash tool whose executor is a private * `GitBashSandboxExecutor` instance (never registered as a service), and this * file mirrors the official tool's rendering contract call-for-call so the - * model sees byte-identical output shapes: + * model sees the official output shapes, with an explicit unconfined warning: * * - `render.ts` of `@deepseek-ai/dsh-tool-bash` (result / promoted / job-read * text shaping), and @@ -28,6 +28,17 @@ export function sandboxDenialMarker(mode) { return `[sandbox: file access denied under ${mode} mode]` } +/** A capability warning, not a denial or permission to bypass another denial. */ +export function sandboxUnconfinedMarker(mode) { + return `[sandbox: Git Bash is unconfined on Windows — shell file-write restrictions are NOT enforced (requested mode: ${mode}); official file-tool write/edit policy still applies; never bypass its denial through shell]` +} + +/** Include the planned execution boundary even before the job has spawned. */ +export function renderBackground(jobId, sandbox) { + const notes = sandboxNotes(sandbox, []) + return `started background job ${jobId}${notes.length ? '\n' + notes.join('\n') : ''}` +} + /** The official same-turn escalation hint (escalation.ts:84). */ export function escalationHintMarker(subject) { return `[sandbox: escalation available — retry this exact ${subject} once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]` @@ -84,6 +95,7 @@ export function renderResult(result, escalationModes = []) { markers.push(sandboxDenialMarker(result.sandbox.mode)) if (escalationModes.length > 0) markers.push(escalationHintMarker('command')) } + if (result.sandbox?.enforcement === 'unconfined') markers.push(sandboxUnconfinedMarker(result.sandbox.mode)) // A command may trap SIGTERM and exit 0 after timeout; still report interruption. if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) // A kill from outside the call (the human stopping its job) is not a @@ -129,6 +141,7 @@ function sandboxNotes(sandbox, escalationModes) { if (escalationModes.length > 0) notes.push(escalationHintMarker('command')) return notes } + if (sandbox?.enforcement === 'unconfined') return [sandboxUnconfinedMarker(sandbox.mode)] return [] } @@ -192,12 +205,7 @@ export function renderJobRead(delta, lossy, spillPaths, sandbox, escalationModes const paths = Array.isArray(spillPaths) ? spillPaths : [] notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) } - if (sandbox && sandbox.runnerFailed) { - notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`) - } else if (sandbox && sandbox.denied) { - notices.push(sandboxDenialMarker(sandbox.mode)) - if (escalationModes.length > 0) notices.push(escalationHintMarker('command')) - } + notices.push(...sandboxNotes(sandbox, escalationModes)) if (notices.length === 0) return delta return `${delta}${delta.length > 0 && !delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` } diff --git a/src/bash-tool.js b/src/bash-tool.js index f4bd5fb..d946279 100644 --- a/src/bash-tool.js +++ b/src/bash-tool.js @@ -43,6 +43,7 @@ import { ESCALATION_TARGETS, parseExitStatus, + renderBackground, renderJobRead, renderPromoted, renderResult, @@ -86,7 +87,7 @@ export async function resolveHostModule(ctx, name) { } /** - * The official bash description (tool-bash index.ts:90), verbatim. + * The official bash description plus unavoidable Git for Windows boundaries. * @returns {string} */ export function bashDescription() { @@ -97,7 +98,11 @@ export function bashDescription() { + 'Provide `description` before `command` in the arguments. ' + 'Before any delete or move, verify that the resolved absolute target path is the intended one; never run it against a computed path you have not checked. ' + 'An unset variable expands to an empty string, so guard variables in such paths with `${VAR:?}`. ' - + 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]`, a policy denial: do not retry another way.' + + 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]`, a policy denial: do not retry another way. ' + + 'Git for Windows (MSYS) converts path-looking arguments when invoking native Windows programs: ipconfig /all and cmd /c may break; use ipconfig //all or cmd //c, or a per-command MSYS2_ARG_CONV_EXCL prefix. ' + + 'Exclusions are prefixes: excluding /c also excludes /c/... paths, so use native drive-letter paths for those arguments; do not disable conversion globally. Verify the expected effect as well as the exit code. ' + + 'Output is decoded as UTF-8; native Windows programs may emit OEM/ANSI bytes (for example CP936/GBK) and display garbled text. Use a program UTF-8 option or explicit known-encoding conversion before capture; locale variables alone do not fix native output, and mixed streams cannot safely be guessed or decoded globally as GBK. ' + + 'On Windows, this Git Bash route does NOT enforce shell file-write restrictions in read-only/workspace-write mode (unconfined). Official file-tool write/edit policy is separate; never use shell to bypass its denial.' } /** The official foreground/present call view (index.ts:108). */ @@ -187,9 +192,17 @@ const BACKGROUND_OUTPUT_PROPERTIES = { kind: { type: 'string', required: true, const: 'background' }, jobId: { type: 'string', required: true }, cwd: { type: 'string', required: true }, + sandbox: { + type: 'object', additionalProperties: false, + properties: { + mode: { type: 'string', required: true }, + denied: { type: 'boolean', required: true }, + enforcement: { type: 'string' }, + }, + }, } -/** The official output union in the spec dialect defineTool consumes (index.ts:402). */ +/** The official output union, with optional background sandbox facts. */ function outputSchemaSpec() { return { oneOf: [ @@ -505,7 +518,15 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm } if (exec.signal.aborted) throw toolAborted(llmModule?.HarnessError) const spec = executor.resolve({ ...request, onExpiry: 'none' }) - return { kind: 'background', jobId: startJob(jobs, args, exec, spec).id, cwd: spec.workdir } + // The job may still be preparing: expose the planned route, not a + // claim that the command has already run or been confined. + const route = typeof executor.gitBashRoute === 'function' ? executor.gitBashRoute(spec) : undefined + const sandbox = route === undefined ? undefined : { + mode: route.mode, denied: false, + ...(route.unconfined ? { enforcement: 'unconfined' } : {}), + } + return { kind: 'background', jobId: startJob(jobs, args, exec, spec).id, cwd: spec.workdir, + ...(sandbox === undefined ? {} : { sandbox }) } } if (jobs !== undefined && promote) { const spec = executor.resolve({ ...request, onExpiry: 'none' }) @@ -534,7 +555,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm render: (_args, value) => [{ type: 'text', text: value.kind === 'background' - ? `started background job ${value.jobId}` + ? renderBackground(value.jobId, value.sandbox) : value.kind === 'promoted' ? renderPromoted(value) : renderResult(value, escalationModes), @@ -564,7 +585,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm render: (_args, value) => [{ type: 'text', text: value.kind === 'background' - ? `started background job ${value.jobId}` + ? renderBackground(value.jobId, value.sandbox) : value.kind === 'promoted' ? renderPromoted(value) : renderResult(value, escalationModes), diff --git a/src/index.js b/src/index.js index 3413e3b..7d306ee 100644 --- a/src/index.js +++ b/src/index.js @@ -612,15 +612,15 @@ const POSIX_DIRECTIVE_TEXT_STRICT = 'The working shell is Git for Windows bash: * write paths into, so the sentence would be pure noise there. Cheap and * honest: the tool list of the very assembly being rendered decides. */ -const RUN_CODE_DIRECTIVE_TEXT = ' Path literals written inside a run_code program are translated the same way before the program runs; shell expansion is not available there, so spell paths out instead of relying on $VARS.' -const RUN_CODE_DIRECTIVE_STRICT_TEXT = ' Path literals written inside a run_code program are translated before the program runs.' +const RUN_CODE_DIRECTIVE_TEXT = ' In a run_code program, compatible plain MSYS path literals are translated on a best-effort basis; interpolated or tagged templates and uncertain syntax are not rewritten. For direct native Node fs/path APIs, prefer explicit Windows drive-letter or cwd-relative paths. Do not rely on literal rewriting for dynamic paths, shell command strings, routes or search data; tool path arguments are translated separately. Shell expansion is unavailable in program strings, so do not rely on $VARS.' +const RUN_CODE_DIRECTIVE_OFF_TEXT = ' In a run_code program, automatic path-literal rewriting is OFF. Direct native Node fs/path APIs require Windows drive-letter or cwd-relative paths, not MSYS /c/... strings. Tool path arguments are still translated separately; program strings do not perform shell expansion.' /** The run_code sentence for this assembly, or '' when the tool is not offered. */ -export function runCodeHintFor(tools) { +export function runCodeHintFor(tools, codePaths = true) { if (!Array.isArray(tools)) return '' for (const tool of tools) { const name = tool && typeof tool.name === 'string' ? tool.name : '' - if (name === 'run_code') return RUN_CODE_DIRECTIVE_TEXT + if (name === 'run_code') return codePaths === true ? RUN_CODE_DIRECTIVE_TEXT : RUN_CODE_DIRECTIVE_OFF_TEXT } return '' } @@ -1155,15 +1155,15 @@ export function translatePathArguments(args, env) { // sees it (see AGENTS.md 4c). The same mount table is therefore applied to the // program's string literals — but ONLY when the whole program scans cleanly and // a literal's ENTIRE content is an absolute MSYS path. Anything unclear — an -// unterminated literal, a template with ${} interpolation, a regex we cannot -// place — turns the whole rewrite into a no-op: a half-rewritten program would +// unterminated literal or a regex we cannot place — turns the whole rewrite +// into a no-op. Interpolated/tagged templates are skipped as data. A partial guess would // be far worse than an untranslated one, and this layer never guesses. // // Deliberately NOT translated: shell-style '$VAR/x' (a JS literal is data, not // a shell word) and anything carrying a scheme ('http://...'). /** Source text allowed inside a rewritten literal: no escapes, no dollar sign. */ -const CODE_LITERAL_SAFE = /^[A-Za-z0-9._@+~\-/ ]+$/ +const CODE_LITERAL_SAFE = /^[\p{L}\p{M}\p{N}._@+~#\-/ ]+$/u /** Previous significant character that lets a '/' start a regex literal. */ function regexCanStart(previous) { @@ -1309,7 +1309,8 @@ export function scanCodeLiterals(code) { const next = code[i + 1] if (c === '/' && next === '/') { const nl = code.indexOf('\n', i) - if (nl === -1) return null + // A line comment may legally terminate at EOF (outside interpolation). + if (nl === -1) break i = nl continue } @@ -1330,7 +1331,12 @@ export function scanCodeLiterals(code) { if (c === '`') { const template = skipTemplateLiteral(code, i) if (template === null) return null - if (template.plain) spans.push({ start: i + 1, end: template.end - 1 }) + // A preceding expression can be a tag (String.raw, fn(), obj[key], ...). + // Tags own raw/cooked semantics: never rewrite their template data. + // Contextual words can also be identifiers/tags; skip rather than guess. + if (template.plain && regexCanStart(last) && !['of', 'await', 'yield'].includes(last)) { + spans.push({ start: i + 1, end: template.end - 1 }) + } i = template.end last = 'x' continue @@ -2179,7 +2185,7 @@ export async function apply(ctx, config = {}) { // v0.20.1: the run_code sentence rides the SAME context entry, but // only when this assembly actually offers the tool (see // runCodeHintFor) — every other mode stays quiet. - const hint = runCodeHintFor(assembly.tools) + const hint = runCodeHintFor(assembly.tools, dialect.codePaths === true) if (hint !== '') { for (const assembled of Array.isArray(assembly.contexts) ? assembly.contexts : []) { if (assembled && assembled.name === 'gitbash-shell:posix-paths' && typeof assembled.text === 'string') { diff --git a/src/shell.js b/src/shell.js index e327da4..b92abd8 100644 --- a/src/shell.js +++ b/src/shell.js @@ -10,25 +10,23 @@ * and hands it to the plugin's OWN bash tool (src/bash-tool.js). The class * body is unchanged from its executor-row era: * - * - extends @deepseek-ai/dsh-bash-sandbox so every confined-mode - * (read-only / workspace-write) call keeps the exact sandbox policy, - * denial, and runner-failure semantics of the shipped stack; + * - extends @deepseek-ai/dsh-bash-sandbox to inherit policy, denial and + * lifecycle semantics EXCEPT the explicit Windows unconfined route below; * - overrides `confine` so the inner argv is [git-bash, -c, command]; * - overrides run/start ONLY for danger-full-access (the parent's * full-access branch calls LocalBashExecutor.run, which hardcodes the * bare `bash` name), routing through runArgv/startArgv with the same * argv as the confined branch; * - on Windows ALSO overrides the confined branch itself (issue #1): - * MSYS2 cannot start under the restricted-token sandbox — msys-2.0.dll - * init creates its cygheap mapping and signal pipe with DACLs naming - * only the user SID, a WRITE_RESTRICTED token's pass-2 write check - * demands a restricting-SID ACE, so init dies with Win32 error 5 / - * 0xC0000142 before argv ever runs (silent fake-success or a hard - * crash, every version since 0.6.0; cmd/pwsh are unaffected — - * anonymous pipes). OS-level conflict with no in-plugin cure, so - * confined Windows calls run Git Bash UNCONFINED, labelled as such in - * the result (sandbox.enforcement === 'unconfined') plus a one-time - * notice. The fs-tool sandbox policy still applies to file tools. + * historical MSYS2/restricted-token tests failed during msys-2.0.dll + * cygheap/signal-pipe initialization (Win32 error 5 / 0xC0000142; + * restricting-SID object permissions, CHANGELOG v0.13.2). No working + * confined Git Bash implementation is provided here; this is not a claim + * that every future runtime/isolation design is impossible. Windows calls + * retain the explicit UNCONFINED route, labelled on the handle/result and + * in model-facing foreground/background text, plus a one-time notice. + * Official file-tool write/edit policy remains separate; it does not + * constrain shell code and its denials must never be bypassed through shell. * * Environment inheritance: bash.exe is spawned as a direct child of the host * process (never through the git-bash.exe login launcher), so it inherits the @@ -262,6 +260,8 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { * @returns the same handle, with `result()` mapped. */ static decorateExecution(ex, sandbox) { + // Background reads/outcomes inspect the handle before result() settles. + ex.sandbox = sandbox const base = ex.result.bind(ex) let decorated ex.result = () => { @@ -407,7 +407,7 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor { warnConfinedUnconfined(mode) { if (this._warnedUnconfined) return this._warnedUnconfined = true - console.log(TAG + ' Windows: MSYS2 cannot start under the restricted-token sandbox (msys-2.0.dll init: cygheap/signal-pipe DACLs carry no restricting-SID ACE -> Win32 error 5 / 0xC0000142; issue #1). Confined call (' + mode + ') ran Git Bash UNCONFINED; the fs-tool sandbox policy still applies.') + console.log(TAG + ' Windows: confined Git Bash is not provided by this restricted-token backend (issue #1). Requested mode ' + mode + ' will use an UNCONFINED Git Bash route: shell file-write restrictions are NOT enforced. Official file-tool write/edit policy is separate; never bypass its denial through shell.') } } diff --git a/tests/compatibility-regressions.mjs b/tests/compatibility-regressions.mjs new file mode 100644 index 0000000..4b303ad --- /dev/null +++ b/tests/compatibility-regressions.mjs @@ -0,0 +1,134 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { rewriteCodePaths, scanCodeLiterals, runCodeHintFor, translateDispatch, dialectApplies, windowsToMsys } from '../src/index.js' +import { bashDescription } from '../src/bash-tool.js' +import { renderResult, renderBackground, renderJobRead, renderPromoted, processOutcome, parseExitStatus } from '../src/bash-render.js' + +const env = { home: 'C:/Users/审计', tmpDir: 'C:/Temp', gitRoot: 'C:/Program Files/Git' } +const sandbox = { mode: 'workspace-write', denied: false, enforcement: 'unconfined' } +const result = { stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false }, exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, sandbox } + +// These programs only compute string values; no filesystem or subprocess calls. +test('compatibility: complete Unicode paths and hash filenames become native literals', () => { + for (const [before, after] of [ + ["return '/c/工作/文件#1.txt'", "return 'C:/工作/文件#1.txt'"], + ['return "/d/café/中文 文件.txt"', 'return "D:/café/中文 文件.txt"'], + ['return `/c/工作/文件.txt`', 'return `C:/工作/文件.txt`'], + ["return '~/笔记.txt'", "return 'C:/Users/审计/笔记.txt'"], + ]) { + assert.equal(rewriteCodePaths(before, env), after) + assert.equal(new Function(after)(), new Function(rewriteCodePaths(before, env))()) + } +}) + +test('compatibility: a legal EOF line comment does not discard earlier paths', () => { + const source = "return '/c/工作/a.txt'; // '/d/ignored'" + assert.ok(Array.isArray(scanCodeLiterals(source))) + assert.equal(rewriteCodePaths(source, env), "return 'C:/工作/a.txt'; // '/d/ignored'") + for (const broken of ["const p='/c/a'; /* unfinished", "const p='/c/a'; const q='unfinished", "const p='/c/a'; const t=`${a // missing brace"]) + assert.equal(rewriteCodePaths(broken, env), broken, 'uncertain source still abandons the whole rewrite') +}) + +test('compatibility: tagged templates retain raw and custom-tag semantics', () => { + for (const source of [ + 'return String.raw`/dev/null`', + 'return tag`/c/工作/a`', + 'return tag()`/c/a`', + 'return tags[0]`/c/a`', + 'return (tag)`/c/a`', + 'const of = String.raw; return of`/dev/null`', + 'const await = String.raw; return await`/dev/null`', + 'const yield = String.raw; return yield`/dev/null`', + ]) assert.equal(rewriteCodePaths(source, env), source) + const source = 'const p="/c/工作/a"; return [p, String.raw`/dev/null`]' + assert.deepEqual(new Function(rewriteCodePaths(source, env))(), ['C:/工作/a', '/dev/null']) + const device = '\\\\' + '.' + '\\' + 'NUL' + const deviceSource = rewriteCodePaths("return '/dev/null'", env) + assert.equal(deviceSource, "return '" + device.replace(/\\/g, '\\\\') + "'") + assert.equal(new Function(deviceSource)(), device) +}) + +test('compatibility: interpolation, escape sequences and ambiguous slash stay conservative', () => { + for (const source of ['return `/c/${name}/文件`', "return '/c/a\\u002eb'", "const p='/c/工作/a'; return (4+2) / 2"]) + assert.equal(rewriteCodePaths(source, env), source) + assert.equal(rewriteCodePaths("return '/c/' + name", env), "return 'C:/' + name") + const unsafe = "const p='/c/工作/a'; return '~/b'" + assert.equal(rewriteCodePaths(unsafe, { home: "C:/user's" }), unsafe, 'translation cannot introduce the closing quote') +}) + +test('compatibility: run_code hints truthfully distinguish codePaths ON and OFF', () => { + const tools = [{ name: 'run_code' }] + assert.match(runCodeHintFor(tools, true), /best-effort/) + assert.match(runCodeHintFor(tools, true), /tagged templates/) + assert.match(runCodeHintFor(tools, true), /routes or search data/) + const off = runCodeHintFor(tools, false) + assert.match(off, /rewriting is OFF/) + assert.doesNotMatch(off, /are translated.*before the program/) + assert.equal(runCodeHintFor([{ name: 'bash' }], false), '') + const args = { code: "return '/c/a'", file_path: '/c/tool-path.txt' } + const out = translateDispatch({ name: 'run_code', arguments: args }, { codePaths: false }, env) + assert.equal(out.code, args.code) + assert.equal(out.file_path, 'C:/tool-path.txt', 'codePaths does not disable typed tool arguments') + assert.equal(args.file_path, '/c/tool-path.txt', 'caller input is not mutated') +}) + +test('compatibility: actual prompt listener reads the live codePaths gate', () => { + const source = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8') + const start = source.indexOf("ctx.on('system-prompt/assemble',") + const end = source.indexOf(" console.log(TAG + ' prompt-assembly path dialect active", start) + assert.ok(start >= 0 && end > start) + let handler + let dialect = { posixPaths: true, codePaths: true, subagentDialect: true } + const ctx = { on(name, fn) { assert.equal(name, 'system-prompt/assemble'); handler = fn } } + new Function('ctx', 'liveSettings', 'dialectApplies', 'windowsToMsys', 'runCodeHintFor', 'GITBASH_ROUTE_ADDENDUM', 'GITBASH_ROUTE_ADDENDUM_NO_PWSH', 'GITBASH_MSYS_ADDENDUM', source.slice(start, end))( + ctx, { dialect: () => dialect, pwshTool: () => false }, dialectApplies, windowsToMsys, runCodeHintFor, '', '', '') + const assemble = agent => { + const assembly = { tools: [{ name: 'run_code' }], contexts: [{ name: 'gitbash-shell:posix-paths', text: 'BASE' }], sections: [] } + handler(assembly, { agent }, () => {}) + return assembly.contexts[0].text + } + assert.match(assemble(), /best-effort/) + dialect = { ...dialect, codePaths: false } + assert.match(assemble(), /rewriting is OFF/) + dialect = { ...dialect, posixPaths: false } + assert.equal(assemble(), 'BASE') + dialect = { ...dialect, posixPaths: true, subagentDialect: false } + assert.equal(assemble({ session: { header: { origin: 'subagent' } } }), 'BASE') +}) + +test('compatibility: the registered bash description always warns about native boundaries', () => { + const text = bashDescription() + assert.match(text, /ipconfig \/all and cmd \/c/) + assert.match(text, /ipconfig \/\/all or cmd \/\/c/) + assert.match(text, /Exclusions are prefixes/) + assert.match(text, /do not disable conversion globally/) + assert.match(text, /expected effect as well as the exit code/) + assert.match(text, /decoded as UTF-8/) + assert.match(text, /CP936\/GBK/) + assert.match(text, /mixed streams cannot safely be guessed/) + assert.match(text, /does NOT enforce shell file-write restrictions/) + assert.match(text, /never use shell to bypass its denial/) +}) + +test('compatibility: unconfined foreground warning preserves exit parsing and cancellation', () => { + assert.match(renderResult(result), /unconfined.*NOT enforced/) + assert.deepEqual(parseExitStatus(renderResult({ ...result, exitCode: 2, timedOut: true })), { + body: renderResult({ ...result, exitCode: 2, timedOut: true }).replace(/\n\[exit code: 2\]$/, ''), exitCode: 2, + }) + const killed = renderResult({ ...result, signal: 'SIGTERM', timedOut: true }) + assert.match(killed, /unconfined/) + assert.match(killed, /\[killed by signal: SIGTERM\]$/) + assert.doesNotMatch(renderResult({ ...result, sandbox: { mode: 'danger-full-access', denied: false } }), /unconfined/) +}) + +test('compatibility: unconfined warnings reach initial/background/promoted feedback', () => { + assert.match(renderBackground('job1', sandbox), /started background job job1\n.*unconfined/) + assert.equal(renderBackground('job1'), 'started background job job1') + const delta = renderJobRead('data', false, [], sandbox, ['workspace-write']) + assert.match(delta, /data\n.*unconfined/) + assert.doesNotMatch(delta, /escalation available/, 'unconfined is not a denial or an escalation request') + assert.match(processOutcome({ status: 'completed', exitCode: 0, sandbox }).detail, /unconfined/) + assert.match(renderPromoted({ jobId: 'job1', timeoutMs: 10, output: delta }), /unconfined[\s\S]*moved to background/) + assert.match(renderJobRead('', true, ['spill'], sandbox, []), /output was dropped[\s\S]*unconfined/) +}) diff --git a/tests/runtime-preview.mjs b/tests/runtime-preview.mjs index eafae3e..5d693bd 100644 --- a/tests/runtime-preview.mjs +++ b/tests/runtime-preview.mjs @@ -2,6 +2,8 @@ // subprocesses. No production profile, filesystem writes, or model requests. import test from 'node:test' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import * as dialectHelpers from '../src/index.js' import { Context } from '@deepseek-ai/cordis' import { createScope as createAgentScope } from '@deepseek-ai/dsh-scope' import { ToolRuntime, defineTool } from '@deepseek-ai/dsh-tools' @@ -260,3 +262,81 @@ test('runtime preview: private execution retains deadlines, background no-expiry await assert.rejects(failed.result(), /isolated provider failure/) assert.equal(f.root.get('shell'), f.official) }) + +test('runtime preview: actual ToolRuntime surfaces unconfined foreground and initial background facts', { timeout: 10000 }, async t => { + const f = await fixture(t) + await bounded(f.activated.promise, 'compatibility bash activation') + assert.match(f.tools.get('bash', f.agent).description, /MSYS2_ARG_CONV_EXCL/) + for (const mode of ['read-only', 'workspace-write']) { + f.agent.session.header.policy = { mode, workspaceRoot: process.cwd() } + const value = await f.execute('bash', { command: 'memory-only command', description: 'Verify unconfined model feedback' }) + assert.equal(value.isError, false, JSON.stringify(value)) + assert.equal(value.value.sandbox.enforcement, 'unconfined') + assert.match(value.content[0].text, /unconfined.*NOT enforced/) + } + const admitted = [] + const off = f.root.provide('jobs', { start(spec) { admitted.push(spec); return 'compatibility-background' } }) + try { + await new Promise(resolve => setImmediate(resolve)) + const value = await f.execute('bash', { command: 'memory-only command', description: 'Verify admission warning', run_in_background: true }) + assert.equal(value.isError, false, JSON.stringify(value)) + assert.equal(value.value.kind, 'background') + assert.equal(value.value.sandbox.enforcement, 'unconfined') + assert.match(value.content[0].text, /started background job compatibility-background[\s\S]*unconfined/) + assert.equal(admitted.length, 1) + } finally { await off() } + assert.equal(f.root.get('shell'), f.official) +}) + +test('runtime preview: actual execution handle carries unconfined facts for live jobs', { timeout: 10000 }, async t => { + const f = await fixture(t, { existingBash: true }) + const { executor, executorScope } = await mountPrivateExecutor(f.root, GitBashSandboxExecutor, createScope) + t.after(() => executorScope.dispose()) + const spec = executor.resolve({ command: 'memory-only command', workdir: process.cwd(), sandboxPolicy: { mode: 'read-only', workspaceRoot: process.cwd() } }) + const handle = await executor.execute(spec) + assert.equal(handle.sandbox.enforcement, 'unconfined') + assert.equal(handle.result(), handle.result(), 'projection remains memoized') + assert.deepEqual((await handle.result()).sandbox, handle.sandbox) + assert.equal(f.root.get('shell'), f.official) +}) + +// The real run_code transport captures rewritten source in a mock PTC runtime; +// it does NOT evaluate the program or claim real PTC-engine/filesystem coverage. +test('runtime preview: actual run_code transport receives Unicode/EOF fixes and preserves tagged data', async t => { + const root = new Context() + const cleanup = [] + const captured = [] + const env = { tmpDir: 'C:/compatibility-temp', home: 'C:/compatibility-home', gitRoot: 'C:/Program Files/Git' } + let dialect = { posixPaths: true, codePaths: true, virtualMounts: true, subagentDialect: true } + for (const [name, value] of Object.entries({ + workingDirectory: { ensure: async () => process.cwd() }, + ptcRuntime: { language: 'typescript', executionInstructions: 'Memory-only source capture', resolve: spec => spec, + async run(spec) { captured.push(spec.program); return { logs: [], value: spec.program } } }, + })) cleanup.push(root.provide(name, value)) + const mount = async (module, config) => { const fiber = root.plugin(module, config); cleanup.push(() => fiber.dispose()); await fiber } + t.after(async () => { for (const off of cleanup.reverse()) await off() }) + await mount(SystemPrompt) + await mount(ToolRuntime, { mode: 'ptc' }) + const source = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8') + const start = source.indexOf("ctx.on('tools/execute',") + const end = source.indexOf(" console.log(TAG + ' MSYS path translation active", start) + assert.ok(start >= 0 && end > start) + await mount(ctx => { + new Function('ctx', 'liveSettings', 'dialectApplies', 'buildTranslateEnv', 'readConfiguredBashPath', 'translateDispatch', 'rewriteResultPaths', source.slice(start, end))( + ctx, { dialect: () => dialect }, dialectHelpers.dialectApplies, () => env, () => '', dialectHelpers.translateDispatch, dialectHelpers.rewriteResultPaths) + }) + const run = async code => { + const args = { description: 'Capture compatible program without executing it', code } + const value = await root.get('tools').execute({ name: 'run_code', callId: 'compat-code-' + captured.length, arguments: args, signal: new AbortController().signal }) + assert.equal(value.isError, false, JSON.stringify(value)) + assert.equal(args.code, code, 'caller source stays unchanged') + return captured.at(-1) + } + const sourceOn = 'const p="/c/工作/文件#1.txt"; return [p, String.raw`/dev/null`]; // EOF' + assert.equal(await run(sourceOn), dialectHelpers.programPrelude(env) + 'const p="C:/工作/文件#1.txt"; return [p, String.raw`/dev/null`]; // EOF') + dialect = { ...dialect, codePaths: false } + assert.equal(await run(sourceOn), sourceOn) + dialect = { ...dialect, codePaths: true } + const uncertain = "const p='/c/工作/a'; return (4+2) / 2" + assert.equal(await run(uncertain), dialectHelpers.programPrelude(env) + uncertain) +}) diff --git a/tests/runtime-regressions.mjs b/tests/runtime-regressions.mjs index f7ac505..d11d3ef 100644 --- a/tests/runtime-regressions.mjs +++ b/tests/runtime-regressions.mjs @@ -176,3 +176,29 @@ test('runtime: missing approval helper still passes the agent\'s standing mode a assert.equal(value.sandbox.mode, 'read-only') await assert.rejects(() => definition.execute({ command: 'echo ok', description: 'Reject unavailable escalation', sandbox_permissions: 'danger-full-access', justification: 'Need broader mode.' }, exec), /no sandbox helper/) }) + +test('runtime: background admission exposes its planned unconfined route before any spawn', async t => { + const admitted = [] + const executor = { + sandboxMode: 'danger-full-access', + resolve: request => ({ ...request, timeoutMs: 10 }), + gitBashRoute: spec => ({ mode: spec.sandboxPolicy.mode, unconfined: spec.sandboxPolicy.mode !== 'danger-full-access' }), + execute() { throw new Error('admission fixture must not spawn') }, + } + const f = await toolFixture(t, { executor, jobs: { start(spec) { admitted.push(spec); return 'admitted-job' } } }) + const definition = f.definitions.get('bash') + assertJsonSchema(definition.output.schema) + const schema = definition.output.schema.oneOf[0] + assert.deepEqual(schema.properties.sandbox.required, ['mode', 'denied']) + for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { + const agent = { session: { header: { mode, cwd: 'D:/child-workspace' } } } + const value = await definition.execute({ command: 'memory-only command', description: 'Verify background planned route', run_in_background: true }, { agent, signal: new AbortController().signal }) + assert.equal(value.kind, 'background') + assert.equal(value.sandbox.mode, mode) + const text = definition.output.render({}, value)[0].text + assert.match(text, /started background job admitted-job/) + if (mode === 'danger-full-access') assert.doesNotMatch(text, /unconfined/) + else assert.match(text, /unconfined.*NOT enforced/) + } + assert.equal(admitted.length, 3) +}) diff --git a/tests/smoke.mjs b/tests/smoke.mjs index dd2819c..292ae42 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -786,6 +786,8 @@ test('executor: dsh 0.1.7 execute() keeps both Git Bash substitutions (issue #6 const confinedHandle = await confinedEx.execute(confined) assert.equal(kindOf(confinedEx, 'super.execute').length, 0) assert.deepEqual(kindOf(confinedEx, 'executeArgv')[0][1], GIT_BASH_ARGV) + assert.deepEqual(confinedHandle.sandbox, { mode: 'workspace-write', denied: false, enforcement: 'unconfined' }, + 'background readers see the boundary before result() is awaited') assert.deepEqual((await confinedHandle.result()).sandbox, { mode: 'workspace-write', denied: false, enforcement: 'unconfined' }) assert.ok(logs.some((line) => line.includes('UNCONFINED')), 'the one-time notice still fires') @@ -925,7 +927,7 @@ test('the run_code literal rewrite has its own switch (codePaths)', async () => assert.ok(strict !== null, 'strict directive text is gone') assert.doesNotMatch(strict[1], /run_code/, 'the strict directive must not talk about run_code either') assert.match(text, /const RUN_CODE_DIRECTIVE_TEXT = '/, 'the conditional run_code sentence is gone') - assert.match(text, /runCodeHintFor\(assembly\.tools\)/, 'the assembly hook must gate the sentence on the tool list') + assert.match(text, /runCodeHintFor\(assembly\.tools, dialect\.codePaths === true\)/, 'the assembly hook must gate on the tool list and live codePaths state') }) test('the run_code sentence is injected only where the tool exists (v0.20.1)', async () => { From c79332ea2fb6da38042063f7b55046115de8e530 Mon Sep 17 00:00:00 2001 From: KouzakiUmi Date: Tue, 13 Oct 2026 22:06:44 +0800 Subject: [PATCH 2/2] fix: render sandbox facts in timeout promotion feedback --- CHANGELOG.md | 11 +++++++ src/bash-render.js | 9 ++++-- src/bash-tool.js | 13 ++++++-- tests/compatibility-regressions.mjs | 10 +++++- tests/runtime-preview.mjs | 50 +++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64b1933..39fde74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ **类型**:fix(MSYS 参数/编码提示、源码路径边界与 unconfined 可观测性) / test / docs。 +### PR #24:超时转后台的独立 sandbox 警示 + +- promoted 返回值显式携带可选 sandbox facts,输出 schema 保持闭合;defineTool 与 JSON-schema 回退共用此契约。 +- `renderPromoted` 自行追加 sandbox notes,不再依赖 output 预先含警示;promotion 输出保留丢失/spill 提示, + 避免重复追加 sandbox 警示。无 sandbox/full-access 的原反馈保持不变,denied/runnerFailed 分支继续保留。 +- 纯回归改用不含警示的原始文本,覆盖空输出、full-access、拒绝与 runner failure。 + 新增实际 ToolRuntime→私有执行器→内存 provider 的 promotion 回归(Jobs wait/read 为受控桩), + 验证 RO/WW 警示各出现一次、full 无警示、DTO 与最终模型文本分离、丢失/spill 提示及 root 服务不变。 +- 验证:普通完整回归 **166/166**、实际安装Core完整preview **18/18**,均exit0,无skip/cancel; + 语法与diff检查通过。不等同于生产GUI或真实Jobs时钟验收。 + ### MSYS 兼容边界尝试修复 - **范围**:从 [`6b26ded`](https://github.com/KouzakiUmi/dsh-gitbash-shell/commit/6b26ded6cc3a2a6113579149286047629b433c48) diff --git a/src/bash-render.js b/src/bash-render.js index fdafeb6..7f87d48 100644 --- a/src/bash-render.js +++ b/src/bash-render.js @@ -115,16 +115,19 @@ export function renderResult(result, escalationModes = []) { /** * Shape a foreground call that stopped waiting into the text the model sees. * Mirrors the official `renderPromoted` (tool-bash render.ts:77). - * @param {{ jobId: string, timeoutMs: number, output: string }} promoted + * @param {{ jobId: string, timeoutMs: number, output: string, sandbox?: object }} promoted + * @param {string[]} [escalationModes] * @returns {string} */ -export function renderPromoted(promoted) { +export function renderPromoted(promoted, escalationModes = []) { const body = promoted.output.length > 0 ? (promoted.output.endsWith('\n') ? promoted.output : `${promoted.output}\n`) : '' - return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]\n` + const text = `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]\n` + 'The command keeps running in the background. You will be notified when it finishes; ' + 'read newer output with job_output, stop it with job_kill.' + const notes = sandboxNotes(promoted.sandbox, escalationModes) + return notes.length > 0 ? `${text}\n${notes.join('\n')}` : text } /** diff --git a/src/bash-tool.js b/src/bash-tool.js index d946279..f6a206b 100644 --- a/src/bash-tool.js +++ b/src/bash-tool.js @@ -216,6 +216,10 @@ function outputSchemaSpec() { jobId: { type: 'string', required: true }, timeoutMs: { type: 'number', required: true }, output: { type: 'string', required: true }, + sandbox: { + ...BACKGROUND_OUTPUT_PROPERTIES.sandbox, + properties: { ...BACKGROUND_OUTPUT_PROPERTIES.sandbox.properties, runnerFailed: { type: 'boolean' } }, + }, }, }, { @@ -437,14 +441,17 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm } if (view.status === 'running' || view.status === 'stopping') { const read = registry.read(attached.id, owner) + const sandbox = attached.process()?.sandbox return { kind: 'promoted', cwd: spec.workdir, jobId: attached.id, timeoutMs, + // Preserve ring/spill notices; sandbox notes belong to the final renderer. output: renderJobRead( - ringDelta(read.chunks), read.lossy, read.job.output.spillPaths ?? [], attached.process()?.sandbox, escalationModes, + ringDelta(read.chunks), read.lossy, read.job.output.spillPaths ?? [], undefined, escalationModes, ), + ...(sandbox === undefined ? {} : { sandbox }), } } registry.remove(attached.id, owner) @@ -557,7 +564,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm text: value.kind === 'background' ? renderBackground(value.jobId, value.sandbox) : value.kind === 'promoted' - ? renderPromoted(value) + ? renderPromoted(value, escalationModes) : renderResult(value, escalationModes), }], presentationMeta: (_args, value) => ({ cwd: value.cwd }), @@ -587,7 +594,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm text: value.kind === 'background' ? renderBackground(value.jobId, value.sandbox) : value.kind === 'promoted' - ? renderPromoted(value) + ? renderPromoted(value, escalationModes) : renderResult(value, escalationModes), }], presentationMeta: (_args, value) => ({ cwd: value.cwd }), diff --git a/tests/compatibility-regressions.mjs b/tests/compatibility-regressions.mjs index 4b303ad..5e0369c 100644 --- a/tests/compatibility-regressions.mjs +++ b/tests/compatibility-regressions.mjs @@ -129,6 +129,14 @@ test('compatibility: unconfined warnings reach initial/background/promoted feedb assert.match(delta, /data\n.*unconfined/) assert.doesNotMatch(delta, /escalation available/, 'unconfined is not a denial or an escalation request') assert.match(processOutcome({ status: 'completed', exitCode: 0, sandbox }).detail, /unconfined/) - assert.match(renderPromoted({ jobId: 'job1', timeoutMs: 10, output: delta }), /unconfined[\s\S]*moved to background/) + const promoted = { jobId: 'job1', timeoutMs: 10, output: 'data', sandbox } + assert.doesNotMatch(promoted.output, /unconfined/) + assert.match(renderPromoted(promoted), /data\n.*moved to background[\s\S]*unconfined.*NOT enforced/) + assert.equal((renderPromoted(promoted).match(/NOT enforced/g) ?? []).length, 1) + assert.doesNotMatch(renderPromoted({ ...promoted, sandbox: { mode: 'danger-full-access', denied: false } }), /unconfined/) + assert.doesNotMatch(renderPromoted({ jobId: 'job1', timeoutMs: 10, output: '' }), /sandbox:/) + assert.match(renderPromoted({ ...promoted, sandbox: { mode: 'read-only', denied: true } }, ['workspace-write']), /sandbox:.*read-only[\s\S]*escalation available/) + assert.match(renderPromoted({ ...promoted, sandbox: { mode: 'read-only', denied: false, runnerFailed: true } }), /runner itself failed/) + assert.match(renderPromoted({ ...promoted, output: '' }), /moved to background[\s\S]*unconfined/) assert.match(renderJobRead('', true, ['spill'], sandbox, []), /output was dropped[\s\S]*unconfined/) }) diff --git a/tests/runtime-preview.mjs b/tests/runtime-preview.mjs index 5d693bd..6d66287 100644 --- a/tests/runtime-preview.mjs +++ b/tests/runtime-preview.mjs @@ -288,6 +288,56 @@ test('runtime preview: actual ToolRuntime surfaces unconfined foreground and ini assert.equal(f.root.get('shell'), f.official) }) +test('runtime preview: actual promoted DTO renders sandbox notes from handle facts, not output text', { timeout: 10000 }, async t => { + for (const mode of ['read-only', 'workspace-write', 'danger-full-access']) { + const spawned = deferred() + const finished = deferred() + const raw = 'promotion-raw-output' + const readFrom = () => ({ text: raw, nextOffset: raw.length, lossy: false }) + const f = await fixture(t, { subprocessRuntime: { spawn() { + spawned.resolve() + return { done: finished.promise, collected: { stdout: { readFrom }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }, + terminate() { finished.resolve({ exitCode: 1, signal: 'SIGTERM' }) } } + } } }) + await bounded(f.activated.promise, `promotion activation: ${mode}`) + f.agent.session.header.policy = { mode, workspaceRoot: process.cwd() } + let hooks + const off = f.root.provide('jobs', { + start(spec) { hooks = spec.run(); return 'compatibility-promoted' }, + async wait(_id, timeoutMs) { + assert.equal(timeoutMs, 5) + await bounded(spawned.promise, 'promotion producer admission') + await new Promise(resolve => setImmediate(resolve)) + return { status: 'running' } + }, + read() { return { chunks: [{ channel: 'stdout', text: raw }], lossy: true, job: { output: { spillPaths: ['promotion-spill'] } } } }, + }) + try { + await new Promise(resolve => setImmediate(resolve)) + const value = await f.execute('bash', { command: 'memory-only command', description: 'Verify promotion sandbox facts', timeoutMs: 5 }) + assert.equal(value.isError, false, JSON.stringify(value)) + assert.equal(value.value.kind, 'promoted') + assert.equal(value.value.sandbox.mode, mode) + assert.match(value.value.output, /promotion-raw-output[\s\S]*output was dropped[\s\S]*promotion-spill/) + assert.doesNotMatch(value.value.output, /unconfined|NOT enforced/, 'the tool must not pre-render sandbox notes into output') + const text = value.content[0].text + assert.match(text, /moved to background job compatibility-promoted/) + if (mode === 'danger-full-access') assert.doesNotMatch(text, /unconfined|NOT enforced/) + else { + assert.equal(value.value.sandbox.enforcement, 'unconfined') + assert.match(text, /moved to background[\s\S]*unconfined.*NOT enforced/) + assert.equal((text.match(/NOT enforced/g) ?? []).length, 1) + assert.doesNotMatch(text, /escalation available/) + } + assert.equal(f.root.get('shell'), f.official) + } finally { + finished.resolve({ exitCode: 0, signal: null }) + if (hooks) await hooks.done + await off() + } + } +}) + test('runtime preview: actual execution handle carries unconfined facts for live jobs', { timeout: 10000 }, async t => { const f = await fixture(t, { existingBash: true }) const { executor, executorScope } = await mountPrivateExecutor(f.root, GitBashSandboxExecutor, createScope)