fix(receipt): judge host-owned paths when the call ran and treat links of every kind as not owned

This commit is contained in:
yhh committed 2026-10-10 20:16:36 -07:00
1 parent d086618850
commit 94e65f522d
7 files changed
+142 -15

No files matched your search

+8 -2
View File
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"reasonix/internal/runtime/taskmonitor"
@@ -11,8 +12,13 @@ import (
// hostOwnedPath reports whether path lies in the task-snapshot store, the host's
// own path convention. It de-noises the receipt only: any writer can create files
// there, and a symlink anywhere on the way means the path leads elsewhere.
// there, and a link of any kind on the way (symlink or Windows junction) or a
// ".." in the path as given means it may lead elsewhere. A missing component
// cannot be a link, so only the existing prefix is inspected.
func hostOwnedPath(root, path string) bool {
if slices.Contains(strings.FieldsFunc(path, func(r rune) bool { return r == '/' || r == filepath.Separator }), "..") {
return false
}
if !filepath.IsAbs(path) {
path = filepath.Join(root, path)
}
@@ -27,7 +33,7 @@ func hostOwnedPath(root, path string) bool {
if err != nil {
return os.IsNotExist(err)
}
if info.Mode()&os.ModeSymlink != 0 {
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
return false
}
}
+17 -5
View File
@@ -199,7 +199,22 @@ func (a *Agent) touchedTheWorkspace(r evidence.Receipt) bool {
if a.writeWorkspaceRoot == "" || len(r.Paths) == 0 {
return true
}
return slices.ContainsFunc(r.Paths, a.pathInWorkspace)
return slices.ContainsFunc(r.Paths, func(p string) bool {
return !slices.Contains(r.HostOwned, p) && a.pathInWorkspace(p)
})
}
// stampHostOwned records which of the receipt's paths are host state as the
// filesystem stands now, while the call's own effects are what it reflects.
func (a *Agent) stampHostOwned(rec *evidence.Receipt) {
if a.writeWorkspaceRoot == "" || rec == nil {
return
}
for _, p := range rec.Paths {
if hostOwnedPath(a.writeWorkspaceRoot, p) {
rec.HostOwned = append(rec.HostOwned, p)
}
}
}
// pathInWorkspace reports whether one path is part of the work product. A
@@ -210,10 +225,7 @@ func (a *Agent) pathInWorkspace(path string) bool {
if root == "" {
return true
}
if filepath.IsAbs(path) && !writeclaim.PathWithin(root, path) {
return false
}
return !hostOwnedPath(root, path)
return !filepath.IsAbs(path) || writeclaim.PathWithin(root, path)
}
// mutationBaseline is what the turn's remaining obligations are measured from:
@@ -55,9 +55,11 @@ func (f receiptFixture) write(t *testing.T, rel string) string {
}
func (f receiptFixture) record(paths ...string) {
f.ledger.Record(evidence.Receipt{
rec := evidence.Receipt{
ToolName: "bash", Success: true, Mutation: true, MutationEvidence: evidence.MutationProven, Paths: paths,
})
}
f.a.stampHostOwned(&rec)
f.ledger.Record(rec)
}
func (f receiptFixture) report() completion.Report {
@@ -138,7 +140,7 @@ func TestSnapshotStoreLinkedToSourceStaysListed(t *testing.T) {
t.Fatal(err)
}
if err := os.Symlink(filepath.Dir(real), link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
t.Fatalf("this runner cannot create symlinks, so the protection is untested: %v", err)
}
f.record(filepath.Join(link, "api.py"))
rep := f.report()
@@ -162,3 +164,80 @@ func TestScanSkipsAVCSStoreKeptAsAFile(t *testing.T) {
t.Fatalf("scan lost the work file: %v", scan.state)
}
}
func mustLink(t *testing.T, target, link string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, link); err != nil {
t.Fatalf("this runner cannot create symlinks, so the protection is untested: %v", err)
}
}
func requireListed(t *testing.T, f receiptFixture, path string) {
t.Helper()
rep := f.report()
if rep.Mutations != 1 {
t.Fatalf("mutations = %d, want the write counted", rep.Mutations)
}
requirePaths(t, changedPaths(rep), path)
}
// The verdict is the filesystem as the write left it: removing the link
// afterwards must not turn a counted write into a host-owned one.
func TestLinkRemovedAfterTheWriteDoesNotHideIt(t *testing.T) {
f := newReceiptFixture(t)
real := f.write(t, filepath.Join("src", "api.py"))
link := filepath.Join(f.root, ".reasonix", "tasks", "x")
mustLink(t, filepath.Dir(real), link)
written := filepath.Join(link, "api.py")
f.record(written)
if err := os.Remove(link); err != nil {
t.Fatal(err)
}
requireListed(t, f, written)
}
func TestLinkedStoreRootsAndDanglingLinksStayListed(t *testing.T) {
for name, build := range map[string]func(f receiptFixture) string{
"the last segment is a link": func(f receiptFixture) string {
real := f.write(t, filepath.Join("src", "api.py"))
link := filepath.Join(f.root, ".reasonix", "tasks", "api.py")
mustLink(t, real, link)
return link
},
"the .reasonix directory is a link": func(f receiptFixture) string {
f.write(t, filepath.Join("src", "tasks", "api.py"))
mustLink(t, filepath.Join(f.root, "src"), filepath.Join(f.root, ".reasonix"))
return filepath.Join(f.root, ".reasonix", "tasks", "api.py")
},
"a dangling link": func(f receiptFixture) string {
link := filepath.Join(f.root, ".reasonix", "tasks", "gone")
mustLink(t, filepath.Join(f.root, "nowhere"), link)
return filepath.Join(link, "api.py")
},
} {
t.Run(name, func(t *testing.T) {
f := newReceiptFixture(t)
path := build(f)
f.record(path)
requireListed(t, f, path)
})
}
}
// A path that climbs out of the store before it is cleaned is not judged by
// where the cleaned spelling lands.
func TestDotDotPathIsNeverHostOwned(t *testing.T) {
f := newReceiptFixture(t)
f.write(t, filepath.Join(taskmonitor.StoreDir, "step-5", "snapshot.json"))
work := f.write(t, filepath.Join("src", "api.py"))
sep := string(filepath.Separator)
climbing := f.root + sep + taskmonitor.StoreDir + sep + "step-5" + sep + ".." + sep + ".." + sep + ".." + sep + "src" + sep + "api.py"
f.record(climbing)
rep := f.report()
if rep.Mutations != 1 || len(rep.Changes) != 1 {
t.Fatalf("mutations = %d, changes = %q, want the write to %s counted", rep.Mutations, changedPaths(rep), work)
}
}
@@ -0,0 +1,23 @@
//go:build windows
package agent
import (
"os/exec"
"path/filepath"
"testing"
)
// A junction is neither a symlink nor a regular directory to Go, and the store
// reached through one is not the host's.
func TestJunctionOnTheWayToTheStoreStaysListed(t *testing.T) {
f := newReceiptFixture(t)
real := f.write(t, filepath.Join("src", "api.py"))
link := filepath.Join(f.root, ".reasonix", "tasks", "x")
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, filepath.Dir(real)).CombinedOutput(); err != nil {
t.Fatalf("mklink /J: %v: %s", err, out)
}
written := filepath.Join(link, "api.py")
f.record(written)
requireListed(t, f, written)
}
+2
View File
@@ -29,6 +29,7 @@ func (a *Agent) recordToolReceipts(ctx context.Context, plan *toolCallPlan, resu
rec := evidence.ReceiptFromToolCall(plan.evidenceName, plan.evidenceArgs, err == nil, plan.facts())
decorateExecutionReceipt(&rec, result, execution)
decorateObservedPaths(&rec, plan)
a.stampHostOwned(&rec)
a.settleUnchangedWorkspace(ctx, &rec, plan)
a.reviewCoverageOf(&rec, plan, result)
a.task.ledger.Record(rec)
@@ -36,6 +37,7 @@ func (a *Agent) recordToolReceipts(ctx context.Context, plan *toolCallPlan, resu
rec := evidence.ReceiptFromToolCall(call.Name, args, err == nil, plan.facts())
decorateExecutionReceipt(&rec, result, execution)
decorateObservedPaths(&rec, plan)
a.stampHostOwned(&rec)
a.settleUnchangedWorkspace(ctx, &rec, plan)
a.reviewCoverageOf(&rec, plan, result)
stampReviewGrant(&rec, plan)
+7 -5
View File
@@ -191,7 +191,7 @@ func changesOf(ledger *evidence.Ledger, receipts []evidence.Receipt, inWorkspace
authored[p] = true
}
for _, p := range r.Paths {
if p == "" || (inWorkspace != nil && !inWorkspace(p)) {
if p == "" || slices.Contains(r.HostOwned, p) || (inWorkspace != nil && !inWorkspace(p)) {
continue
}
if _, seen := at[p]; !seen {
@@ -223,7 +223,7 @@ func changesOf(ledger *evidence.Ledger, receipts []evidence.Receipt, inWorkspace
func mutationsOf(receipts []evidence.Receipt, inWorkspace func(string) bool) int {
count := 0
for _, r := range receipts {
if r.Success && (r.Mutation || r.Write) && keptAnyPath(r.Paths, inWorkspace) {
if r.Success && (r.Mutation || r.Write) && keptAnyPath(r, inWorkspace) {
count++
}
}
@@ -233,11 +233,13 @@ func mutationsOf(receipts []evidence.Receipt, inWorkspace func(string) bool) int
// keptAnyPath reports whether a receipt touched the work product. A receipt
// that named no path counts either way: `sed -i` and `rm` name nothing and
// must not escape the gap by staying silent about where they landed.
func keptAnyPath(paths []string, inWorkspace func(string) bool) bool {
if inWorkspace == nil || len(paths) == 0 {
func keptAnyPath(r evidence.Receipt, inWorkspace func(string) bool) bool {
if len(r.Paths) == 0 {
return true
}
return slices.ContainsFunc(paths, inWorkspace)
return slices.ContainsFunc(r.Paths, func(p string) bool {
return !slices.Contains(r.HostOwned, p) && (inWorkspace == nil || inWorkspace(p))
})
}
// browserCheckTool is the browser call the host can classify as a check.
+3
View File
@@ -23,6 +23,9 @@ type Receipt struct {
CitedChecks []string `json:"cited_checks,omitempty"`
TodoStep *TodoStepMatch `json:"todo_step,omitempty"`
Paths []string `json:"paths,omitempty"`
// HostOwned are the Paths the host judged to be its own state when the call
// ran, so a later change to the filesystem cannot reclassify the write.
HostOwned []string `json:"host_owned,omitempty"`
// MutationPaths preserves path spelling before identity normalization can
// fold case or discard empty paths; scope exemptions must fail closed on both.
MutationPaths []string `json:"mutation_paths,omitempty"`