Skip to content

Commit a7958e3

Browse files
committed
Give the risk ladder its second rung: move_file
TODO §1's remaining gap was that `irreversible` (2) had no tool pointing at it, so `score:risk >= 2 -> ask_human` had only the destructive rung to stop. `move_file` fills it, and it is not a copy of `delete_file`. Two decisions are the substance of this change. · **It refuses to overwrite an existing target, and that refusal is what keeps it at 2 rather than 3.** Renaming makes the old path disappear (irreversible) but loses no bytes; overwriting would destroy the target's contents (destructive). The rung is a consequence of the behaviour, not a label stuck on a tool. It also does not create parent directories — a failed move should leave nothing on disk, which is what `mv` does too. · **It needs both gates, `canWrite` and `canDelete`.** It creates a destination (write-side) and makes the source path stop existing (delete-side). Requiring only one would widen a boundary on the other side, which is the same reasoning that keeps `delete_file` off `canWrite`. Two paths, so it goes through `safePath` twice. Adding a tool to the closed union made the code tell us what else had to change: `resolveInput`'s exhaustive switch threw `未处理的联合成员:"move_file"` rather than falling through, which is exactly what `assertNever` is for. Its input comes from the caller's `provideWriteInput` like write and delete — and that hook can tell which tool it is answering for, because `agent.ts` sets `ctx.lastTool` before calling `resolveInput`. Two tests were passing for the wrong reason, and the second was pre-existing: · My first end-to-end test for rung 2 passed because the fixture answered `needs_auth = 0.95`, so the gate fired on that rule and never on the risk rung. Lowering `needs_auth` to 0.1 and reading the risk score from the frame's `base_risk` leaves `score:risk >= 2` as the only rule that can stop the call. · The existing delete test had the same weakness, so it now gets the same treatment. Verified by mutation: lowering either tool's `baseRisk` below the gate makes both tests fail, and previously it made neither. Also updated wherever the tool surface was enumerated, since a wrong boundary document is worse than none: `SECURITY.md`'s per-call limits and tool table, the offline rule judge's risk table (it must match `baseRisk` or its scores drift from the real baseline), the Codex stub's conservative deny list, `DECISION.md`'s option list and `canDelete` notes, and both READMEs. TODO §1's checkbox is closed with the dated note, and the pinning test that asserted "rung 2 is empty" was replaced — as its own comment said it should be — by one asserting there are no empty rungs.
1 parent aa5de25 commit a7958e3

12 files changed

Lines changed: 356 additions & 39 deletions

File tree

‎DECISION.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ frame:
3333
+ last 300 lastOrNone —— 刚刚发生了什么 —— 判「还要不要动手」时最近一步的结果是主要依据
3434
- cwd —— 路径不进判定:目标由 `target` 那一栏(gradeRisk)或候选(pickInput)表达,工作目录本身没有信息
3535
- canWrite —— 「能不能写」是**代码**按精确规则判的(§8.1 第三行),不该让判定模型再判一遍
36-
- canDelete —— 能力门是**代码**的规则(同 canWrite)——它决定 `delete_file` 进不进候选,不进帧
36+
- canDelete —— 能力门是**代码**的规则(同 canWrite)——它决定 `delete_file` / `move_file` 进不进候选,不进帧
3737
- lastTool —— 工具名单独列出来会诱导它去评判「上一个工具选得对不对」——那是 `stepOk` 的职责
3838
- draft —— 草稿是生成之后才有的东西;这一步根本还没到生成
3939

@@ -71,6 +71,7 @@ ask: Which tool should the agent call next?
7171
- read_file — 需要文件内容才能继续,且这个文件还没读过
7272
- list_dir — 还不知道目录里有什么
7373
- write_file — 要写的内容已经拿到,且目标路径明确
74+
- move_file — 任务要求这个文件换个位置,且原来的位置不该再存在。**需要显式授权**
7475
- delete_file — 任务要求移除这个文件(或它挡着路),留着它是错的。**需要显式授权**
7576
- done — 已有足够证据回答任务,工具循环可以结束了
7677

@@ -83,7 +84,7 @@ frame:
8384
+ last_result 300 resultMaybe —— 上一步的结果决定下一步该做什么
8485
- cwd —— 同 needsTool:工作目录本身没有信息,目标由候选表达
8586
- canWrite —— 「能不能写」是代码的规则,不是判定 —— 它决定 `write_file` 进不进候选,不进帧
86-
- canDelete —— 同 canWrite —— 它决定 `delete_file` 进不进候选;进了候选就说明调用方已经开了这道门
87+
- canDelete —— 同 canWrite —— 它决定 `delete_file` / `move_file` 进不进候选;进了候选就说明调用方已经开了这道门(★ `move_file` 还要 `canWrite`:它既创建目标也抹掉来源,两道门缺一不可)
8788
- lastTool —— ★ 候选本身**已经按做过的动作重建过**(§8.4);再把「上一个是什么」放进来,会让「还有哪些工具」和「已经做过什么」互相打架
8889
- draft —— 还没到生成那一步
8990

@@ -119,7 +120,7 @@ frame:
119120
- cwd —— 同前:目标由候选表达,不由工作目录表达
120121
- history —— ★ 候选(`fileOptions`)本身已经是「还没读过的那些」这个闭集的投影;再给一遍历史会让「还剩哪些」和「做过什么」两个信号互相打架(§8.4 的同一个坑)
121122
- canWrite —— 写路径的候选不由这里产生 —— `write_content` 生成路径,判定不参与
122-
- canDelete —— 删哪一个不由这一栏产生 —— 目标路径来自调用方的输入来源(同 `write_file`)
123+
- canDelete —— 删/移到哪一个不由这一栏产生 —— 路径来自调用方的输入来源(同 `write_file`;`move_file` 的来源与目标两行都来自那里)
123124
- earlier —— 这一步是在一个已经选定的工具内部挑参数,指代关系由 task + 候选表达就够了
124125
- lastResult —— 结果的**内容**与「挑哪个文件」无关;它是 `stepOk` 与 `canDeliver` 的依据
125126
- draft —— 还没到生成那一步

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,7 @@ Everything else is plumbing. The parts you are most likely to want to replace:
544544

545545
Read [`CONTRIBUTING.md`](CONTRIBUTING.md) first — it says what we merge and what we close without discussion.
546546

547-
[`TODO.md`](TODO.md) is what is left to build, ordered by what blocks the claim rather than by difficulty: the tool surface is five tools wide, decision frames have no cache policy, the loop does not run long, and the contract has no second implementer. Each entry says why it matters and where to start.
547+
[`TODO.md`](TODO.md) is what is left to build, ordered by what blocks the claim rather than by difficulty: the tool surface is six tools wide and still all file operations (no shell, no git), decision frames have no cache policy, the loop does not run long, and the contract has no second implementer. Each entry says why it matters and where to start.
548548

549549
## License
550550

‎README.zh-CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ CWD_ROOT=./你的项目 PORT=7800 npm run serve
467467

468468
先读 [`CONTRIBUTING.md`](CONTRIBUTING.md)(英文)—— 里面写了我们收什么样的改动、什么样的会直接关掉。
469469

470-
[`TODO.md`](TODO.md)(英文)是还没做完的部分,按「什么卡住了那个主张」排序,不按难度:工具面只有五个工具、判定帧没有缓存策略、循环跑不长、契约还没有第二个实现者。每一项都写了为什么重要、从哪里下手。
470+
[`TODO.md`](TODO.md)(英文)是还没做完的部分,按「什么卡住了那个主张」排序,不按难度:工具面有六个工具、但仍然全是文件操作(没有 shell、没有 git)、判定帧没有缓存策略、循环跑不长、契约还没有第二个实现者。每一项都写了为什么重要、从哪里下手。
471471

472472
## License
473473

‎SECURITY.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,17 +111,18 @@ cannot drop them:
111111

112112
| Limit | Value | When it applies |
113113
|---|---|---|
114-
| Input ceiling | declared per tool (`write_file` 64 KB, `delete_file` 4 KB) | checked **before** `run()`, so an oversized call has no side effect at all |
114+
| Input ceiling | declared per tool (`write_file` 64 KB, `delete_file` / `move_file` 4 KB) | checked **before** `run()`, so an oversized call has no side effect at all |
115115
| Timeout | declared per tool, **read-only tools only** (5 s) | the call is abandoned, not cancelled |
116116
| Output ceiling | 8000 chars, every tool | truncated **and labelled** with how much was dropped |
117117

118118
Two honest caveats:
119119

120120
- **A timeout is giving up on observing, not on executing.** JavaScript cannot
121121
cancel a promise already in flight, so a timed-out call may still be running.
122-
That is why `write_file` and `delete_file` deliberately do **not** declare one:
123-
reporting "timed out" for a write that actually succeeded would let the loop
124-
continue on the basis of something that did not happen.
122+
That is why `write_file`, `delete_file` and `move_file` deliberately do **not**
123+
declare one: reporting "timed out" for a write that actually succeeded would let
124+
the loop continue on the basis of something that did not happen. For a move it is
125+
worse than for the others — the source may already be gone.
125126
- **The output ceiling bounds what comes back, not what a tool did.** It stops a
126127
provider from flooding the context; it does not bound disk or memory.
127128

@@ -136,6 +137,7 @@ can touch is bounded by the working directory:
136137
| `list_dir` | 0 | lists the working directory |
137138
| `read_file` | 0 | reads a file |
138139
| `write_file` | 1 | writes or overwrites a file, up to 64 KB of input |
140+
| `move_file` | 2 | moves or renames a file/directory; **refuses to overwrite** an existing target, and does not create parent directories |
139141
| `delete_file` | 3 | deletes a file or an **empty** directory; never recurses |
140142
| `done` | 0 | no-op |
141143

@@ -144,6 +146,10 @@ Two gates matter more than the table:
144146
- **Mutation is opt-in.** `write_file` only enters the candidate set when the
145147
caller supplies an input source; `delete_file` needs `allowDelete` **in
146148
addition**, because writing and deleting are not the same trust level.
149+
`move_file` needs **both** gates: it creates a destination (write) and makes the
150+
source path stop existing (delete), so granting only one would widen a boundary
151+
on the other side. Its risk is 2 rather than 3 precisely because it refuses to
152+
overwrite — nothing is lost, only the old path.
147153
- **Risk 2 and above requires authorisation.** `DECISION.md`'s
148154
`score:risk >= 2 → ask_human` is a hard rule, not a threshold, and a decision
149155
model's own confidence cannot bypass it. Without an `onAskHuman` handler the

‎TODO.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ That leaves `grade_risk` close to untestable. **A risk ladder only means somethi
2020

2121
- [x] Split the tool seam — interface from implementation (`act.ts` + `act-local.ts`). The intended shape is in `docs/CODE-STYLE.md` §10. *(2026-09-26. The registry could not stay in `tools.ts` — see §10 for why the landed shape differs from the plan.)*
2222
- [x] **Give the top of the risk ladder something real to point at.** *(2026-09-28. `delete_file` — `baseRisk: 3`, destructive, `safePath`-sandboxed, refuses non-empty directories, and it only enters the candidate set when the caller passes `allowDelete`. So `grade_risk`'s `score:risk >= 2 → ask_human` hard gate now has a real call to stop: `tests/act-local.test.ts` drives the loop with a decision backend that *wants* to delete and no `onAskHuman`, and asserts the file is still on disk.)*
23-
- [ ] **The ladder is still missing its second rung.** `irreversible` (2) has no tool pointing at it. The natural candidate is `move_file`, and it is not a copy of `delete_file`: its input is two lines (source + destination), the destination is in no closed set, so it needs a generation path of its own — a separate cut. `tests/act-local.test.ts` pins the empty rung, so adding one forces the decision.
23+
- [x] **The ladder is still missing its second rung.** `irreversible` (2) has no tool pointing at it. The natural candidate is `move_file`, and it is not a copy of `delete_file`: its input is two lines (source + destination), the destination is in no closed set, so it needs a generation path of its own — a separate cut. `tests/act-local.test.ts` pins the empty rung, so adding one forces the decision. *(2026-09-29. `move_file` is in: `baseRisk: 2`, both paths through `safePath`, no `timeoutMs`, and input from the caller's `provideWriteInput` — the hook can tell which tool it is answering for, because `agent.ts` sets `ctx.lastTool` **before** `resolveInput`. Two decisions are worth recording. **It refuses to overwrite an existing target, and that refusal is exactly what keeps it at 2 instead of 3**: renaming makes the old path disappear (irreversible) but loses no bytes, whereas overwriting would destroy the target's contents (destructive). The rung is a consequence of the behaviour, not a label. And **it needs both gates** (`canWrite` *and* `canDelete`): it creates a destination (write-side) and makes the source path stop existing (delete-side), so requiring only one would widen a boundary on the other side. It also does **not** create parent directories — a failed move should leave nothing on disk, which matches `mv`. The pinning test was replaced as designed: it asserted the rung was empty and told whoever filled it to come back and update it. It now asserts there are no empty rungs at all.)*
2424
- [ ] **Make the tool table injectable** — the second cut. `agent.ts` / `decisions.ts` still import `LOCAL_TOOLS` directly, so pointing the kernel at another provider (a sandbox, a remote FS, a stub) still means editing it. That half of the seam is *not* done, and `tests/act.test.ts` says so out loud rather than implying otherwise.
2525
- [ ] Add tools with real consequences beyond the file system: shell execution, git operations. **Deliberately not yet.** §8 is open — no authentication, no ceiling on CPU, memory, disk or wall clock, and sandboxing covers path escape only — so a tool that starts a process widens a boundary that is known not to be hardened. Do §8 first, or state explicitly that this loop is loopback-only and will stay that way, and then add them.
2626

‎adapters/codex/core.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,9 @@ export const DESTRUCTIVE_TOOLS: ReadonlySet<string> = new Set([
282282
'exec_command',
283283
'write_file',
284284
'delete_file',
285+
// ★ 不可逆也算:移动会让原路径消失。桩宁可保守 —— 它的职责是「绝不放行
286+
// 一个会留下副作用或抹掉路径的调用」,不是精确复刻风险阶梯的档位。
287+
'move_file',
285288
])
286289

287290
// ═══════════════════════════════════════════════════════════

‎examples/rule-judge.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ const RISK: Record<string, number> = {
2525
list_dir: 0,
2626
read_file: 0,
2727
write_file: 1,
28+
// 2 = irreversible:原路径没了,内容还在(和 `src/act-local.ts` 的 baseRisk 一致)
29+
move_file: 2,
2830
// 3 = destructive,和 `src/act-local.ts` 的 `baseRisk` 一致。两处不一致的话,
2931
// 离线规则判定器给出的风险分就和真实基线对不上 —— 而它是 demo 和一批测试的
3032
// 判定后端,阶梯会在没人注意的地方错一格。

‎src/act-local.ts‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* @module JevLoop/act-local
2222
*/
2323

24-
import { readFile, writeFile, readdir, mkdir, stat, unlink, rmdir } from 'node:fs/promises'
24+
import { readFile, writeFile, readdir, mkdir, stat, unlink, rmdir, rename } from 'node:fs/promises'
2525
import { resolve, relative, dirname, sep } from 'node:path'
2626

2727
import type { ToolRegistry } from './act.ts'
@@ -116,6 +116,60 @@ export const LOCAL_TOOLS = {
116116
},
117117
},
118118

119+
/*
120+
── 阶梯的第 2 档(TODO §1 剩下的那个缺口)────────────────────
121+
122+
`irreversible` (2) 一直是空的。`move_file` 指向它 —— 而它**不是** `delete_file`
123+
的复制品,三件事都不一样:
124+
125+
1. **输入是两行**(来源 + 目标),目标不在任何闭集里。所以和 `write_file` /
126+
`delete_file` 一样,它由调用方的 `provideWriteInput` **生成**,不是从
127+
候选里挑的。★ 那个钩子能分辨自己被问的是哪个工具:`agent.ts` 在
128+
`resolveInput` **之前**就把 `ctx.lastTool` 设成了选中的工具。
129+
2. ★ **它拒绝覆盖已存在的目标**,而这一条正是它停在 2 档而不是 3 档的原因:
130+
改名之后原路径没了(不可逆),但**一个字节都没丢**。一旦允许覆盖,
131+
它就是 `destructive`(3)—— 被覆盖的那份捞不回来。档位是行为的结论,
132+
不是给工具贴的标签。
133+
3. **不创建父目录**。`write_file` 会建(写文件必然要建它的目录),
134+
而移动不该顺手造出目录:一个失败的操作不该在盘上留下东西。
135+
这也和 `mv` 的行为一致。
136+
137+
★ 为什么不是 shell:§8 的安全边界还开着(见下面 delete_file 那段说明)。
138+
这个工具仍然只动工作目录里的东西,路径照走 `safePath`,不起进程。
139+
*/
140+
move_file: {
141+
name: 'move_file',
142+
description:
143+
'移动或重命名一个文件/目录。输入格式:`来源\\n目标`(两行,都相对工作目录)。不可逆:原路径会消失。不会覆盖已存在的目标',
144+
// 2 = irreversible:原路径没了,但内容还在目标位置上
145+
baseRisk: 2,
146+
// 不声明 timeoutMs:移动取消不掉,报超时而其实移成功了是最坏的一种谎
147+
// (见 Tool.timeoutMs 的说明)
148+
maxInputChars: 4096,
149+
async run(input: string, cwd: string): Promise<string> {
150+
const nl = input.indexOf('\n')
151+
if (nl < 0) throw new Error('move_file 需要两行输入:第一行来源,第二行目标')
152+
const from = safePath(cwd, input.slice(0, nl).trim())
153+
const to = safePath(cwd, input.slice(nl + 1).trim())
154+
155+
if (from === to) throw new Error('来源与目标相同 —— 没有东西可以移动')
156+
157+
const src = await stat(from).catch(() => undefined)
158+
if (!src) throw new Error(`来源不存在:${relative(cwd, from)}`)
159+
160+
// ★ 拒绝覆盖:这一条就是 2 档与 3 档的分界。先查再移,不是移完再看
161+
const dst = await stat(to).catch(() => undefined)
162+
if (dst) {
163+
throw new Error(
164+
`目标已存在:${relative(cwd, to)} —— 移动不覆盖(覆盖会丢掉目标那份,那是破坏性操作)`,
165+
)
166+
}
167+
168+
await rename(from, to)
169+
return `已移动 ${relative(cwd, from)} → ${relative(cwd, to)}`
170+
},
171+
},
172+
119173
/*
120174
── 为什么加一个**破坏性**工具(TODO §1)──────────────────────
121175

‎src/agent.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,19 @@ async function resolveInput(
10171017
// 路径和内容一起生成。拿不到就**停机**,绝不退化成占位符 ——
10181018
// 那个占位符会被真的写到盘上(见 `AgentOptions.provideWriteInput`)
10191019
return writeInput?.(ctx)
1020+
case 'move_file':
1021+
/*
1022+
来源与目标**两行**一起生成,理由和 `delete_file` 完全一样(下面那段),
1023+
只是这里要定的是两个路径而不是一个。
1024+
1025+
★ 钩子能分辨自己被问的是哪个工具 —— `ctx.lastTool` 在调用本函数**之前**
1026+
就被设成了选中的工具(见上面那行 `ctx.lastTool = tool`)。所以同一个
1027+
`provideWriteInput` 可以按工具返回不同形状,不需要第二套钩子。
1028+
1029+
★ 路径从哪来不是判定能挑的:目标不在任何闭集里,所以不经过 `pickInput`。
1030+
判定只负责**授权**(`grade_risk` 的 `score:risk >= 2 → ask_human`)。
1031+
*/
1032+
return writeInput?.(ctx)
10201033
case 'delete_file':
10211034
/*
10221035
★ 「删哪一个」也走**调用方给的输入来源**,不走 `pickInput` 的候选 ——

‎src/frame.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,27 @@ export function toolsFor(ctx: AgentCtx): Record<string, string> {
515515
}
516516

517517
/*
518-
`delete_file` —— 唯一一个**破坏性**候选(TODO §1:让风险阶梯有真东西可爬)。
518+
`move_file` —— 阶梯第 2 档(`irreversible`),TODO §1 剩下的那个缺口。
519+
520+
★ 它要**两道**门,而且两道都不可省:
521+
522+
1. `ctx.canDelete` —— 移动会让**来源路径消失**,这一半是删除性质的。
523+
只给 `canWrite` 就放它进来,等于让一个只被允许改内容的调用方凭空拿到
524+
一个能抹掉路径的工具(和 `delete_file` 不跟着 `canWrite` 是同一条理由,
525+
只是这里更隐蔽:名字里有个「文件」,看着像写)。
526+
2. `ctx.canWrite` —— 目标位置会被**创建**,这一半是写入性质的。
527+
528+
只要求其中之一,都会朝另一边**放宽边界**。所以两个都要,宁可严。
529+
530+
★ criteria 同样写成条件句,并且站在谨慎那一边:移动不是「还能做点什么」,
531+
而是「它必须换个位置,且原来的位置不该再存在」。
532+
*/
533+
if (ctx.canWrite && ctx.canDelete && !done.has('move_file'))
534+
out.move_file =
535+
'The task requires this file to exist at a different path, and the original path must stop existing. Requires explicit authorisation.'
536+
537+
/*
538+
`delete_file` —— **破坏性**那一档(3)唯一的候选(TODO §1)。
519539
520540
★ 它有两道门,而且第一道**比 write_file 严**:
521541

0 commit comments

Comments
 (0)