You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit a7958e3
Browse filesBrowse the repository at this point in the historyBrowse files
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.
Copy file name to clipboardExpand all lines: README.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -544,7 +544,7 @@ Everything else is plumbing. The parts you are most likely to want to replace:
544
544
545
545
Read [`CONTRIBUTING.md`](CONTRIBUTING.md) first — it says what we merge and what we close without discussion.
546
546
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.
Copy file name to clipboardExpand all lines: SECURITY.md
+10-4Lines changed: 10 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -111,17 +111,18 @@ cannot drop them:
111
111
112
112
| Limit | Value | When it applies |
113
113
|---|---|---|
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 |
115
115
| Timeout | declared per tool, **read-only tools only** (5 s) | the call is abandoned, not cancelled |
116
116
| Output ceiling | 8000 chars, every tool | truncated **and labelled** with how much was dropped |
117
117
118
118
Two honest caveats:
119
119
120
120
-**A timeout is giving up on observing, not on executing.** JavaScript cannot
121
121
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.
125
126
-**The output ceiling bounds what comes back, not what a tool did.** It stops a
126
127
provider from flooding the context; it does not bound disk or memory.
127
128
@@ -136,6 +137,7 @@ can touch is bounded by the working directory:
136
137
|`list_dir`| 0 | lists the working directory |
137
138
|`read_file`| 0 | reads a file |
138
139
|`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 |
139
141
|`delete_file`| 3 | deletes a file or an **empty** directory; never recurses |
140
142
|`done`| 0 | no-op |
141
143
@@ -144,6 +146,10 @@ Two gates matter more than the table:
144
146
-**Mutation is opt-in.**`write_file` only enters the candidate set when the
145
147
caller supplies an input source; `delete_file` needs `allowDelete`**in
146
148
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.
147
153
-**Risk 2 and above requires authorisation.**`DECISION.md`'s
148
154
`score:risk >= 2 → ask_human` is a hard rule, not a threshold, and a decision
149
155
model's own confidence cannot bypass it. Without an `onAskHuman` handler the
Copy file name to clipboardExpand all lines: TODO.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -20,7 +20,7 @@ That leaves `grade_risk` close to untestable. **A risk ladder only means somethi
20
20
21
21
-[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.)*
22
22
-[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.)*
24
24
-[ ]**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.
25
25
-[ ] 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.
0 commit comments