Repository navigation
feat(tools): route the remaining write tools through the guard (U7, #1375) #1918
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
426f809
4528075
9207cee
5d02948
1e7c390
86ae014
cbe99cc
7559543
0fcf305
7e0533e
2f42d5c
47dc492
b5a213a
cf751fb
38d99ed
6a46b6b
9adf66f
8647b7d
3ba741a
7ad01ae
14ec1fb
b27f4c8
46c1cfb
ba10fd4
1b2f187
f4f917f
fd1c719
d6446b5
d2d9e9d
160ddb5
0f8c6c0
483ed65
8fc2c5b
183de84
e06bed1
3187e58
3183810
c8caff0
c434b0b
9e59f01
cd5bfdc
6b68a57
510effe
376a7b9
d63ba73
800916e
13fa14b
0b0c15b
da2e0ef
6d8f077
bc43841
41c0295
a00c7e2
14f8634
cf07183
f756ee3
6d2bf3e
f5fddf6
c198c2e
e349528
2f237b8
f7aae8b
8466a37
44cf49b
9edc71a
ae4b018
2264dff
d16fbcf
cbb6a16
6e8d494
1046c83
504af84
40ac71f
64dc74c
002b34f
52628eb
7f90fc2
1956703
a020885
6222e24
aef08ed
ceef734
f06c6d9
e5b4795
273dd7b
2b38c33
a39df1d
06dbe77
7e00c5d
2304635
30e2ef8
278f428
0f60393
c9bdb01
6d85182
84b8b3a
8b394b3
599a10b
714988a
dfa3032
9334a30
26afbd9
d921308
43d859d
3dc5140
d75ed9b
a15991d
2efa9bd
a6a1156
bee03bb
0a76a7a
7ae59a8
304db07
c72de0c
f653f86
42077a2
3159d0a
15d9f8e
dc4c848
1d993fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -106,6 +106,7 @@ import { buildNativeToolsArrayWithRestrictions } from "./build-tools" | |
| import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" | ||
| import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" | ||
| import { FileContextTracker } from "../context-tracking/FileContextTracker" | ||
| import { ObservationRegistry } from "./observationRegistry" | ||
| import { RooIgnoreController } from "../ignore/RooIgnoreController" | ||
| import { RooProtectedController } from "../protect/RooProtectedController" | ||
| import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" | ||
|
|
@@ -296,6 +297,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike { | |
| readonly instanceId: string | ||
| readonly metadata: TaskMetadata | ||
|
|
||
| // The observed on-disk version of each file this task has read. Declared here so the | ||
| // read tools can record it; a write guard later compares a token against this registry. | ||
| readonly observationRegistry = new ObservationRegistry() | ||
|
Comment on lines
+300
to
+302
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| todoList?: TodoItem[] | ||
|
|
||
| readonly rootTask: Task | undefined = undefined | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { describe, it, expect, vi } from "vitest" | ||
|
|
||
| import { ObservationRegistry } from "../observationRegistry" | ||
|
|
||
| describe("ObservationRegistry", () => { | ||
| it("observe → get returns the recorded version and observedAt", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "1:2:300:4000000000:5000000000") | ||
|
|
||
| const obs = reg.get("/a/b/c.ts") | ||
| expect(obs).toBeDefined() | ||
| expect(obs!.version).toBe("1:2:300:4000000000:5000000000") | ||
| expect(typeof obs!.observedAt).toBe("number") | ||
| }) | ||
|
|
||
| it("re-observe replaces the entry with a fresh observedAt", () => { | ||
| vi.useFakeTimers() | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
| const first = reg.get("/a/b/c.ts")! | ||
| expect(first.version).toBe("v1") | ||
|
|
||
| vi.advanceTimersByTime(50) | ||
| reg.observe("/a/b/c.ts", "v2") | ||
| const second = reg.get("/a/b/c.ts")! | ||
| expect(second.version).toBe("v2") | ||
| expect(second.observedAt).toBeGreaterThan(first.observedAt) | ||
|
|
||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| it("has returns true for observed paths, false otherwise", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/x.ts", "t1") | ||
| expect(reg.has("/x.ts")).toBe(true) | ||
| expect(reg.has("/y.ts")).toBe(false) | ||
| }) | ||
|
|
||
| it("size reflects the number of observed entries", () => { | ||
| const reg = new ObservationRegistry() | ||
| expect(reg.size).toBe(0) | ||
| reg.observe("/a.ts", "t1") | ||
| reg.observe("/b.ts", "t2") | ||
| expect(reg.size).toBe(2) | ||
| }) | ||
|
|
||
| it("clear removes all entries and resets size to 0", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a.ts", "t1") | ||
| reg.observe("/b.ts", "t2") | ||
| reg.clear() | ||
| expect(reg.size).toBe(0) | ||
| expect(reg.get("/a.ts")).toBeUndefined() | ||
| expect(reg.has("/b.ts")).toBe(false) | ||
| }) | ||
|
|
||
| it("get on empty registry returns undefined", () => { | ||
| const reg = new ObservationRegistry() | ||
| expect(reg.get("/any.ts")).toBeUndefined() | ||
| }) | ||
|
|
||
| it("separate instances are independent — observing in one does not appear in the other", () => { | ||
| const regA = new ObservationRegistry() | ||
| const regB = new ObservationRegistry() | ||
| regA.observe("/shared.ts", "v1") | ||
| expect(regA.get("/shared.ts")).toBeDefined() | ||
| expect(regB.get("/shared.ts")).toBeUndefined() | ||
| regB.observe("/shared.ts", "v2") | ||
| expect(regA.get("/shared.ts")!.version).toBe("v1") | ||
| expect(regB.get("/shared.ts")!.version).toBe("v2") | ||
| }) | ||
|
|
||
| describe("completeness scope (S4b follow-up #46)", () => { | ||
| it("defaults to a complete observation when the read scope is not given", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
|
|
||
| expect(reg.get("/a/b/c.ts")!.complete).toBe(true) | ||
| }) | ||
|
|
||
| it("records a partial observation when the read only returned a view of the file", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1", false) | ||
|
|
||
| expect(reg.get("/a/b/c.ts")!.complete).toBe(false) | ||
| }) | ||
|
|
||
| it("re-observing replaces the entry's completeness with the new read's scope", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1", false) | ||
| reg.observe("/a/b/c.ts", "v2") | ||
|
|
||
| const obs = reg.get("/a/b/c.ts")! | ||
| expect(obs.version).toBe("v2") | ||
| expect(obs.complete).toBe(true) | ||
| }) | ||
|
|
||
| it("re-observing with a partial scope downgrades a previously complete entry", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
| reg.observe("/a/b/c.ts", "v2", false) | ||
|
|
||
| const obs = reg.get("/a/b/c.ts")! | ||
| expect(obs.version).toBe("v2") | ||
| expect(obs.complete).toBe(false) | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| /** | ||
| * Per-task file observation registry (upstream epic #1375, phase A2). | ||
| * | ||
| * Each Task owns its own instance so parent and subtask observations are | ||
| * independent. The S4 guarded-write will compare these versions against the | ||
| * token recomputed pre-write to detect stale reads or file replacement. | ||
| * | ||
| * Pure in-memory — zero I/O, no dependencies. The S4 guarded-write consults | ||
| * these observations for the version check and for the completeness check that | ||
| * gates a full-file replacement. | ||
| */ | ||
|
|
||
| export interface FileObservation { | ||
| /** Version token derived from on-disk fs.stat (bigint mode). */ | ||
| version: string | ||
| /** Millisecond timestamp when the observation was recorded. */ | ||
| observedAt: number | ||
| /** | ||
| * Whether the read that produced this observation returned the complete | ||
| * file. A slice, line-range, truncated, or indentation-block read returns | ||
| * only a view of the file; such an observation authorizes targeted edits | ||
| * on the view the model saw, but never a full-file replacement. | ||
| */ | ||
| complete: boolean | ||
| } | ||
|
|
||
| export class ObservationRegistry { | ||
| private readonly entries = new Map<string, FileObservation>() | ||
|
|
||
| /** | ||
| * Record an observation for a file at its absolute path. | ||
| * | ||
| * Re-observing replaces the entry with a fresh observedAt timestamp, the | ||
| * new version token, and the read's completeness. `complete` defaults to | ||
| * true for callers that read the whole file themselves (spec doubles, | ||
| * WriteToFileTool). A caller whose read is internal to a targeted edit must | ||
| * carry the model's prior completeness instead, so the tool's own read cannot | ||
| * upgrade a partial read into authority for a full-file replacement. | ||
| */ | ||
| observe(absolutePath: string, version: string, complete: boolean = true): void { | ||
| this.entries.set(absolutePath, { version, observedAt: Date.now(), complete }) | ||
| } | ||
|
|
||
| get(absolutePath: string): FileObservation | undefined { | ||
| return this.entries.get(absolutePath) | ||
| } | ||
|
|
||
| has(absolutePath: string): boolean { | ||
| return this.entries.has(absolutePath) | ||
| } | ||
|
|
||
| /** | ||
| * Drop the observation for one path. A caller that must revoke an | ||
| * authorization it did not earn - a preview that observed a version the model | ||
| * never read - needs this instead of clear(), which would also discard the | ||
| * observations other reads of the same task still rely on. | ||
| */ | ||
| forget(absolutePath: string): boolean { | ||
| return this.entries.delete(absolutePath) | ||
| } | ||
|
|
||
| clear(): void { | ||
| this.entries.clear() | ||
| } | ||
|
|
||
| get size(): number { | ||
| return this.entries.size | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 3966
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 47074
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 42352
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 14086
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 43483
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 23898
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 8655
Revoke complete observations when condensation hides their reads.
If condensation omits file content, the old complete observation still authorizes
write_to_file. An approved replacement built from the summary can therefore delete omitted content. Revoke that authority when the corresponding read leaves the model’s context.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The authority outlives the read, measurably.
src/core/task/Task.ts:302isreadonly observationRegistry = new ObservationRegistry()— one registry for the life of the task, not for the life of the context — and the condensation path never touches it:condenseContextlives at:2682, and a search forobservationRegistryacrosssrc/core/condensereturns nothing at all. So when condensation replaces a full read with a summary, the complete observation for that file is still in the map withcomplete: true(src/core/task/observationRegistry.ts:24, set byobserveat:40-:42), andwrite_to_filestill treats the file as read.That makes your consequence real: an approved replacement generated from the summary can drop content the model can no longer see, and nothing in the registry says the read that authorized it has left the conversation.
Revoking on condensation is not a lookup either — it needs condensation to report which file reads it omitted, which is a fact about the condenser's output rather than about the registry. Major and heavy-lift, in a main-owned file, on a line this branch added: surfaced for you, and I have not guessed at the mechanism.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@easonLiangWorldedtech Agreed. The task-owned registry is not updated when condensation changes the model’s context. A complete observation can therefore continue to authorize a full replacement after the read is hidden.
The finding remains open. A regression test should verify that an omitted complete read no longer authorizes replacement, while a complete read still in context retains its authority. It should also cover failed or cancelled condensation.
You are interacting with an AI system.