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.
This commit is contained in:
zumac committed 2026-10-19 18:03:44 -07:00
1 parent d2f919736b
commit c8c8d8e341
13 files changed
+358 -5

No files matched your search

@@ -291,6 +291,14 @@ describe("rebuilding a reopened transcript", () => {
expect(got[0].text).toBe("继续");
});
it("strips a session-scratch block from a replayed turn", () => {
const got = users([
{ role: "user", content: "<session-scratch>\nThis session has a private scratch directory, $TMPDIR.\n</session-scratch>\n继续" },
]);
expect(got).toHaveLength(1);
expect(got[0].text).toBe("继续");
});
it("leaves an ordinary turn as its text", () => {
const got = users([{ role: "user", content: "第一句话" }]);
expect(got).toHaveLength(1);
+1 -1
View File
@@ -651,7 +651,7 @@ function withReceipt(items: Item[], r?: Receipt): Item[] {
// eleven. The server strips these before /history now — this is what covers
// sessions already on disk.
const CONTROL =
/<(reasoning-language|turn-narration|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|current-date|project-instructions|project-rules|scoped-rules|capability-route|interrupted-turn-recovery|workspace|scheduled-run|mcp-prompt-failure|refused-calls)[\s\S]*?<\/\1>\s*/g;
/<(reasoning-language|turn-narration|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|current-date|session-scratch|project-instructions|project-rules|scoped-rules|capability-route|interrupted-turn-recovery|workspace|scheduled-run|mcp-prompt-failure|refused-calls)[\s\S]*?<\/\1>\s*/g;
const stripControl = (s: string) => s.replace(CONTROL, "").trim();
// A plan that ran to the end is spent: struck through in the rail it reads as
@@ -0,0 +1,141 @@
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)
}
}
+1
View File
@@ -202,6 +202,7 @@ func ApplyRuntimeMigration(ctrl, old *control.Controller, m RuntimeMigration) er
ctrl.CarryRecoveryFrom(old)
}
ctrl.InheritLifecycleFrom(old)
ctrl.InheritScratchFrom(old)
}
ctrl.RestoreSessionAuthorizations(m.authorizations)
// A mode survives only onto a model that declares it: switching to one
@@ -81,6 +81,7 @@ type controllerDeps struct {
skills skillSet
rules rulesSet
date dateAnchor
scratch scratchAnchor
skillRunner skill.SubagentRunner
side sideSet // see side_session.go
readOnlySkillRunner skill.SubagentRunner
@@ -179,6 +180,7 @@ func newControllerDeps(opts Options, sink event.Sink, usageTee *goalUsageTee, ru
modelFace: faceOfEntry(opts.ModelEntry),
sessionDir: opts.SessionDir,
date: newDateAnchor(opts.Clock),
scratch: scratchAnchor{temp: opts.SessionTemp},
skills: newSkillSet(opts.Skills, opts.AllSkills, opts.SkillStore, opts.AllSkillStore, opts.DisableImplicitSkillInvocation, opts.WorkspaceRoot),
skillRunner: opts.SkillRunner,
side: sideSet{factory: opts.SideFactory},
+1
View File
@@ -45,6 +45,7 @@ func (s *inboxEventSink) Emit(e event.Event) {
return
}
s.c.date.debt.forget()
s.c.scratch.debt.forget()
s.c.skills.forgetDeliveredCatalog()
s.c.memory.forgetDeliveredInstructions()
s.c.rules.forget()
@@ -46,6 +46,13 @@ func (d *projectionDebt) sent() bool {
return prev != nil && *prev != ""
}
// inherit takes over what another controller's model was told, for a
// replacement that carries that conversation verbatim.
func (d *projectionDebt) inherit(from *projectionDebt) {
d.pending.Store(nil)
d.delivered.Store(from.delivered.Load())
}
// forget returns the debt to unknown: what was sent is no longer in the context
// the model samples from. A new session starts here, and so does a fold — which
// also discards a turn's pending bytes, since the fold decided their fate.
@@ -0,0 +1,59 @@
package control
import "reasonix/internal/state/sessiontemp"
const sessionScratchTag = "session-scratch"
const scratchGuidance = "This session has a private scratch directory, $TMPDIR, which the host deletes when the session ends. " +
"Put throwaway tests and probes at `$TMPDIR/<name>`, spelled that way in bash (quoted) and in the file tools, " +
"instead of the repository, /tmp or `mktemp -d`, and do not delete it."
const scratchRetracted = "No session scratch directory is available now; do not rely on $TMPDIR for scratch files."
// scratchAnchor tells the model the one spelling of its scratch directory that
// bash and the file tools both resolve. The host path differs per platform and
// per sandbox (bwrap binds the directory at /tmp), so $TMPDIR is the identity
// and the path is never projected. Only a manager the assembly supplied counts:
// it is the one the tools are bound to.
type scratchAnchor struct {
temp *sessiontemp.Manager
debt projectionDebt
}
// InheritScratchFrom carries what prev's model was told across a rebuild that
// keeps its conversation, so an unchanged block is not sent twice.
func (c *Controller) InheritScratchFrom(prev *Controller) {
if prev == nil {
return
}
c.scratch.debt.inherit(&prev.scratch.debt)
}
// owed asks the manager rather than waiting to be told: a generation that can
// no longer be created changes the answer, and noticing is the projection's job.
func (s *scratchAnchor) owed() string {
if scratchDir(s.temp) != "" {
return s.debt.owed(scratchGuidance)
}
if !s.debt.sent() {
return s.debt.owed("")
}
return s.debt.owed(scratchRetracted)
}
// scratchDir names the current generation, creating it when no command has yet:
// a directory the model is told about must exist before its first write.
func scratchDir(m *sessiontemp.Manager) string {
if m == nil {
return ""
}
if dir := m.Dir(); dir != "" {
return dir
}
lease, err := m.Acquire()
if err != nil {
return ""
}
defer lease.Release()
return lease.Dir()
}
@@ -0,0 +1,130 @@
package control
import (
"errors"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/state/sessiontemp"
)
func scratchBlockOf(composed string) string {
open, close := "<"+sessionScratchTag+">", "</"+sessionScratchTag+">"
i := strings.Index(composed, open)
if i < 0 {
return ""
}
j := strings.Index(composed[i:], close)
if j < 0 {
return composed[i:]
}
return composed[i : i+j+len(close)]
}
// The model is told the one spelling bash and the file tools both resolve. The
// host path is never projected: under bwrap bash sees the directory at /tmp, and
// on Windows a backslashed literal does not survive a bash command line.
func TestScratchBlockProjectsTheSpellingNotTheHostPath(t *testing.T) {
m := sessiontemp.NewWithRoot(testenv.TempDir(t))
c := New(Options{SessionTemp: m, Sink: event.Discard})
defer c.Close()
first := scratchBlockOf(c.Compose("turn-one"))
dir := m.Dir()
if dir == "" {
t.Fatal("projecting the scratch block did not create the session's directory")
}
if !strings.Contains(first, "`$TMPDIR/<name>`") {
t.Fatalf("scratch block does not give the $TMPDIR spelling:\n%s", first)
}
if strings.Contains(first, dir) || strings.Contains(first, sessiontemp.Prefix()) {
t.Fatalf("scratch block projected the host path %q:\n%s", dir, first)
}
c.settleTurnProjections()
if again := scratchBlockOf(c.Compose("turn-two")); again != "" {
t.Fatalf("an unchanged block was projected twice:\n%s", again)
}
c.settleTurnProjections()
m.Rotate()
if rotated := scratchBlockOf(c.Compose("turn-three")); rotated != "" {
t.Fatalf("a rotation re-sent bytes the model already holds:\n%s", rotated)
}
}
// Composing alone settles nothing: a turn a hook blocks put the block nowhere.
func TestScratchBlockIsOwedUntilATurnCarriesIt(t *testing.T) {
m := sessiontemp.NewWithRoot(testenv.TempDir(t))
c := New(Options{SessionTemp: m, Sink: event.Discard})
defer c.Close()
if scratchBlockOf(c.Compose("blocked")) == "" {
t.Fatal("first turn carried no scratch block")
}
if scratchBlockOf(c.Compose("retry")) == "" {
t.Fatal("an unsettled scratch block was dropped from the next turn")
}
c.settleTurnProjections()
c.scratch.debt.forget()
if scratchBlockOf(c.Compose("after-fold")) == "" {
t.Fatal("a forgotten delivery was not re-owed")
}
}
// A directory the model was told about and that can no longer be had — the
// next generation failed to create — is retracted once, rather than left
// reading as current.
func TestScratchBlockRetractsADirectoryThatNoLongerHolds(t *testing.T) {
m := sessiontemp.NewWithRoot(testenv.TempDir(t))
c := New(Options{SessionTemp: m, Sink: event.Discard})
defer c.Close()
if scratchBlockOf(c.Compose("one")) == "" {
t.Fatal("first turn carried no scratch block")
}
c.settleTurnProjections()
m.SetMkDirForTest(func(string) (string, error) { return "", errors.New("disk full") })
m.Rotate()
retract := scratchBlockOf(c.Compose("two"))
if retract == "" || strings.Contains(retract, "`$TMPDIR/<name>`") {
t.Fatalf("a directory that no longer holds was not retracted:\n%s", retract)
}
c.settleTurnProjections()
if again := scratchBlockOf(c.Compose("three")); again != "" {
t.Fatalf("the retraction was repeated:\n%s", again)
}
}
// Only the manager the assembly bound the tools to is announced; a controller
// that made its own has no tool writing there.
func TestScratchBlockNeedsTheAssemblysManager(t *testing.T) {
c := New(Options{Sink: event.Discard})
defer c.Close()
if got := scratchBlockOf(c.Compose("one")); got != "" {
t.Fatalf("a controller without an assembly-supplied manager announced one:\n%s", got)
}
}
// A rebuild carries the conversation verbatim, so a block it already holds is
// not sent again, whichever manager the replacement is bound to.
func TestScratchDeliverySurvivesARebuild(t *testing.T) {
m := sessiontemp.NewWithRoot(testenv.TempDir(t))
old := New(Options{SessionTemp: m, Sink: event.Discard})
defer old.Close()
if scratchBlockOf(old.Compose("one")) == "" {
t.Fatal("first turn carried no scratch block")
}
old.settleTurnProjections()
for _, temp := range []*sessiontemp.Manager{m, sessiontemp.NewWithRoot(testenv.TempDir(t))} {
next := New(Options{SessionTemp: temp, Sink: event.Discard})
next.InheritScratchFrom(old)
if got := scratchBlockOf(next.Compose("two")); got != "" {
t.Fatalf("a rebuild re-sent the block:\n%s", got)
}
next.Close()
}
}
@@ -57,6 +57,7 @@ func (c *Controller) turnBlocksFor(source string, includeOwed bool, notes []stri
blocks = append(blocks,
turnBlock{hookContextTag, c.drainHookContextBlock()},
turnBlock{currentDateTag, wrapTurnBlock(currentDateTag, c.date.owed())},
turnBlock{sessionScratchTag, wrapTurnBlock(sessionScratchTag, c.scratch.owed())},
turnBlock{"available-skills", wrapTurnBlock("available-skills", c.skills.owedCatalog())},
turnBlock{"project-instructions", wrapTurnBlock("project-instructions", c.memory.owedInstructions())},
turnBlock{"project-rules", wrapTurnBlock("project-rules", always)},
@@ -170,6 +171,7 @@ func (c *Controller) runWithRunner(ctx context.Context, input string) error {
func (c *Controller) settleTurnProjections() {
c.date.debt.settle()
c.scratch.debt.settle()
c.skills.catalog.settle()
c.memory.instructions.settle()
c.rules.settle()
@@ -20,6 +20,7 @@ func TestTurnBlockOrderIsDeclared(t *testing.T) {
want := []string{
"hook-context",
"current-date",
"session-scratch",
"available-skills",
"project-instructions",
"project-rules",
+1
View File
@@ -27,6 +27,7 @@ var TransientUserBlockTags = []string{
"hook-context",
"available-skills",
"current-date",
"session-scratch",
"project-instructions",
"project-rules",
"scoped-rules",
+4 -4
View File
@@ -3,10 +3,10 @@
// duration of a logical chat session, while each bwrap/sandbox-exec invocation
// still enters its own namespace.
//
// The manager is intentionally invisible to models and users: no settings,
// tool parameters, or prompt surface is added. Hot rebuilds retain the same
// Manager; /new, /clear, resume of another session, and branch switches rotate
// to a fresh generation.
// The manager adds no settings or tool parameters; its one model-visible
// surface is the controller's turn block naming it as $TMPDIR, never its path. Hot
// rebuilds retain the same Manager; /new, /clear, resume of another session,
// and branch switches rotate to a fresh generation.
package sessiontemp
import (