diff --git a/config/config.go b/config/config.go index e28b0dd..fbfb087 100644 --- a/config/config.go +++ b/config/config.go @@ -73,6 +73,13 @@ type SandboxConfig struct { ProjectRoot string `json:"project_root,omitempty"` DenyCommands []string `json:"deny_commands,omitempty"` AllowedPaths []string `json:"allowed_paths,omitempty"` + + // RequireHardBoundary refuses file operations that would be enforced by + // in-process checks alone. It is for a deployment that must not run with a + // weaker boundary than the kernel provides, and it fails closed: on a host + // with no kernel mechanism every fenced operation is refused instead of + // being silently allowed. + RequireHardBoundary bool `json:"require_hard_boundary,omitempty"` } // MCPServerConfig defines a single MCP server to connect to. @@ -238,6 +245,13 @@ func merge(dst, src Config) Config { if len(src.Sandbox.AllowedPaths) > 0 { dst.Sandbox.AllowedPaths = append(dst.Sandbox.AllowedPaths, src.Sandbox.AllowedPaths...) } + // A bool has no "unset" to distinguish from false, so it is only + // inherited when the overlay turns it on: a project file cannot turn + // off a user-level requirement, and a user file cannot turn it on by + // being absent. + if src.Sandbox.RequireHardBoundary { + dst.Sandbox.RequireHardBoundary = true + } } // Merge agent overrides diff --git a/main.go b/main.go index 4382cd6..f3012af 100644 --- a/main.go +++ b/main.go @@ -108,6 +108,7 @@ func newRootCmd() *cobra.Command { tool.DefaultSandbox.DenyCommands = append( tool.DefaultSandbox.DenyCommands, cfg.Sandbox.DenyCommands...) } + applySandboxCapabilityPolicy(&cfg) reg := agent.NewRegistry() if err := applyAgentOverrides(reg, &cfg); err != nil { @@ -424,10 +425,12 @@ func newRootCmd() *cobra.Command { tool.DefaultSandbox.AllowAlways(p) } } + applySandboxCapabilityPolicy(&cfg) // The session is created last: an informational command must never // create or flush a session file. sess := store.Create("default") + sess.Containment = tool.ContainmentInfo().String() ag.SessionStore = sess defer sess.Flush() @@ -482,6 +485,27 @@ func newRootCmd() *cobra.Command { return rootCmd } +// applySandboxCapabilityPolicy turns the configured hard-boundary requirement +// into the tool package's policy, and records once what this host can enforce. +// +// The verdict is logged rather than assumed: a deployment that relies on the +// kernel boundary should see, in the run's own log, whether it got one. When +// the requirement is on and no kernel mechanism exists, the warning says what +// will happen next — every fenced operation refuses — so a refusal later in the +// run is not a surprise. +func applySandboxCapabilityPolicy(cfg *config.Config) { + if cfg.Sandbox != nil && cfg.Sandbox.RequireHardBoundary { + tool.SetRequireHardBoundary(true) + } + info := tool.ContainmentInfo() + tlog.Info("sandbox", "containment", info.String()) + if tool.HardBoundaryRequired() && info.Level != tool.ContainmentKernel { + tlog.Warn("sandbox", "hard_boundary_unavailable", + "containment", info.String(), + "effect", "fenced file operations will be refused") + } +} + // expandPath expands $VARS and a leading "~" in a configured path, so values // like "~/.tinycode/sessions" behave the way users expect. func expandPath(p string) string { diff --git a/session/session.go b/session/session.go index b1db220..d537859 100644 --- a/session/session.go +++ b/session/session.go @@ -32,6 +32,14 @@ type Session struct { // Permission paths allowed for this session (Allow session) AllowedPaths []string `json:"allowed_paths,omitempty"` + // Containment records what enforced file boundaries when this session ran, + // as reported by the tool package at creation ("kernel (…)", + // "userspace (…)"). It is recorded once because it is a property of the + // host, not of a call: a later reader can tell how the session's file + // operations were enforced instead of having to infer it from the platform + // the file happens to be read on. + Containment string `json:"containment,omitempty"` + Messages []types.Message `json:"messages"` dir string } diff --git a/session/session_test.go b/session/session_test.go index ca7934b..794ed2d 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -42,6 +42,37 @@ func TestAppendAndFlush(t *testing.T) { } } +// TestContainmentRoundTrips pins the field a later reader uses to tell how a +// session's file operations were enforced. It is asserted twice: through the +// struct (so the field survives Flush/Load) and in the raw file (so the key is +// stable for anyone reading the JSON rather than the Go type). +func TestContainmentRoundTrips(t *testing.T) { + dir := t.TempDir() + const enforcement = "kernel (openat2 RESOLVE_BENEATH)" + + s := New("containment-roundtrip", dir) + s.Containment = enforcement + if err := s.Flush(); err != nil { + t.Fatal(err) + } + + loaded, err := Load("containment-roundtrip", dir) + if err != nil { + t.Fatalf("Load error: %v", err) + } + if loaded.Containment != enforcement { + t.Fatalf("Containment = %q after Load, want %q", loaded.Containment, enforcement) + } + + raw, err := os.ReadFile(filepath.Join(dir, "containment-roundtrip.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"containment"`) || !strings.Contains(string(raw), enforcement) { + t.Fatalf("persisted file does not carry the containment fact under its documented key:\n%s", raw) + } +} + func TestLoad(t *testing.T) { dir := t.TempDir() s := New("load-test", dir) diff --git a/tool/apply_patch.go b/tool/apply_patch.go index d192edf..3940ef3 100644 --- a/tool/apply_patch.go +++ b/tool/apply_patch.go @@ -204,7 +204,7 @@ func ApplyPatch() Tool { // Build summary var sb strings.Builder - sb.WriteString(fmt.Sprintf("Applied patch: %d operation(s)\n", len(results))) + sb.WriteString(fmt.Sprintf("Applied patch: %d operation(s)%s\n", len(results), containmentNote())) for _, r := range results { switch r.applied { case "updated": diff --git a/tool/containment.go b/tool/containment.go new file mode 100644 index 0000000..1bdc990 --- /dev/null +++ b/tool/containment.go @@ -0,0 +1,131 @@ +package tool + +import ( + "errors" + "sync" + "sync/atomic" +) + +// ContainmentLevel says what actually enforces a file operation's boundary. +type ContainmentLevel string + +const ( + // ContainmentKernel means the operating system enforces the boundary as + // part of reaching the path: a component that leaves the root, or a + // component swapped for a symlink between the check and the open, is + // refused by the kernel rather than followed. + ContainmentKernel ContainmentLevel = "kernel" + + // ContainmentUserspace means only the in-process checks apply — + // canonicalize, then compare. They answer the question they were written + // for, but they cannot promise anything about a component swapped after + // they ran, because no kernel decision is attached to the open. + ContainmentUserspace ContainmentLevel = "userspace" +) + +// The mechanisms behind those levels, named for a report a person reads. +const ( + mechanismOpenat2 = "openat2 RESOLVE_BENEATH" + mechanismOpenatWalk = "openat walk, O_NOFOLLOW per component" + mechanismPortable = "resolved-path checks in process" +) + +// Containment is what this host can enforce for a file operation. +type Containment struct { + Level ContainmentLevel + Mechanism string +} + +// String renders the pair for a log line or a status report. +func (c Containment) String() string { + switch { + case c.Mechanism == "": + return string(c.Level) + case c.Level == "": + return c.Mechanism + default: + return string(c.Level) + " (" + c.Mechanism + ")" + } +} + +// probeContainment is the per-platform probe. It is a variable only so this +// package's tests can present a host without a kernel mechanism; production +// never reassigns it. +var probeContainment = defaultProbeContainment + +var ( + containmentOnce sync.Once + containmentInfo Containment +) + +// ContainmentInfo reports what confines a file operation on this host, probing +// once per process. +// +// The probe asks the kernel rather than inferring from the platform name, so +// the verdict cannot drift from what the open layer will actually do: a kernel +// that lost openat2, or a build without the walk, reports the weaker level. +func ContainmentInfo() Containment { + containmentOnce.Do(func() { + containmentInfo = probeContainment() + }) + return containmentInfo +} + +// resetContainmentForTest installs a probe and clears the once, returning a +// restore function that leaves the real probe to run again on next use. +func resetContainmentForTest(probe func() Containment) func() { + previous := probeContainment + probeContainment = probe + containmentInfo = Containment{} + containmentOnce = sync.Once{} + ContainmentInfo() + return func() { + probeContainment = previous + containmentInfo = Containment{} + containmentOnce = sync.Once{} + } +} + +var requireHardBoundary atomic.Bool + +// SetRequireHardBoundary turns the "require a hard boundary" policy on or off. +// It is configuration, not a per-call decision: the host either has a kernel +// mechanism or it does not. +func SetRequireHardBoundary(required bool) { requireHardBoundary.Store(required) } + +// HardBoundaryRequired reports whether that policy is on. +func HardBoundaryRequired() bool { return requireHardBoundary.Load() } + +// ErrHardBoundaryUnavailable is returned when the policy demands a kernel +// boundary and the host has none. +// +// It is an error rather than a permission prompt on purpose: no approval can +// create a capability the host does not have, so refusing is the only honest +// answer. +var ErrHardBoundaryUnavailable = errors.New( + "a hard file boundary is required (sandbox.require_hard_boundary) but this host enforces file operations with in-process checks only; refusing rather than running with a weaker boundary") + +// checkHardBoundary applies the policy to one operation. +func checkHardBoundary() error { + if !HardBoundaryRequired() { + return nil + } + if ContainmentInfo().Level == ContainmentKernel { + return nil + } + return ErrHardBoundaryUnavailable +} + +// containmentNote is appended to a file-mutating tool's success result only +// when the kernel is NOT enforcing the boundary, so a degraded host is visible +// in the result the model is reading rather than only in a log line. +// +// It is empty wherever a kernel mechanism exists, which is every supported +// platform with openat2 or the component walk; ordinary results are therefore +// unchanged, and the note cannot become noise that readers learn to skip. +func containmentNote() string { + if ContainmentInfo().Level == ContainmentKernel { + return "" + } + return " [containment: in-process checks only — this host has no kernel file-boundary mechanism]" +} diff --git a/tool/containment_darwin.go b/tool/containment_darwin.go new file mode 100644 index 0000000..bef4977 --- /dev/null +++ b/tool/containment_darwin.go @@ -0,0 +1,15 @@ +//go:build darwin + +package tool + +// defaultProbeContainment reports the mechanism the open layer uses on macOS. +// +// There is no openat2 here, so every open goes through the component walk: each +// component is opened with O_NOFOLLOW, and a component that is a symlink — which +// a resolved path cannot legitimately contain, and which is therefore one +// swapped in after the check — is refused with ELOOP rather than followed. That +// is a kernel decision attached to the open, so the level is kernel; what macOS +// lacks is the single-syscall form, not the boundary. +func defaultProbeContainment() Containment { + return Containment{Level: ContainmentKernel, Mechanism: mechanismOpenatWalk} +} diff --git a/tool/containment_linux.go b/tool/containment_linux.go new file mode 100644 index 0000000..a73eaf9 --- /dev/null +++ b/tool/containment_linux.go @@ -0,0 +1,50 @@ +//go:build linux + +package tool + +import ( + "errors" + + "golang.org/x/sys/unix" +) + +// defaultProbeContainment asks the kernel which mechanism it will actually +// apply to an open, in the same order the open layer tries them. +// +// openat2 with RESOLVE_BENEATH is the strongest: the decision and the open are +// one syscall. Where it is missing, the component walk (openat with +// O_NOFOLLOW on every component) still confines — a symlink swapped in after +// the check is refused instead of followed — so the level stays kernel. Only a +// host with neither falls back to in-process checks. +func defaultProbeContainment() Containment { + dir, err := unix.Open(".", unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + // Nothing to probe against. Report the weaker level rather than + // claiming a boundary that was not demonstrated. + return Containment{Level: ContainmentUserspace, Mechanism: mechanismPortable} + } + defer unix.Close(dir) + + how := &unix.OpenHow{ + Flags: unix.O_PATH | unix.O_CLOEXEC, + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_MAGICLINKS, + } + fd, err := unix.Openat2(dir, ".", how) + if err == nil { + unix.Close(fd) + return Containment{Level: ContainmentKernel, Mechanism: mechanismOpenat2} + } + + // The same three errno values the escape probe treats as "this kernel does + // not have openat2"; latch it so the runtime stops trying. + if errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.E2BIG) { + openat2Unsupported.Store(true) + // The walk needs only openat, which predates openat2 by a decade: a + // kernel that lacks openat2 still confines through it. + return Containment{Level: ContainmentKernel, Mechanism: mechanismOpenatWalk} + } + + // Any other error is about this particular path, not about the mechanism. + // openat2 is present, so the boundary is the kernel's. + return Containment{Level: ContainmentKernel, Mechanism: mechanismOpenat2} +} diff --git a/tool/containment_other.go b/tool/containment_other.go new file mode 100644 index 0000000..4e81963 --- /dev/null +++ b/tool/containment_other.go @@ -0,0 +1,11 @@ +//go:build !linux && !darwin + +package tool + +// defaultProbeContainment reports the weakest level for platforms where +// openResolvedNoFollow has no implementation: there is no kernel-side walk to +// attach the decision to, so the in-process checks in CheckPath are the only +// gate. +func defaultProbeContainment() Containment { + return Containment{Level: ContainmentUserspace, Mechanism: mechanismPortable} +} diff --git a/tool/containment_test.go b/tool/containment_test.go new file mode 100644 index 0000000..08bee65 --- /dev/null +++ b/tool/containment_test.go @@ -0,0 +1,127 @@ +package tool + +import ( + "context" + "errors" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +// withContainment swaps the containment probe for one test and restores the +// real one afterwards, including the require-hard-boundary policy. +func withContainment(t *testing.T, info Containment) { + t.Helper() + restore := resetContainmentForTest(func() Containment { return info }) + previousPolicy := HardBoundaryRequired() + t.Cleanup(func() { + SetRequireHardBoundary(previousPolicy) + restore() + }) +} + +// TestContainmentProbeRunsOnce pins the contract the startup verdict relies on: +// the probe is a syscall, so it must not run again on every check. +func TestContainmentProbeRunsOnce(t *testing.T) { + var calls atomic.Int64 + restore := resetContainmentForTest(func() Containment { + calls.Add(1) + return Containment{Level: ContainmentKernel, Mechanism: mechanismOpenat2} + }) + defer restore() + + for i := 0; i < 5; i++ { + if got := ContainmentInfo().Level; got != ContainmentKernel { + t.Fatalf("ContainmentInfo().Level = %q, want %q", got, ContainmentKernel) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("probe ran %d times, want exactly 1", got) + } +} + +// TestContainmentNoteOnlyWhenDegraded pins where the fact appears: a host with +// a kernel mechanism adds nothing to a result, so the note cannot become noise +// readers learn to skip, and a degraded host cannot stay invisible either. +func TestContainmentNoteOnlyWhenDegraded(t *testing.T) { + withContainment(t, Containment{Level: ContainmentKernel, Mechanism: mechanismOpenat2}) + if note := containmentNote(); note != "" { + t.Fatalf("containmentNote() = %q on a kernel host, want empty", note) + } + + withContainment(t, Containment{Level: ContainmentUserspace, Mechanism: mechanismPortable}) + note := containmentNote() + if !strings.Contains(note, "containment") || !strings.Contains(note, "in-process checks only") { + t.Fatalf("containmentNote() = %q, want it to name the userspace containment", note) + } +} + +// TestWriteResultCarriesTheDegradedFact is the acceptance test for the fact +// being visible in a tool result — not only in a log line. It runs a real +// mutation through the real tool, so it fails if the note is dropped from any +// of the result-formatting call sites that carry it. +func TestWriteResultCarriesTheDegradedFact(t *testing.T) { + tmpDir := t.TempDir() + saved := DefaultSandbox + DefaultSandbox = &SandboxConfig{ProjectRoot: tmpDir, allowedPaths: make(map[string]bool)} + defer func() { DefaultSandbox = saved }() + + withContainment(t, Containment{Level: ContainmentUserspace, Mechanism: mechanismPortable}) + + target := filepath.Join(tmpDir, "degraded.txt") + res, err := ApplyPatch().Execute(context.Background(), map[string]any{ + "patch_text": "*** Begin Patch\n*** Add File: " + target + "\n+payload\n*** End Patch\n", + }) + if err != nil { + t.Fatalf("apply_patch failed: %v", err) + } + if !strings.Contains(res, "in-process checks only") { + t.Fatalf("result = %q, want it to carry the userspace containment fact", res) + } +} + +// TestHardBoundaryPolicyFailsClosed covers the policy: where a kernel +// mechanism exists the operation proceeds; where it does not, the operation is +// refused instead of silently weakened, and no permission dialog can change +// that — it is not a decision the user is able to grant. +func TestHardBoundaryPolicyFailsClosed(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("kernel host proceeds", func(t *testing.T) { + withContainment(t, Containment{Level: ContainmentKernel, Mechanism: mechanismOpenat2}) + SetRequireHardBoundary(true) + sandbox := &SandboxConfig{ProjectRoot: tmpDir, allowedPaths: map[string]bool{}} + if err := sandbox.CheckPath(filepath.Join(tmpDir, "a.txt")); err != nil { + t.Fatalf("CheckPath on a kernel host = %v, want nil", err) + } + }) + + t.Run("userspace host refuses", func(t *testing.T) { + withContainment(t, Containment{Level: ContainmentUserspace, Mechanism: mechanismPortable}) + SetRequireHardBoundary(true) + sandbox := &SandboxConfig{ProjectRoot: tmpDir, allowedPaths: map[string]bool{}} + err := sandbox.CheckPath(filepath.Join(tmpDir, "b.txt")) + if !errors.Is(err, ErrHardBoundaryUnavailable) { + t.Fatalf("CheckPath = %v, want ErrHardBoundaryUnavailable", err) + } + }) + + t.Run("refuses before the unconfigured escape", func(t *testing.T) { + withContainment(t, Containment{Level: ContainmentUserspace, Mechanism: mechanismPortable}) + SetRequireHardBoundary(true) + sandbox := &SandboxConfig{allowedPaths: map[string]bool{}} + if err := sandbox.CheckPath(filepath.Join(tmpDir, "c.txt")); !errors.Is(err, ErrHardBoundaryUnavailable) { + t.Fatalf("CheckPath with no project root = %v, want ErrHardBoundaryUnavailable (fail closed)", err) + } + }) + + t.Run("policy off keeps the weaker boundary usable", func(t *testing.T) { + withContainment(t, Containment{Level: ContainmentUserspace, Mechanism: mechanismPortable}) + SetRequireHardBoundary(false) + sandbox := &SandboxConfig{ProjectRoot: tmpDir, allowedPaths: map[string]bool{}} + if err := sandbox.CheckPath(filepath.Join(tmpDir, "d.txt")); err != nil { + t.Fatalf("CheckPath with the policy off = %v, want nil", err) + } + }) +} diff --git a/tool/edit.go b/tool/edit.go index f4e7af1..82bf36e 100644 --- a/tool/edit.go +++ b/tool/edit.go @@ -156,7 +156,7 @@ func Edit() Tool { return "", fmt.Errorf("write %s: %w", path, err) } - result := fmt.Sprintf("Applied %d edit(s) to %s (%d line(s) changed)", applied, path, totalChanges) + result := fmt.Sprintf("Applied %d edit(s) to %s (%d line(s) changed)%s", applied, path, totalChanges, containmentNote()) if lsp.IsAvailable() { if newDiags := lsp.GetNewDiagnostics(safePath, content); len(newDiags) > 0 { diff --git a/tool/filesystem.go b/tool/filesystem.go index 227fddc..0222f86 100644 --- a/tool/filesystem.go +++ b/tool/filesystem.go @@ -174,7 +174,7 @@ func WriteFile() Tool { return "", fmt.Errorf("write %s: %w", path, err) } - result := fmt.Sprintf("Wrote %d bytes to %s", len(content), path) + result := fmt.Sprintf("Wrote %d bytes to %s%s", len(content), path, containmentNote()) tlog.Info("fs.write", "done", "file", path, "bytes", len(content)) // LSP diagnostics: only new errors introduced by this edit diff --git a/tool/pathbeneath_walk.go b/tool/pathbeneath_walk.go index 000964d..8a1b5fa 100644 --- a/tool/pathbeneath_walk.go +++ b/tool/pathbeneath_walk.go @@ -16,10 +16,12 @@ import ( // // This is the fallback for every path openat2 cannot cover (issue #7): a path the // user allowed outside the project root has no common dirfd to be RESOLVE_BENEATH -// against, and platforms without openat2 (macOS) have no kernel-side containment -// at all. Opening such a path by name after the sandbox decision is the +// against, and platforms without openat2 (macOS) have no single-syscall form at +// all. Opening such a path by name after the sandbox decision is the // check-then-open window: a component swapped for an escaping symlink in between -// would be followed. +// would be followed. The walk closes that window one component at a time, so a +// host that reaches here is still kernel-enforced — what it lacks is the atomic +// decision-and-open that RESOLVE_BENEATH performs, not the boundary. // // A resolved path contains no symlink by construction, so any symlink found here // was swapped in after the check and is refused with ELOOP instead of followed. diff --git a/tool/sandbox.go b/tool/sandbox.go index 1bf7231..808aca7 100644 --- a/tool/sandbox.go +++ b/tool/sandbox.go @@ -232,6 +232,12 @@ func (sc *SandboxConfig) WritableRoots() []string { // The roots come from WritableRoots, which is also what command execution // consumes, so the two cannot hold different opinions about the writable set. func (sc *SandboxConfig) CheckPath(absPath string) error { + // The capability check comes first, and before the "no project root" escape: + // a policy that demands a kernel boundary must refuse an unconfigured + // sandbox too, not fall through to the unconfined path. + if err := checkHardBoundary(); err != nil { + return err + } if sc.ProjectRoot == "" { return nil } diff --git a/tui/model.go b/tui/model.go index f2e81f9..36acb5f 100644 --- a/tui/model.go +++ b/tui/model.go @@ -198,6 +198,7 @@ func commandList() []cmdEntry { {"/theme", "Switch theme"}, {"/skill", "Load a skill"}, {"/diagnostics", "LSP diagnostics"}, + {"/sandbox", "Sandbox status"}, {"/model", "Switch model"}, {"/fork", "Create session branch"}, {"/session", "List/switch branch"}, @@ -414,6 +415,7 @@ func (m *TuiModel) checkPermissionDialog() bool { // the process working directory). sess := session.New(m.currentBranch, m.SessionDir) sess.AllowedPaths = []string{reqPath} + sess.Containment = tool.ContainmentInfo().String() sess.Flush() } } diff --git a/tui/sandbox.go b/tui/sandbox.go new file mode 100644 index 0000000..23a5f98 --- /dev/null +++ b/tui/sandbox.go @@ -0,0 +1,46 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/yusiwen/tinycode/tool" +) + +// sandboxReport renders what actually confines file operations in this process, +// for the /sandbox command. +// +// The facts come from the tool package, where they otherwise exist only as a +// log line. Printing them here is what makes a degraded host visible to the +// person running the agent — and what a scenario can assert, since a fact that +// lives only in a tool result is never drawn on screen. +func sandboxReport() string { + info := tool.ContainmentInfo() + + required := "no" + if tool.HardBoundaryRequired() { + required = "yes" + } + + root := tool.DefaultSandbox.ProjectRoot + if root == "" { + root = "(unset — file fencing is off)" + } + + var b strings.Builder + b.WriteString("Sandbox\n") + fmt.Fprintf(&b, " containment: %s\n", info.String()) + fmt.Fprintf(&b, " hard boundary required: %s\n", required) + fmt.Fprintf(&b, " project root: %s\n", root) + + roots := tool.DefaultSandbox.WritableRoots() + if len(roots) == 0 { + b.WriteString(" writable roots: none\n") + } else { + b.WriteString(" writable roots:\n") + for _, r := range roots { + b.WriteString(" " + r + "\n") + } + } + return b.String() +} diff --git a/tui/testdata/golden/frames/palette_120x40.txt b/tui/testdata/golden/frames/palette_120x40.txt index 18435d4..be1ac04 100644 --- a/tui/testdata/golden/frames/palette_120x40.txt +++ b/tui/testdata/golden/frames/palette_120x40.txt @@ -45,6 +45,7 @@ /theme Switch theme /skill Load a skill /diagnostics LSP diagnostics + /sandbox Sandbox status /model Switch model /fork Create session branch /session List/switch branch diff --git a/tui/testdata/golden/frames/palette_40x12.txt b/tui/testdata/golden/frames/palette_40x12.txt index dcbc94a..d22e67c 100644 --- a/tui/testdata/golden/frames/palette_40x12.txt +++ b/tui/testdata/golden/frames/palette_40x12.txt @@ -17,6 +17,7 @@ /theme Switch theme /skill Load a skill /diagnostics LSP diagnostics + /sandbox Sandbox status /model Switch model /fork Create session branch /session List/switch branch diff --git a/tui/testdata/golden/frames/palette_80x24.txt b/tui/testdata/golden/frames/palette_80x24.txt index e5801cb..c31ef9c 100644 --- a/tui/testdata/golden/frames/palette_80x24.txt +++ b/tui/testdata/golden/frames/palette_80x24.txt @@ -29,6 +29,7 @@ /theme Switch theme /skill Load a skill /diagnostics LSP diagnostics + /sandbox Sandbox status /model Switch model /fork Create session branch /session List/switch branch diff --git a/tui/testdata/scenarios/sandbox-command.scenario b/tui/testdata/scenarios/sandbox-command.scenario new file mode 100644 index 0000000..2dbb847 --- /dev/null +++ b/tui/testdata/scenarios/sandbox-command.scenario @@ -0,0 +1,30 @@ +# "/sandbox" reports what actually confines file operations in this process: the +# enforcement level, the mechanism behind it, whether a hard boundary is required, +# and the effective writable roots. +# +# The command exists because the fact otherwise lives only in a tool result and a +# log line, and the TUI draws tool *calls*, not tool result text — so a degraded +# host was invisible on screen. What this file asserts is deliberately the part +# that is the same on every host: the report's shape. The level itself is a +# property of the machine (openat2 on Linux, the component walk on macOS, +# in-process checks where neither exists), so pinning its value here would make +# the scenario fail for a reason that has nothing to do with the code. +# +# The write-result half of the same fact is asserted where it can be: a Go test +# in the tool package presents a host with no kernel mechanism and reads the note +# out of a real mutation's result. +open --dir . --size 120x30 --env HOME=/tmp/tinyscen-home --env TERM=xterm-256color --env COLORTERM=truecolor --env CLICOLOR_FORCE=1 --env NO_COLOR -- ./bin/tinycode --api-key=scenario --base-url=http://127.0.0.1:9/v1 --model=scenario --session-dir=/tmp/tinyscen-home/sessions --log-level=error +wait --text "Get started" --timeout 20s +# Type only once the input row exists: the welcome heading appears first, and keys sent +# before the input is ready can be lost (issue #102). +wait --text "Type your request" --timeout 20s +send --text /sandbox +send --key enter +wait --text "containment:" --timeout 10s +wait --text "hard boundary required:" --timeout 10s +wait --text "writable roots:" --timeout 10s +screenshot /tmp/tinyscen-shots/sandbox-120x30.png --scale 2 +send --text /exit +send --key enter +wait-exit 15s +expect-exit 0 diff --git a/tui/update.go b/tui/update.go index 7a0a87b..e9e5751 100644 --- a/tui/update.go +++ b/tui/update.go @@ -903,6 +903,7 @@ func (m *TuiModel) persistSession() { } else { s = session.New(id, m.SessionDir) } + s.Containment = tool.ContainmentInfo().String() for _, chatMsg := range m.messages { s.Append(types.Message{ Role: chatMsg.Role, @@ -1276,6 +1277,10 @@ Mouse: m.ShowStatus("No LSP diagnostics.") } return m, nil + case "/sandbox": + m.messages = append(m.messages, chatMessage{Role: "system", Content: sandboxReport()}) + m.autoScroll() + return m, nil case "/skill": parts := strings.Fields(cmd) skills := skill.Discover(".")