Files
DeepSeek-Reasonix/internal/assembly/boot/effect_session_scratch_test.go
zumac c8c8d8e341 fix(control): tell the model its scratch directory is $TMPDIR and needs no cleanup
The host already confines bash's $TMPDIR to a per-session directory, the
file tools resolve a leading $TMPDIR to it, and the host deletes it when
the session ends. The model was never told the last two, so it verified
with `scratch=$(mktemp -d); ...; rm -rf "$scratch"` or wrote tests to /tmp,
and the host refused them (shell.delete_nonliteral, shell.delete_sequence,
write scope): 26 refused calls over 36 deepseek-flash benchmark runs, one
model round each.

A <session-scratch> turn block now says: put throwaway files at
`$TMPDIR/<name>`, spelled that way in bash and in the file tools, and do
not delete them. The host path is deliberately not projected: under bwrap
bash sees the directory at /tmp, and on Windows a backslashed literal does
not survive a bash command line, so $TMPDIR is the one spelling every tool
resolves. The block follows the dateAnchor/projectionDebt pattern: it rides
the turn and never the prefix, settles only when a turn reaches the runner,
is re-owed after a fold, retracts itself once if the directory can no
longer be created, is projected only for a manager the assembly supplied,
and is inherited across a rebuild. Its bytes carry no per-session value,
so two sessions over the same state project identically.

No shell-safety rule changes; the effect test asserts that a recursive
delete through $TMPDIR or a mktemp variable is still refused.
2026-10-19 18:03:44 -07:00

142 lines
4.9 KiB
Go

package boot
import (
"context"
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/state/sessiontemp"
)
// scratchProvider plays a model that verifies in the scratch directory it was
// told about, spelling it exactly as the block says, one tool call per round.
type scratchProvider struct {
mu sync.Mutex
reqs []provider.Request
}
func (p *scratchProvider) Name() string { return "boot-effect-scratch" }
func (p *scratchProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.mu.Lock()
p.reqs = append(p.reqs, req)
round := len(p.reqs)
told := round > 1 || strings.Contains(blockOf(lastUserContent(req), "session-scratch"), "`$TMPDIR/<name>`")
p.mu.Unlock()
call := func(id, name string, args map[string]string) provider.Chunk {
raw, _ := json.Marshal(args)
return provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: string(raw)}}
}
ch := make(chan provider.Chunk, 2)
switch {
case !told:
ch <- provider.Chunk{Type: provider.ChunkText, Text: "no scratch block was projected"}
case round == 1:
ch <- call("w1", "write_file", map[string]string{"path": "$TMPDIR/check/probe_test.py", "content": "assert True\n"})
case round == 2:
ch <- call("b1", "bash", map[string]string{"command": `cd "$TMPDIR/check" && cat probe_test.py`})
case round == 3:
ch <- call("b2", "bash", map[string]string{"command": `rm -rf "$TMPDIR/check"`})
case round == 4:
ch <- call("b3", "bash", map[string]string{"command": `scratch=$(mktemp -d); rm -rf "$scratch"`})
default:
ch <- provider.Chunk{Type: provider.ChunkText, Text: "done"}
}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func lastUserContent(req provider.Request) string {
for _, m := range slices.Backward(req.Messages) {
if m.Role == provider.RoleUser {
return m.Content
}
}
return ""
}
func (p *scratchProvider) toolResult(round int, id string) string {
p.mu.Lock()
defer p.mu.Unlock()
if round >= len(p.reqs) {
return ""
}
for _, m := range p.reqs[round].Messages {
if m.Role == provider.RoleTool && m.ToolCallID == id {
return m.Content
}
}
return ""
}
// TestEffectSessionScratchIsReachableByItsSpelling pins what the scratch block
// buys at the tool boundary: the one spelling it gives, `$TMPDIR/<name>`, reaches
// the same directory through write_file and through bash, the host path never
// reaches the model, and the deletes the gate exists to refuse are still
// refused for the scratch directory itself.
func TestEffectSessionScratchIsReachableByItsSpelling(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
rec := &scratchProvider{}
resolver := &provider.StaticResolver{
Descriptors: []provider.Descriptor{{Ref: "test-model/x", Model: "x", Default: true}},
Providers: map[string]provider.Provider{"test-model/x": rec},
}
writeUserConfig(t, "[sandbox]\nbash = \"off\"\n")
writeFile(t, dir, "reasonix.toml", "\n[agent]\nsystem_prompt = \"BASE\"\n")
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, Model: "test-model/x", ProviderResolver: resolver, resolvedShell: pinnedEffectShell()})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
ctrl.SetSessionPath(filepath.Join(dir, ".reasonix", "sessions", "scratch.jsonl"))
ctrl.SetBypass(true)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := ctrl.Run(ctx, "verify in scratch"); err != nil {
t.Fatalf("Run: %v", err)
}
rec.mu.Lock()
first := rec.reqs[0]
rec.mu.Unlock()
scratch := ctrl.SessionTemp().Dir()
if scratch == "" {
t.Fatal("the session has no scratch directory")
}
for _, m := range first.Messages {
if strings.Contains(m.Content, scratch) || strings.Contains(m.Content, sessiontemp.Prefix()) {
t.Fatalf("the host scratch path reached the model in a %s message", m.Role)
}
}
if got := rec.toolResult(1, "w1"); strings.Contains(got, "refused") {
t.Fatalf("write_file to $TMPDIR/check was refused: %s", got)
}
if _, err := os.Stat(filepath.Join(scratch, "check", "probe_test.py")); err != nil {
t.Fatalf("write_file did not land in the session's directory: %v", err)
}
if got := rec.toolResult(2, "b1"); strings.Contains(got, "refused") || !strings.Contains(got, "assert True") {
t.Fatalf(`bash did not reach the file write_file wrote through "$TMPDIR/check": %s`, got)
}
if got := rec.toolResult(3, "b2"); !strings.Contains(got, "refused") {
t.Fatalf("a recursive delete through $TMPDIR was not refused: %s", got)
}
if got := rec.toolResult(4, "b3"); !strings.Contains(got, "refused") {
t.Fatalf("a delete through a mktemp variable was not refused: %s", got)
}
if _, err := os.Stat(filepath.Join(scratch, "check", "probe_test.py")); err != nil {
t.Fatalf("a refused delete still removed the scratch file: %v", err)
}
}