mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(permission): refuse the remaining read-only escapes and trust no home directory
This commit is contained in:
1 parent
527eee4b4c
commit
a548df7f55
10 files changed
+114
-62
No files matched your search
+7
-3
@@ -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
@@ -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` 撤销;项目自己的文件不能写入信任记录。
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user