fix(permission): refuse the remaining read-only escapes and trust no home directory

This commit is contained in:
esengine committed 2026-09-29 01:02:46 -07:00
1 parent 527eee4b4c
commit a548df7f55
10 files changed
+114 -62

No files matched your search

+7 -3
View File
@@ -396,9 +396,13 @@ With no mode named, a session opens in `auto` only when both hold:
Otherwise it opens in `ask`; on Windows, which has no OS sandbox, always.
What `auto` then allows without asking is bounded by that sandbox, not by the
folder alone: shell commands may also write temp and toolchain caches (`~/go`,
`~/.cargo`, `~/.cache` and the like), and reach the network unless
`[sandbox] network = false`.
folder alone:
- shell commands may also write your `allow_write` and `--add-dir`
directories, temp, and toolchain caches (`~/go`, `~/.cargo`, `~/.cache` and
the like), and what lands in `~/.cargo/bin` or `~/go/bin` runs later outside
the sandbox;
- they reach the network unless `[sandbox] network = false`.
- The terminal UI asks once per folder whether to trust it, never for a home
directory or a filesystem root, and keeps the answer in your Reasonix home.
+5 -2
View File
@@ -351,8 +351,11 @@ reasonix --allowed-tools "Bash(go test ./...)" --allowed-tools read_file
否则以 `ask` 打开;Windows 没有 OS 沙盒,始终如此。
此时 `auto` 不经询问放行的范围由沙盒决定,不只是这个文件夹:shell 命令还能写临时目录和工具链缓存
(`~/go`、`~/.cargo`、`~/.cache` 等),并且能访问网络,除非设置 `[sandbox] network = false`。
此时 `auto` 不经询问放行的范围由沙盒决定,不只是这个文件夹:
- shell 命令还能写你配置的 `allow_write` 与 `--add-dir` 目录、临时目录和工具链缓存(`~/go`、`~/.cargo`、
`~/.cache` 等),写进 `~/.cargo/bin`、`~/go/bin` 的东西以后会在沙盒外运行;
- 能访问网络,除非设置 `[sandbox] network = false`。
- 终端界面对每个文件夹问一次是否信任(家目录和文件系统根目录从不问),答案记在你的 Reasonix 主目录。
- `reasonix trust` 信任当前文件夹,`reasonix trust --revoke` 撤销;项目自己的文件不能写入信任记录。
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/config"
"reasonix/internal/ext/hook"
"reasonix/internal/session/control"
)
type trustOptions struct {
@@ -53,7 +54,7 @@ func runTrust(args []string, in *bufio.Scanner, out io.Writer, interactive bool)
return 1
}
trust, _ := grants.Trust(root)
canTrust := trustPromptable(root)
canTrust := control.TrustableFolder(root)
if !canTrust {
fmt.Fprintf(out, "%s is a home directory or filesystem root; it is not trusted as a whole.\n", root)
}
+5 -32
View File
@@ -5,9 +5,6 @@ import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/contract/config"
@@ -41,7 +38,7 @@ func settleTUIPosture(ctrl postureHost, mode cliPermissionMode, named bool, home
ctrl.SetToolApprovalMode(mode.approval)
return nil
}
if ctrl.WritesConfined() && ctrl.WorkspaceTrust() == config.WorkspaceTrustUndecided && trustPromptable(ctrl.WorkspaceRoot()) {
if ctrl.WritesConfined() && ctrl.WorkspaceTrust() == config.WorkspaceTrustUndecided && control.TrustableFolder(ctrl.WorkspaceRoot()) {
askWorkspaceTrust(ctrl, in, out)
}
ctrl.SetToolApprovalMode(ctrl.DefaultApprovalMode())
@@ -61,7 +58,10 @@ func confirmYolo(home string, in *bufio.Scanner, out io.Writer) error {
}
// trustScope is what the sandbox confines a trusted folder's commands to.
const trustScope = "The OS sandbox limits their writes to this folder, temp and toolchain caches (~/go, ~/.cargo, ~/.cache and\nthe like); network access follows [sandbox] network, and deny rules still apply."
const trustScope = `The OS sandbox limits their writes to this folder, your allow_write and --add-dir directories,
temp, and toolchain caches (~/go, ~/.cargo, ~/.cache and the like) — and what lands in a
cache such as ~/.cargo/bin or ~/go/bin runs later outside the sandbox. Network access
follows [sandbox] network; deny rules still apply.`
// askWorkspaceTrust records the answer either way: a folder declined once is
// not asked about again, and `reasonix trust` changes it later.
@@ -76,30 +76,3 @@ func askWorkspaceTrust(ctrl postureHost, in *bufio.Scanner, out io.Writer) {
fmt.Fprintln(out, "warning: could not record the answer:", err)
}
}
// trustPromptable leaves out the folders nobody should grant wholesale: a
// home directory or a filesystem root holds far more than one project.
func trustPromptable(root string) bool {
root = filepath.Clean(strings.TrimSpace(root))
if root == "." || root == "" || filepath.Dir(root) == root {
return false
}
if home, err := os.UserHomeDir(); err == nil && sameDir(home, root) {
return false
}
return true
}
func sameDir(a, b string) bool {
if r, err := filepath.EvalSymlinks(a); err == nil {
a = r
}
if r, err := filepath.EvalSymlinks(b); err == nil {
b = r
}
a, b = filepath.Clean(a), filepath.Clean(b)
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return strings.EqualFold(a, b)
}
return a == b
}
+24 -7
View File
@@ -16,16 +16,17 @@ var argRules = map[string]func(sub string, args []string) bool{
"git": gitArgsWrite,
"go": goArgsWrite,
"gofmt": func(_ string, args []string) bool {
return hasAnyArg(args, "-w", "--w") || hasGoFlag(args, "cpuprofile")
return hasGoFlag(args, "w") || hasGoFlag(args, "cpuprofile")
},
"env": func(_ string, args []string) bool { return envRunsAProgram(args) },
"rg": func(_ string, args []string) bool {
return hasAnyArg(args, "--pre") || hasArgWithPrefix(args, "--pre=")
return hasLongOpt(args, "--pre", 5) || hasLongOpt(args, "--hostname-bin", 14)
},
"uniq": func(_ string, args []string) bool { return len(operands(args, "-f", "-s", "-w")) > 1 },
"file": func(_ string, args []string) bool { return hasShortFlag(args, 'C') || hasLongOpt(args, "--compile", 5) },
"date": func(_ string, args []string) bool {
return hasShortFlag(withoutValues(args, "-d", "--date", "-f", "--file", "-r", "--reference"), 's') || hasLongOpt(args, "--set", 3)
rest := withoutValues(args, "-d", "--date", "-f", "--file", "-r", "--reference")
return hasShortFlag(withoutPrefixed(rest, "-I"), 's') || hasLongOpt(args, "--set", 3)
},
"hostname": func(_ string, args []string) bool {
return len(operands(args)) > 0 || hasShortFlag(args, 'F') || hasLongOpt(args, "--file", 4)
@@ -43,17 +44,23 @@ func gitArgsWrite(sub string, args []string) bool {
case "grep":
return hasShortFlag(args, 'O') || hasLongOpt(args, "--open-files-in-pager", 4)
case "reflog":
return len(args) > 0 && (args[0] == "expire" || args[0] == "delete" || args[0] == "drop")
return len(args) > 0 && args[0] != "show" && args[0] != "exists" && !strings.HasPrefix(args[0], "-")
}
return false
}
// goArgsWrite: -toolexec, -exec and -vettool hand the build a program to run.
// goArgsWrite: -toolexec, -exec and -vettool hand the build a program to run,
// -mod may rewrite go.mod, and doc -http starts a server.
func goArgsWrite(sub string, args []string) bool {
if sub == "env" && hasAnyArg(args, "-w", "-u") {
if sub == "env" && (hasGoFlag(args, "w") || hasGoFlag(args, "u")) {
return true
}
return hasGoFlag(args, "toolexec") || hasGoFlag(args, "exec") || hasGoFlag(args, "vettool")
for _, flag := range []string{"toolexec", "exec", "vettool", "mod", "http"} {
if hasGoFlag(args, flag) {
return true
}
}
return false
}
// hasShortFlag reports a short option letter anywhere in a cluster such as
@@ -125,3 +132,13 @@ func withoutValues(args []string, valued ...string) []string {
}
return out
}
func withoutPrefixed(args []string, prefix string) []string {
var out []string
for _, arg := range args {
if !strings.HasPrefix(arg, prefix) {
out = append(out, arg)
}
}
return out
}
+7 -2
View File
@@ -20,7 +20,11 @@ func TestReadOnlyCommandsRefuseTheirEscapes(t *testing.T) {
"hostname evil", "hostname -F f",
"npm audit fix",
"kubectl get pods --kubeconfig=./k", "kubectl get pods --kubeconfig ./k",
"gofmt -cpuprofile out -l .",
"gofmt -cpuprofile out -l .", "gofmt -w=true a.go", "gofmt --w=true a.go",
"go env -w=true GOFLAGS=-toolexec=./x", "go env --w GOFLAGS=x", "go env -u=true GOFLAGS",
"rg --hostname-bin=./e --hyperlink-format=default x", "rg --hostname-bin ./e x",
"info -o out ls", "git reflog write x", "git reflog main",
"go list -mod=mod ./...", "go doc -http", "env -S'sh -c x'", "env --split-string=sh",
}
for _, c := range escapes {
if base, sub, fields, ok := ClassifyReadOnlyCommand(c); ok && !ArgsMakeReadOnlyCommandWrite(base, sub, fields) {
@@ -34,7 +38,8 @@ func TestReadOnlyCommandsRefuseTheirEscapes(t *testing.T) {
"file -b x", "file -m magic x", "date", "date -d '-2 days'", "date -u +%s",
"hostname", "hostname -f", "npm audit", "npm audit --json",
"kubectl get pods -n x", "go vet ./...", "go list -json ./...", "gofmt -l .",
"more README.md", "cargo search serde",
"more README.md", "cargo search serde", "date -Iseconds", "git reflog show", "git reflog --all",
"go env GOFLAGS", "env", "env -i",
}
for _, c := range reads {
base, sub, fields, ok := ClassifyReadOnlyCommand(c)
+3 -9
View File
@@ -55,15 +55,6 @@ func gitTagIsListing(args []string) bool {
return len(operands) == 0
}
func hasArgWithPrefix(args []string, prefix string) bool {
for _, arg := range args {
if strings.HasPrefix(arg, prefix) {
return true
}
}
return false
}
func hasAnyArg(args []string, unsafe ...string) bool {
for _, arg := range args {
if slices.Contains(unsafe, arg) {
@@ -79,6 +70,9 @@ func hasAnyArg(args []string, unsafe ...string) bool {
// and env can no more vouch for it than a shell can. Option arguments that take
// a separate value (-u VAR) read as that bare word and fail closed.
func envRunsAProgram(args []string) bool {
if hasShortFlag(args, 'S') || hasLongOpt(args, "--split-string", 3) {
return true
}
for _, arg := range args {
if strings.HasPrefix(arg, "-") || strings.Contains(arg, "=") {
continue
+6 -6
View File
@@ -12,11 +12,11 @@ import (
"reasonix/internal/base/shellparse"
)
// ReadOnlyCommands holds single-word commands whose base name alone implies a
// read-only operation. Ones read-only only for certain subcommands are in
// ReadOnlyPrefixes; ones a flag can turn into writers stay here and are
// rejected by ArgsMakeReadOnlyCommandWrite. Absent on purpose: `sed`/`awk`
// write through a script language nothing here parses; `less`/`man` run commands.
// ReadOnlyCommands holds commands whose base name alone implies a read; ones
// read-only only for some subcommands are in ReadOnlyPrefixes, and arguments
// that turn one into a writer are ArgsMakeReadOnlyCommandWrite's. Absent on
// purpose: sed/awk write through a script language nothing here parses,
// less/man run commands, info writes where it is told.
var ReadOnlyCommands = map[string]bool{
"cat": true, "head": true, "tail": true, "more": true,
"ls": true, "find": true, "locate": true, "which": true, "whereis": true, "type": true,
@@ -29,7 +29,7 @@ var ReadOnlyCommands = map[string]bool{
"stat": true, "file": true, "du": true, "df": true,
"ps": true, "top": true, "htop": true,
"diff": true, "cmp": true, "comm": true,
"info": true, "help": true,
"help": true,
"true": true, "false": true, "test": true, "[": true,
"basename": true, "dirname": true, "realpath": true, "readlink": true,
// PowerShell inspection cmdlets. Keep this list intentionally narrow: only
@@ -4,6 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/contract/config"
@@ -640,9 +643,40 @@ func DefaultApprovalMode(writesConfined bool, trust config.WorkspaceTrust) strin
// DefaultApprovalMode reads the trust record now, so a folder trusted after
// this controller was built counts.
func (c *Controller) DefaultApprovalMode() string {
if !TrustableFolder(c.WorkspaceRoot()) {
return ToolApprovalAsk
}
return DefaultApprovalMode(c.posture.WritesConfined, c.WorkspaceTrust())
}
// TrustableFolder leaves out the folders nobody should grant wholesale: a home
// directory or a filesystem root holds far more than one project, so a trust
// record for one, however it got there, opens nothing.
func TrustableFolder(root string) bool {
root = filepath.Clean(strings.TrimSpace(root))
if root == "." || root == "" || filepath.Dir(root) == root {
return false
}
if home, err := os.UserHomeDir(); err == nil && sameDir(home, root) {
return false
}
return true
}
func sameDir(a, b string) bool {
if r, err := filepath.EvalSymlinks(a); err == nil {
a = r
}
if r, err := filepath.EvalSymlinks(b); err == nil {
b = r
}
a, b = filepath.Clean(a), filepath.Clean(b)
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return strings.EqualFold(a, b)
}
return a == b
}
// WorkspaceTrust is the person's recorded decision about this workspace; an
// unreadable record is undecided, which costs a prompt and grants nothing.
func (c *Controller) WorkspaceTrust() config.WorkspaceTrust {
+21
View File
@@ -3,6 +3,8 @@ package control
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"reasonix/internal/contract/config"
@@ -157,3 +159,22 @@ func TestReadOnlyKeepsFreshHumanDecisionsHuman(t *testing.T) {
t.Fatalf("a fresh human decision passed a read-only gate: %+v", v)
}
}
// A trust record for a home directory, however it was written, opens nothing.
func TestHomeDirectoryTrustOpensNothing(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no user home:", err)
}
store := t.TempDir()
c := New(Options{WorkspaceRoot: home, Posture: PostureEvidence{WritesConfined: true, Home: store}})
if err := c.SetWorkspaceTrust(config.WorkspaceTrusted); err != nil {
t.Fatal(err)
}
if got := c.DefaultApprovalMode(); got != ToolApprovalAsk {
t.Fatalf("a trusted home directory opens in %q", got)
}
if TrustableFolder(string(filepath.Separator)) || TrustableFolder("") || !TrustableFolder(t.TempDir()) {
t.Fatal("TrustableFolder misjudges a root, an empty path or a project folder")
}
}