@godv61/dsh-task-engine 0.22.2 → 0.22.3

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/.p0-test.mjs CHANGED
@@ -140,7 +140,8 @@ await assertThrows(
140
140
  const prop = await exe({ operation: 'init', phase: 'propose', content: '# 项目\n示例' }, EXEC)
141
141
  assert(prop.includes('proposed create'), 'propose previews without writing')
142
142
  assert(fs._files.get('AGENTS.md') === undefined, 'propose writes nothing')
143
- const app = await exe({ operation: 'init', phase: 'apply', content: '# 项目\n示例' }, EXEC)
143
+ const hash9 = /content hash: ([0-9a-f]{64})/.exec(prop)[1]
144
+ const app = await exe({ operation: 'init', phase: 'apply', content: '# 项目\n示例', expected_hash: hash9 }, EXEC)
144
145
  assert(app.includes('wrote ./AGENTS.md'), 'apply writes the file')
145
146
  assert(fs._files.get('AGENTS.md') === '# 项目\n示例', 'apply persisted the draft')
146
147
  }
@@ -149,16 +150,23 @@ await assertThrows(
149
150
  {
150
151
  const fs = makeFs({ 'AGENTS.md': '# 已有治理文件\n' })
151
152
  const exe = await registered(fs)
153
+ const prop10 = await exe({ operation: 'init', phase: 'propose', content: '# new' }, EXEC)
154
+ const hash10 = /content hash: ([0-9a-f]{64})/.exec(prop10)[1]
152
155
  await assertThrows(
153
- () => exe({ operation: 'init', phase: 'apply', content: '# new' }, EXEC),
156
+ () => exe({ operation: 'init', phase: 'apply', content: '# new', expected_hash: hash10 }, EXEC),
154
157
  'protected',
155
158
  'apply without overwrite on an existing file is rejected',
156
159
  )
157
160
  await assertThrows(
158
- () => exe({ operation: 'init', phase: 'apply', content: '# new', overwrite: true }, EXEC),
161
+ () => exe({ operation: 'init', phase: 'apply', content: '# new', expected_hash: hash10, overwrite: true }, EXEC),
159
162
  'approval',
160
163
  'overwrite requires an approval service',
161
164
  )
165
+ await assertThrows(
166
+ () => exe({ operation: 'init', phase: 'apply', content: '# new' }, EXEC),
167
+ 'requires expected_hash',
168
+ 'apply without expected_hash is rejected',
169
+ )
162
170
  }
163
171
 
164
172
  // ── 11. a project cannot shadow a bundled core rule ────────────────────────
@@ -615,4 +623,50 @@ function memProbe(files) {
615
623
  assert(controller.includes('not an allowed workspace'), 'remote rejects system-wide roots')
616
624
  }
617
625
 
626
+ // ── 33. 0.23: verify runs in the task's recorded project root ──────────────
627
+ {
628
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }), 'package.json': '{}' })
629
+ const seenWorkdirs = []
630
+ const shell = {
631
+ resolve(request) { return { command: request.command, workdir: request.workdir ?? '', timeoutMs: request.timeoutMs } },
632
+ async run(spec) { seenWorkdirs.push(spec.workdir); return { exitCode: 0, timedOut: false, aborted: false, stdout: { text: 'ok' }, stderr: { text: '' } } },
633
+ }
634
+ const ctx = makeCtx(fs, undefined)
635
+ ctx.get = (service) => (service === 'shell' ? shell : service === 'approval' ? undefined : undefined)
636
+ registerDevTask(ctx)
637
+ const exe = ctx._tools[0].execute
638
+ await exe({ operation: 'create', task_id: 'VR-1', title: 'x', branch: 'main' }, EXEC)
639
+ await exe({ operation: 'verify', task_id: 'VR-1' }, EXEC)
640
+ const record = JSON.parse(fs._files.get('.dsh/task-VR-1.json'))
641
+ assert(seenWorkdirs.length === 1, 'bare verify ran the default command')
642
+ assert(seenWorkdirs[0] === (record.root ?? ''), 'verify ran in the recorded project root, not the session cwd')
643
+ assert(record.verification.receipt.root === record.root, 'receipt binds the recorded project root')
644
+ }
645
+
646
+ // ── 34. 0.23: revision increments on every write (CAS foundation) ───────────
647
+ {
648
+ const fs = makeFs({ '.dsh/eng.json': JSON.stringify({ flow: 'standard' }) })
649
+ const exe = await registered(fs)
650
+ await exe({ operation: 'create', task_id: 'CAS-1', title: 'x', branch: 'main' }, EXEC)
651
+ const r1 = JSON.parse(fs._files.get('.dsh/task-CAS-1.json')).revision
652
+ await exe({ operation: 'scope', task_id: 'CAS-1', files: ['a'] }, EXEC)
653
+ const r2 = JSON.parse(fs._files.get('.dsh/task-CAS-1.json')).revision
654
+ assert(r1 === 1 && r2 === 2, 'revision increments on every write')
655
+ const devtask = readFileSync('./lib/dev-task.js', 'utf8')
656
+ assert(devtask.includes('changed concurrently'), 'concurrent-write CAS guard is present')
657
+ }
658
+
659
+ // ── 35. 0.23: Remote path guard + workspace registry ────────────────────────
660
+ {
661
+ const ctl = await import('./lib/controller.js')
662
+ assert(ctl.checkedPath('D:/proj/one') === 'D:/proj/one', 'absolute workspace passes')
663
+ assert(ctl.checkedPath('D:/proj/one/') === 'D:/proj/one', 'trailing slash is normalized')
664
+ await assertThrows(() => ctl.checkedPath('relative/path'), 'must be absolute', 'relative path is rejected')
665
+ await assertThrows(() => ctl.checkedPath('C:/Windows/system32'), 'not an allowed workspace', 'system root is rejected')
666
+ await assertThrows(() => ctl.checkedPath('c:/windows'), 'not an allowed workspace', 'case variant of a system root is rejected')
667
+ ctl.registerWorkspace('D:/proj/one')
668
+ assert(ctl.checkedPath('D:/PROJ/one') === 'D:/PROJ/one', 'registered workspace passes under any case')
669
+ await assertThrows(() => ctl.checkedPath('D:/other-project'), 'not registered', 'unregistered workspace is rejected once the registry is active')
670
+ }
671
+
618
672
  console.log(`\nP0 acceptance: ${passed} checks passed`)
package/README.md CHANGED
@@ -137,13 +137,19 @@ dsh plugin --profile <name> add .
137
137
 
138
138
  > 钩子读 `.dsh/eng.json` + `.dsh/task-*.json`,跨仓库零依赖、自包含。
139
139
 
140
+ ### 门禁边界:hook / host / CI 各自负责什么(0.22.3 起明确)
141
+
142
+ - **git hook = 本地即时反馈**:拦常规 `git commit`(校验任务存在、阶段、范围、消息、快照 hash、敏感路径)。它挡不住 `git commit --no-verify`、`core.hooksPath` 替换或手改 task 记录——这是定位,不是漏洞。
143
+ - **host 工具流 = 正常路径的强约束**:`dev_task` 的阶段/范围/回执/审批门禁在工具调用时生效;Remote 路径校验 + workspace registry(host 集成 `registerWorkspace` 后生效)挡住越界访问;文件沙箱是最终文件边界。
144
+ - **CI = 最终可信门禁**:CI 里重新验证 task id、快照 hash、staged 范围、提交消息、真实验证退出码、高风险回执、hook 是否被绕过——这是唯一不依赖"模型配合"的可信验证点。
145
+
140
146
  ## 项目初始化(init)
141
147
 
142
148
  二开 / 遗留项目常常没有文档,AI 接手前需要先「认识」这个项目。`dev_task`(operation=init)走 **inspect → propose → apply** 三阶段管理项目根的 `AGENTS.md`,绝不直接覆盖已有治理文件:
143
149
 
144
150
  1. `inspect`——读取现有 `AGENTS.md`(若有)返回给模型;没有则提示扫描项目。
145
- 2. `propose`——模型扫描(目录结构、技术栈、构建/运行命令、约定、红线)后给出全文草稿,此步**只预览不落盘**。
146
- 3. `apply`——落盘。首次创建直接写入;覆盖已有 `AGENTS.md` 必须 `overwrite: true` 且**经人工批准**,否则拒绝。
151
+ 2. `propose`——模型扫描(目录结构、技术栈、构建/运行命令、约定、红线)后给出全文草稿,此步**只预览不落盘**,并返回草稿内容 hash(已有文件时另附现有文件 hash)。
152
+ 3. `apply`——落盘。**必须传回 propose 返回的 `expected_hash`**(防预览与落盘之间内容被换);覆盖已有 `AGENTS.md` 必须 `overwrite: true` 且**经人工批准**,传回 `existing_hash` 可防审批期间文件被他人改动(TOCTOU),否则拒绝。
147
153
 
148
154
  - **为什么是 `AGENTS.md`**:DSH 平台会把项目根的 `AGENTS.md` **自动注入到每个会话**——生成一次,之后每个任务开工 AI 都自带这份项目认知,引擎无需额外的注入逻辑。
149
155
  - **行数上限**:`AGENTS.md` 每次都进上下文,200 行(约 4–6K token)是硬约束,逼着只写「项目是什么 → 怎么跑 → 结构 → 约定 → 坑」,而不是塞长篇文档。
@@ -179,4 +185,5 @@ dsh plugin --profile <name> add .
179
185
  - **通用项目适配(0.21.0)**:① 新增项目适配层 `src/project.ts`——按特征文件识别 Node / Java / Python / Go / Rust 类型,从 workspace 向上发现项目根(`.git` / `.dsh` / 语言特征文件);② 各语言默认验证命令(`npm test` / `mvn -q test` / `python -m pytest` / `go test ./...` / `cargo test`),`verify` 不带 `command` 时按 `.dsh/eng.json` 的 `verify_command` → 语言默认链自动跑真实命令;③ 治理文件识别扩展至 `AGENTS.md`、`CLAUDE.md`、`.cursorrules`,`init inspect` 一并报告治理文件、项目根、语言栈与项目级 skill/rule 目录(约定 `.dsh/rules/*.md`、`.dsh/skills/<name>/SKILL.md`);④ 提交钩子加风险策略——触及敏感路径(`.git` / `.env` / credentials / secrets;`.dsh` 的任务记录与流程配置由快照 hash 与豁免保护)要求任务为 `high_risk` 且验证有真实命令回执,否则拒绝;⑤ 初始化写入越界保护——`init` / `create` 的写入目标必须落在项目根内;任务记录新增 `root` / `project_type` 字段。
180
186
  - **审计和发布质量(0.22.0)**:① 流程快照 hash——任务创建时固化 `config` 的 SHA-256,工具与提交钩子读任务记录时校验,被手改的快照一律拒绝继续;② 提交钩子完整性检测——新增 `dev_task verify_hook`,比对 `.git/hooks/commit-msg` 与内置门禁的 hash,被替换/篡改立即报错;③ 风险降级审批——新增 `set_risk`,`high_risk → standard` 必须人工批准并落 `risk_downgrades` 审计记录;④ 验证回执绑定项目根——receipt 记录 `root`,与任务 workspace 绑定;⑤ 内置规则指纹锁定——创建时固化内置规则内容指纹,包升级后 `status` 报 `bindings_drift` 而非静默换规则;⑥ 多项目 / 多语言 / 多任务测试矩阵补强。
181
187
  - **沙箱写入修复(0.22.1)**:`dev_task` 的文件写入此前没有携带按调用传递的沙箱策略(`writeText` 的 `sandboxPolicy` 参数),在 DSH 文件沙箱下会把 workspace 内的任务记录写入误判为越界而拒绝(`file access denied under workspace-write mode`,且会话策略变化无法影响它);现在每次写入显式携带 `{ mode, workspaceRoot: 会话目录 }`,并新增 `sandbox_permissions` 参数(`workspace-write` / `danger-full-access`)作为被拒后的一次性升级路径。
182
- - **质量修补(0.22.2,采纳 codex 评审五项)**:① client typecheck 修复——`project.ts` 不再依赖 `node:path`,浏览器面可完整类型检查;② sandbox 升级补审批——`sandbox_permissions` 必须与 `justification` 成对出现,`danger-full-access` 需人工一次批准,无审批服务即拒绝;③ Remote 任意路径设防——工作台 Remote 拒绝非绝对路径与系统级根目录;④ `set_risk` 升到 `high_risk` 时受流程能力门约束(`minimal`/`agile` 拒绝,不再绕过 `create` 的检查);⑤ `loadTask` 补齐旧记录缺失字段(items / verification / review / commits),损坏记录 fail-closed 而非引擎裸崩。
188
+ - **质量修补(0.22.2,采纳 codex 评审五项)**:① client typecheck 修复——`project.ts` 不再依赖 `node:path`,浏览器面可完整类型检查;② sandbox 升级补审批——`sandbox_permissions` 必须与 `justification` 成对出现,`danger-full-access` 需人工一次批准,无审批服务即拒绝;③ Remote 任意路径设防——工作台 Remote 拒绝非绝对路径与系统级根目录;④ `set_risk` 升到 `high_risk` 时受流程能力门约束(`minimal`/`agile` 拒绝,不再绕过 `create` 的检查);⑤ `loadTask` 补齐旧记录缺失字段(items / verification / review / commits),损坏记录 fail-closed 而非引擎裸崩。
189
+ - **可信边界加固(0.22.3,采纳 GPT 评审)**:① 验证命令统一在任务记录的项目根运行(monorepo 子目录不再跑错目录),receipt.root 与任务根强校验;② 旧任务(无 frozen 快照)禁止升 `high_risk`——迁移或重建后才可;③ `init apply` 强制 `expected_hash`,新增 `existing_hash` 防审批期间文件被换(TOCTOU);④ 任务记录新增 `revision`,每次写入 compare-and-swap,并发覆盖直接报错(`changed concurrently`);⑤ 工作台 Remote 增加 host workspace registry(`registerWorkspace` 供 harness 集成,注册后未授权路径一律拒绝);⑥ sandbox 升级审批展示 workspace;⑦ 文档明确 hook(本地反馈)/ host(工具流约束)/ CI(最终可信门禁)三层职责边界。