mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(receipt): judge host-owned paths when the call ran and treat links of every kind as not owned
This commit is contained in:
1 parent
d086618850
commit
94e65f522d
7 files changed
+142
-15
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in new issue
Block a user