fix(permission): close the read-only escapes and keep the posture's sensitive code declared

Found in an adversarial review of the new default posture:

- shellsafe: arguments that make a read-only command run another program or
  write are refused in every spelling the program accepts (clustered short
  flags, abbreviated long options): git grep -O, rg --pre, sort
  --compress-program, go -toolexec/-exec/-vettool, file -C, uniq's output
  operand, date -s, hostname NAME, git reflog expire/delete, npm audit fix,
  kubectl --kubeconfig, gofmt -cpuprofile. less, man and cargo check/doc
  leave the read-only tables.
- The headless gate and the posture decision live in the files REASONIX.md
  declares sensitive again (approval_bridge.go, approval_orchestration.go);
  the MCP fast-path refusal is decided in package permission.
- A sub-agent under an attended Auto no longer gets dynamic shell its parent
  would have to ask about.
- Read-only keeps fresh human decisions human; a refusal no rule produced
  names the posture instead of a deny list.
- The trust prompt and docs state what the sandbox actually bounds (temp,
  toolchain caches, network) and that trust covers reasonix run; a home
  directory or filesystem root is never trusted wholesale; the run result
  names the posture it settled on.
This commit is contained in:
esengine committed 2026-09-29 00:30:16 -07:00
1 parent 980008de88
commit 527eee4b4c
23 files changed
+611 -305

No files matched your search

+16 -6
View File
@@ -232,14 +232,17 @@ The final structured object has this shape:
},
"permission_denials": [
{"tool_name": "write_file", "tool_use_id": "call_1", "code": "permission.unattended"}
]
],
"permission_mode": "ask"
}
```
`permission_denials` lists every call a permission gate refused; it is empty
when nothing was, and a refusal never changes the exit code. The same `code`
rides the refused tool result: `refusalCode` in `stream-json`, `refusal_code`
in `--events-jsonl`.
`permission_denials` lists the calls the run's permission gate refused; it is
empty when nothing was, and a refusal never changes the exit code. The same
`code` rides the refused tool result: `refusalCode` in `stream-json`,
`refusal_code` in `--events-jsonl`.
`permission_mode` is the posture the run settled on, named or defaulted.
| `code` | Cause |
| --- | --- |
@@ -370,7 +373,7 @@ reasonix --allowed-tools "Bash(go test ./...)" --allowed-tools read_file
| Mode | Behavior |
| --- | --- |
| `read-only` | Refuse every call that is not a read — file writes, shell commands not known to be reads, tools not declared read-only — whatever allow rules say. Nothing is asked. |
| `read-only` | Refuse every call that is not a read — file writes, shell commands not known to be reads, tools not declared read-only — whatever allow rules say. Nothing is asked. An installed extension's permission hook can still overrule it. |
| `manual`, `ask` | Ask for ordinary approval decisions. |
| `auto` | Automatically approve normal fallback operations while preserving explicit ask and deny rules. |
| `acceptEdits` | Allow file-editing tools; this is not full Auto mode. |
@@ -392,10 +395,17 @@ 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`.
- 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.
- `reasonix trust` trusts the current folder; `reasonix trust --revoke`
forgets it. A project's own files cannot record trust.
- Trust belongs to the folder's path, not its contents: whatever is checked out
there later is trusted too.
- Headless runs never ask. In `ask` they refuse writes and list them in
`permission_denials`.
+10 -3
View File
@@ -204,14 +204,17 @@ reasonix run "运行测试" --output-format stream-json
},
"permission_denials": [
{"tool_name": "write_file", "tool_use_id": "call_1", "code": "permission.unattended"}
]
],
"permission_mode": "ask"
}
```
`permission_denials` 列出被权限门拒绝的每个调用,没有被拒时为空数组;被拒不改变退出码。
`permission_denials` 列出本次运行的权限门拒绝的调用,没有被拒时为空数组;被拒不改变退出码。
同一个 `code` 也随被拒的工具结果出现:`stream-json` 里是 `refusalCode`,`--events-jsonl`
里是 `refusal_code`。
`permission_mode` 是本次运行实际采用的档位(指定的或默认的)。
| `code` | 原因 |
| --- | --- |
| `permission.unattended` | 需要批准,但无人可以批准。 |
@@ -327,7 +330,7 @@ reasonix --allowed-tools "Bash(go test ./...)" --allowed-tools read_file
| 模式 | 行为 |
| --- | --- |
| `read-only` | 拒绝一切非读取调用——写文件、不能确认是读取的 shell 命令、未声明只读的工具——allow 规则也放不过;不弹审批。 |
| `read-only` | 拒绝一切非读取调用——写文件、不能确认是读取的 shell 命令、未声明只读的工具——allow 规则也放不过;不弹审批。已安装扩展的权限钩子仍可推翻它。 |
| `manual`、`ask` | 普通权限决策会弹出审批。 |
| `auto` | 自动批准普通 fallback 操作,同时保留显式 ask 和 deny 规则。 |
| `acceptEdits` | 允许文件编辑工具;不等同于完整 Auto 模式。 |
@@ -348,8 +351,12 @@ reasonix --allowed-tools "Bash(go test ./...)" --allowed-tools read_file
否则以 `ask` 打开;Windows 没有 OS 沙盒,始终如此。
此时 `auto` 不经询问放行的范围由沙盒决定,不只是这个文件夹:shell 命令还能写临时目录和工具链缓存
(`~/go`、`~/.cargo`、`~/.cache` 等),并且能访问网络,除非设置 `[sandbox] network = false`。
- 终端界面对每个文件夹问一次是否信任(家目录和文件系统根目录从不问),答案记在你的 Reasonix 主目录。
- `reasonix trust` 信任当前文件夹,`reasonix trust --revoke` 撤销;项目自己的文件不能写入信任记录。
- 信任跟着文件夹路径走,不看内容:以后在同一路径检出的任何东西也被信任。
- 无头运行从不询问;`ask` 下写入被拒,并列在 `permission_denials` 中。
终端界面里 Shift+Tab 按 只读 → 询问 → 自动 → YOLO → 计划 循环(YOLO 确认过后才进入循环);
+1
View File
@@ -538,6 +538,7 @@ func runAgent(args []string, version string) int {
permissions.approval = ctrl.DefaultApprovalMode()
}
ctrl.ApplyHeadlessApprovalMode(permissions.approval)
resultOutput.SetPermissionMode(permissions.approval)
if err := bindRunSession(ctrl, leases, resumeSession, resumePath); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, control.SessionInUseMessage(err)+"; "+control.SessionLeaseCloseHint)
+24 -3
View File
@@ -54,6 +54,13 @@ type runResultUsage struct {
Estimated bool `json:"estimated,omitempty"`
}
// runPermissionRecord is what the result says about permissions: the posture
// the run settled on and every call its gate refused.
type runPermissionRecord struct {
mode string
denials []runPermissionDenial
}
type runPermissionDenial struct {
ToolName string `json:"tool_name"`
ToolUseID string `json:"tool_use_id"`
@@ -86,6 +93,8 @@ type runResult struct {
// PermissionDenials lists every call a permission gate refused, by the
// refusal's code; a run that wrote nothing says why here, not only in prose.
PermissionDenials []runPermissionDenial `json:"permission_denials"`
// PermissionMode is the posture the run settled on, named or defaulted.
PermissionMode string `json:"permission_mode,omitempty"`
}
type machineEventUsage struct {
@@ -164,7 +173,7 @@ type runOutputSink struct {
machineToolNames map[string]string
nextMachineToolID uint64
nextMachineToolName uint64
denials []runPermissionDenial
permissions runPermissionRecord
err error
}
@@ -239,7 +248,7 @@ func (s *runOutputSink) Emit(e event.Event) {
s.turns++
}
if e.Kind == event.ToolResult && permission.IsRefusalCode(e.Tool.RefusalCode) {
s.denials = append(s.denials, runPermissionDenial{ToolName: e.Tool.Name, ToolUseID: e.Tool.ID, Code: e.Tool.RefusalCode})
s.permissions.denials = append(s.permissions.denials, runPermissionDenial{ToolName: e.Tool.Name, ToolUseID: e.Tool.ID, Code: e.Tool.RefusalCode})
}
// stdout carries the answer alone, so a warning had nowhere to go and was
// dropped — a planner fallback, a folded user turn, an unread check. stderr
@@ -359,7 +368,8 @@ func (s *runOutputSink) Finalize(sessionID string, started time.Time, runErr err
OriginalTotals: s.originalTotals,
CostQuote: aggQuote,
Usage: s.usage,
PermissionDenials: append([]runPermissionDenial{}, s.denials...),
PermissionDenials: append([]runPermissionDenial{}, s.permissions.denials...),
PermissionMode: s.permissions.mode,
})
}
@@ -446,3 +456,14 @@ func machineEventKind(kind event.Kind) string {
}
return "unknown"
}
// SetPermissionMode records the posture the run settled on. A nil sink, which
// a text run without a result object has, takes nothing.
func (s *runOutputSink) SetPermissionMode(mode string) {
if s == nil {
return
}
s.mu.Lock()
s.permissions.mode = mode
s.mu.Unlock()
}
@@ -19,18 +19,20 @@ func TestRunResultListsPermissionDenials(t *testing.T) {
sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "c2", Name: "bash", Err: "exit 1"}})
sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "c3", Name: "edit_file", Err: "stale", RefusalCode: "tool.stale_anchor"}})
sink.Emit(event.Event{Kind: event.Message, Text: "could not write"})
sink.SetPermissionMode("ask")
if err := sink.Finalize("s", time.Now(), nil); err != nil {
t.Fatal(err)
}
var got struct {
IsError bool `json:"is_error"`
Mode string `json:"permission_mode"`
Denials []runPermissionDenial `json:"permission_denials"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("%v: %s", err, out.String())
}
want := []runPermissionDenial{{ToolName: "write_file", ToolUseID: "c1", Code: permission.RefusalUnattended}}
if got.IsError || len(got.Denials) != 1 || got.Denials[0] != want[0] {
if got.IsError || got.Mode != "ask" || len(got.Denials) != 1 || got.Denials[0] != want[0] {
t.Fatalf("result = %+v, want only the permission refusal listed and no error", got)
}
+13 -7
View File
@@ -53,12 +53,16 @@ func runTrust(args []string, in *bufio.Scanner, out io.Writer, interactive bool)
return 1
}
trust, _ := grants.Trust(root)
if len(pending) == 0 && trust == config.WorkspaceTrusted {
fmt.Fprintf(out, "%s is trusted and nothing in it is waiting for approval.\n", root)
canTrust := trustPromptable(root)
if !canTrust {
fmt.Fprintf(out, "%s is a home directory or filesystem root; it is not trusted as a whole.\n", root)
}
if len(pending) == 0 && (trust == config.WorkspaceTrusted || !canTrust) {
fmt.Fprintf(out, "Nothing in %s is waiting for approval.\n", root)
return 0
}
if trust != config.WorkspaceTrusted {
fmt.Fprintf(out, "Trusting %s lets edits inside it run without asking where the OS sandbox confines them.\n", root)
if trust != config.WorkspaceTrusted && canTrust {
fmt.Fprintf(out, "Trusting %s lets edits and shell commands in it run without asking, in the terminal UI and in `reasonix run`.\n%s\n", root, trustScope)
}
if len(pending) > 0 {
fmt.Fprintf(out, "%s names programs Reasonix would run on your machine:\n", root)
@@ -85,9 +89,11 @@ func runTrust(args []string, in *bufio.Scanner, out io.Writer, interactive bool)
return 1
}
}
if err := grants.SetTrust(root, config.WorkspaceTrusted); err != nil {
fmt.Fprintln(out, "trust:", err)
return 1
if canTrust {
if err := grants.SetTrust(root, config.WorkspaceTrusted); err != nil {
fmt.Fprintln(out, "trust:", err)
return 1
}
}
fmt.Fprintln(out, "Approved. Any change to a program it names needs approval again.")
return 0
+15
View File
@@ -90,3 +90,18 @@ func TestTrustRecordsTheFolderAndRevokeForgetsIt(t *testing.T) {
t.Fatalf("--yes = %d, trust %q", code, trust())
}
}
func TestTrustNeverTrustsAHomeDirectoryWholesale(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
home := homeDir(t)
var out bytes.Buffer
if code := runTrust([]string{"--dir", home, "--yes"}, bufio.NewScanner(strings.NewReader("")), &out, false); code != 0 {
t.Fatalf("trust in a home directory = %d: %s", code, out.String())
}
if got, _ := config.NewProjectGrantStore(config.Roots{}.Home()).Trust(home); got != config.WorkspaceTrustUndecided {
t.Fatalf("a home directory was recorded as %q", got)
}
if !strings.Contains(out.String(), "not trusted as a whole") {
t.Fatalf("the refusal is not said: %s", out.String())
}
}
+5 -2
View File
@@ -60,11 +60,14 @@ func confirmYolo(home string, in *bufio.Scanner, out io.Writer) error {
return nil
}
// 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."
// askWorkspaceTrust records the answer either way: a folder declined once is
// not asked about again, and `reasonix trust` changes it later.
func askWorkspaceTrust(ctrl postureHost, in *bufio.Scanner, out io.Writer) {
fmt.Fprintf(out, "Reasonix can edit files in %s without asking each time:\n", ctrl.WorkspaceRoot())
fmt.Fprintln(out, "the OS sandbox keeps shell writes inside this folder, and deny rules still apply.")
fmt.Fprintf(out, "In a trusted folder, edits and shell commands run without asking, here and in `reasonix run`:\n%s\n", ctrl.WorkspaceRoot())
fmt.Fprintln(out, trustScope)
trust := config.WorkspaceTrustDeclined
if strings.EqualFold(ask(in, out, "Trust this folder? (`reasonix trust --revoke` undoes it)", "y/N"), "y") {
trust = config.WorkspaceTrusted
+2 -9
View File
@@ -496,18 +496,11 @@ func (a *Agent) applyRecoveryAndPermission(ctx context.Context, plan *toolCallPl
errMsg: "blocked: MCP server identity is not authorized",
}, true
}
if outcome, blocked := readOnlyPostureBlock(ctx, a.svc.gate, plan.permName, gateArgs, plan.readOnly); blocked {
if outcome, blocked := mcpFastPathBlock(ctx, a.svc.gate, plan.permName, gateArgs, plan.readOnly); blocked {
return outcome, true
}
if denyGate, ok := a.svc.gate.(ExplicitDenyGate); ok && denyGate.ExplicitlyDenies(plan.permName, gateArgs) {
return toolOutcome{
output: "blocked: denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.",
blocked: true,
errMsg: "blocked by permission policy",
}, true
}
} else if a.svc.gate != nil {
v, err := gateVerdict(ctx, a.svc.gate, plan.permName, gateArgs, plan.readOnly)
v, err := permission.VerdictOf(ctx, a.svc.gate, plan.permName, gateArgs, plan.readOnly)
if err != nil {
return toolOutcome{
output: fmt.Sprintf("blocked: %s (%v)", v.Reason, err),
+15 -29
View File
@@ -7,36 +7,22 @@ import (
"reasonix/internal/safety/permission"
)
// verdictGate is a Gate that also names why it refused.
type verdictGate interface {
Verdict(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (permission.Verdict, error)
}
// writerDenyingGate is a Gate whose posture refuses every call that is not a
// read, which a path around the gate has to honour as well.
type writerDenyingGate interface {
DeniesWriters() bool
}
func gateVerdict(ctx context.Context, g Gate, toolName string, args json.RawMessage, readOnly bool) (permission.Verdict, error) {
if vg, ok := g.(verdictGate); ok {
return vg.Verdict(ctx, toolName, args, readOnly)
}
allow, reason, err := g.Check(ctx, toolName, args, readOnly)
return permission.Verdict{Allow: allow, Reason: reason}, err
}
// readOnlyPostureBlock refuses a writer that skips the ordinary gate — an
// authorized MCP server — while the posture is read-only. Authorizing a server
// answered whether it may be called, not whether a read-only session may write.
func readOnlyPostureBlock(ctx context.Context, g Gate, toolName string, args json.RawMessage, readOnly bool) (toolOutcome, bool) {
dg, ok := g.(writerDenyingGate)
if readOnly || !ok || !dg.DeniesWriters() {
// mcpFastPathBlock is what still applies to an authorized MCP server, which
// skips the ordinary gate: a read-only posture and an explicit deny rule.
func mcpFastPathBlock(ctx context.Context, g Gate, toolName string, args json.RawMessage, readOnly bool) (toolOutcome, bool) {
if g == nil {
return toolOutcome{}, false
}
v, err := gateVerdict(ctx, g, toolName, args, readOnly)
if err == nil && v.Allow {
return toolOutcome{}, false
if v, blocked := permission.PostureRefusal(ctx, g, toolName, args, readOnly); blocked {
return toolOutcome{output: "blocked: " + v.Reason, blocked: true, errMsg: "blocked by permission policy", refusalCode: v.Code}, true
}
return toolOutcome{output: "blocked: " + v.Reason, blocked: true, errMsg: "blocked by permission policy", refusalCode: v.Code}, true
if denyGate, ok := g.(ExplicitDenyGate); ok && denyGate.ExplicitlyDenies(toolName, args) {
return toolOutcome{
output: "blocked: denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.",
blocked: true,
errMsg: "blocked by permission policy",
refusalCode: permission.RefusalDenyRule,
}, true
}
return toolOutcome{}, false
}
+14 -19
View File
@@ -8,34 +8,29 @@ import (
"reasonix/internal/safety/permission"
)
type checkOnlyGate struct{}
func (checkOnlyGate) Check(context.Context, string, json.RawMessage, bool) (bool, string, error) {
return false, "no", nil
}
// An authorized MCP server skips the ordinary gate; a read-only posture still
// refuses its writers, and lets its readers and every other posture through.
func TestReadOnlyPostureBlocksWritersThatSkipTheGate(t *testing.T) {
// An authorized MCP server skips the ordinary gate; a read-only posture and a
// deny rule still stop it, each under its own code, and nothing else does.
func TestMCPFastPathKeepsReadOnlyAndDenyRules(t *testing.T) {
ctx := context.Background()
policy := permission.New("allow", []string{"mcp__srv__put"}, nil, nil)
policy.ReadOnly = true
readOnly := permission.NewGate(policy, nil)
out, blocked := readOnlyPostureBlock(ctx, readOnly, "mcp__srv__put", json.RawMessage(`{}`), false)
if !blocked || out.refusalCode != permission.RefusalReadOnly || !out.blocked {
out, blocked := mcpFastPathBlock(ctx, readOnly, "mcp__srv__put", json.RawMessage(`{}`), false)
if !blocked || out.refusalCode != permission.RefusalReadOnly {
t.Fatalf("a writer through the MCP fast path ran in read-only mode: %+v", out)
}
if _, blocked := readOnlyPostureBlock(ctx, readOnly, "mcp__srv__get", json.RawMessage(`{}`), true); blocked {
if _, blocked := mcpFastPathBlock(ctx, readOnly, "mcp__srv__get", json.RawMessage(`{}`), true); blocked {
t.Fatal("a declared MCP reader was refused")
}
open := permission.NewGate(permission.New("ask", nil, nil, nil), nil)
if _, blocked := readOnlyPostureBlock(ctx, open, "mcp__srv__put", json.RawMessage(`{}`), false); blocked {
t.Fatal("outside read-only the fast path is left to the deny list")
open := permission.NewGate(permission.New("ask", nil, nil, []string{"mcp__srv__drop"}), nil)
if _, blocked := mcpFastPathBlock(ctx, open, "mcp__srv__put", json.RawMessage(`{}`), false); blocked {
t.Fatal("outside read-only only the deny list applies to the fast path")
}
if _, blocked := readOnlyPostureBlock(ctx, checkOnlyGate{}, "mcp__srv__put", json.RawMessage(`{}`), false); blocked {
t.Fatal("a gate with no posture to report is not read-only")
out, blocked = mcpFastPathBlock(ctx, open, "mcp__srv__drop", json.RawMessage(`{}`), false)
if !blocked || out.refusalCode != permission.RefusalDenyRule {
t.Fatalf("a deny rule on the fast path carries no code: %+v", out)
}
if v, _ := gateVerdict(ctx, checkOnlyGate{}, "x", nil, false); v.Allow || v.Reason != "no" || v.Code != "" {
t.Fatalf("a Check-only gate keeps its answer and names no code: %+v", v)
if _, blocked := mcpFastPathBlock(ctx, nil, "mcp__srv__put", json.RawMessage(`{}`), false); blocked {
t.Fatal("no gate, nothing to refuse with")
}
}
+48 -5
View File
@@ -72,11 +72,7 @@ func (g *Gate) Verdict(ctx context.Context, toolName string, args json.RawMessag
}
switch decision {
case Deny:
reason := "denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain."
if ruleReason != "" {
reason = ruleReason + "\n" + reason
}
return Verdict{Reason: reason, Code: RefusalDenyRule}, nil
return g.denial(ruleReason), nil
case Ask:
return g.ask(ctx, toolName, args, ruleReason)
default:
@@ -117,3 +113,50 @@ func (g *Gate) ask(ctx context.Context, toolName string, args json.RawMessage, r
}
return Verdict{Allow: true}, nil
}
const postureRefusal = "this session's permission mode refuses anything that would need approval, and this call would. Nobody declined it and no deny rule matched; do the part that needs no approval, or report what was refused."
// denial names what refused a call: a matched deny rule, the read-only
// posture, or a mode that refuses whatever it would otherwise ask about.
func (g *Gate) denial(ruleReason string) Verdict {
switch {
case ruleReason != "":
return Verdict{Reason: ruleReason + "\ndenied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.", Code: RefusalDenyRule}
case g.Policy.ReadOnly:
return Verdict{Reason: readOnlyRefusal, Code: RefusalReadOnly}
default:
return Verdict{Reason: postureRefusal, Code: RefusalUnattended}
}
}
// Checker is any gate a tool call is put to.
type Checker interface {
Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error)
}
// VerdictOf asks g for its verdict, falling back to Check for a gate that
// names no refusal code.
func VerdictOf(ctx context.Context, g Checker, toolName string, args json.RawMessage, readOnly bool) (Verdict, error) {
if vg, ok := g.(interface {
Verdict(context.Context, string, json.RawMessage, bool) (Verdict, error)
}); ok {
return vg.Verdict(ctx, toolName, args, readOnly)
}
allow, reason, err := g.Check(ctx, toolName, args, readOnly)
return Verdict{Allow: allow, Reason: reason}, err
}
// PostureRefusal is g's refusal of a writer that skips the ordinary gate, such
// as an authorized MCP server, while g's posture is read-only. Authorizing the
// server answered whether it may be called, not whether this session writes.
func PostureRefusal(ctx context.Context, g Checker, toolName string, args json.RawMessage, readOnly bool) (Verdict, bool) {
dg, ok := g.(interface{ DeniesWriters() bool })
if readOnly || !ok || !dg.DeniesWriters() {
return Verdict{}, false
}
v, err := VerdictOf(ctx, g, toolName, args, readOnly)
if err == nil && v.Allow {
return Verdict{}, false
}
return v, true
}
@@ -3,6 +3,7 @@ package permission
import (
"context"
"encoding/json"
"strings"
"testing"
)
@@ -98,3 +99,31 @@ func TestVerdictNamesWhoRefused(t *testing.T) {
t.Fatalf("Check drops the verdict's reason: allow=%v reason=%q", allow, reason)
}
}
type checkOnly struct{}
func (checkOnly) Check(context.Context, string, json.RawMessage, bool) (bool, string, error) {
return false, "no", nil
}
// A refusal no rule produced names the posture that produced it, not a deny
// list nobody wrote.
func TestDenialWithoutARuleNamesThePosture(t *testing.T) {
ctx := context.Background()
dontAsk := NewGate(New("deny", nil, nil, nil), nil)
if v, _ := dontAsk.Verdict(ctx, "write_file", json.RawMessage(`{"path":"a"}`), false); v.Code != RefusalUnattended || strings.Contains(v.Reason, "deny list") {
t.Fatalf("a fallback refusal reads as a deny rule: %+v", v)
}
p := New("deny", nil, nil, nil)
p.ReadOnly = true
ro := NewGate(p, nil)
if v, _ := ro.Verdict(ctx, "bash", commandArgs("env"), false); v.Allow || v.Code != RefusalReadOnly {
t.Fatalf("a read the host cannot confirm is refused as read-only, got %+v", v)
}
if v, _ := VerdictOf(ctx, checkOnly{}, "x", nil, false); v.Allow || v.Reason != "no" || v.Code != "" {
t.Fatalf("a Check-only gate keeps its answer and names no code: %+v", v)
}
if _, refused := PostureRefusal(ctx, checkOnly{}, "x", nil, false); refused {
t.Fatal("a gate with no posture to report is not read-only")
}
}
+127
View File
@@ -0,0 +1,127 @@
package shellsafe
import "strings"
// argRules says, per read-only command, which arguments turn a call into one
// that writes or runs another program. An option named here in any spelling
// the program itself accepts — a clustered short flag, an abbreviated long
// one — is that option; the tables list programs, these list their escapes.
var argRules = map[string]func(sub string, args []string) bool{
"find": func(_ string, args []string) bool {
return hasAnyArg(args, "-exec", "-execdir", "-delete", "-ok", "-okdir", "-fls", "-fprint", "-fprint0", "-fprintf")
},
"sort": func(_ string, args []string) bool {
return hasShortFlag(args, 'o') || hasLongOpt(args, "--output", 3) || hasLongOpt(args, "--compress-program", 4)
},
"git": gitArgsWrite,
"go": goArgsWrite,
"gofmt": func(_ string, args []string) bool {
return hasAnyArg(args, "-w", "--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=")
},
"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)
},
"hostname": func(_ string, args []string) bool {
return len(operands(args)) > 0 || hasShortFlag(args, 'F') || hasLongOpt(args, "--file", 4)
},
"npm": func(sub string, args []string) bool { return sub == "audit" && len(args) > 0 && args[0] == "fix" },
"kubectl": func(_ string, args []string) bool { return hasGoFlag(args, "kubeconfig") },
}
func gitArgsWrite(sub string, args []string) bool {
switch sub {
case "diff", "show", "log", "whatchanged":
return hasLongOpt(args, "--output", 5)
case "tag":
return !gitTagIsListing(args)
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 false
}
// goArgsWrite: -toolexec, -exec and -vettool hand the build a program to run.
func goArgsWrite(sub string, args []string) bool {
if sub == "env" && hasAnyArg(args, "-w", "-u") {
return true
}
return hasGoFlag(args, "toolexec") || hasGoFlag(args, "exec") || hasGoFlag(args, "vettool")
}
// hasShortFlag reports a short option letter anywhere in a cluster such as
// `-no`; getopt reads the rest of the cluster as that option's value.
func hasShortFlag(args []string, letter byte) bool {
for _, arg := range args {
if arg == "--" {
return false
}
if len(arg) > 1 && arg[0] == '-' && arg[1] != '-' && strings.IndexByte(arg[1:], letter) >= 0 {
return true
}
}
return false
}
// hasLongOpt reports canonical or any abbreviation of it at least minLen
// long, the unambiguous prefixes getopt_long and git accept.
func hasLongOpt(args []string, canonical string, minLen int) bool {
for _, arg := range args {
if arg == "--" {
return false
}
name, _, _ := strings.Cut(arg, "=")
if strings.HasPrefix(name, "--") && len(name) >= minLen && strings.HasPrefix(canonical, name) {
return true
}
}
return false
}
// hasGoFlag reports a Go-style flag, one or two dashes, with or without =.
func hasGoFlag(args []string, name string) bool {
for _, arg := range args {
flag, _, _ := strings.Cut(strings.TrimLeft(arg, "-"), "=")
if strings.HasPrefix(arg, "-") && flag == name {
return true
}
}
return false
}
// operands are the arguments that are not options, skipping the value of
// each option in valued.
func operands(args []string, valued ...string) []string {
var out []string
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--":
return append(out, args[i+1:]...)
case hasAnyArg([]string{arg}, valued...):
i++
case !strings.HasPrefix(arg, "-") || arg == "-":
out = append(out, arg)
}
}
return out
}
func withoutValues(args []string, valued ...string) []string {
var out []string
for i := 0; i < len(args); i++ {
if hasAnyArg([]string{args[i]}, valued...) {
i++
continue
}
out = append(out, args[i])
}
return out
}
@@ -0,0 +1,45 @@
package shellsafe
import "testing"
// Each of these runs another program or writes, spelled the ways the program
// itself accepts; the reads beside them must stay reads.
func TestReadOnlyCommandsRefuseTheirEscapes(t *testing.T) {
escapes := []string{
"git grep -O'touch PWNED; true' hello", "git -C . grep -Osh x", "git grep -nO x",
"git grep --open-files-in-pager=sh x", "git grep --open-files x", "git grep --open x",
"git log --outp=f", "git show --output f", "git reflog expire --expire=now --all", "git reflog delete HEAD@{1}",
"rg --pre sh x", "rg --pre=./x y",
"man -P cat ls", "less +!sh x", "less -o log x",
"sort --compress-program=sh f", "sort --comp=sh f", "sort -no out f", "sort --outp=out f",
"go vet -vettool=./x ./...", "go list -toolexec=./x -export ./...", "go vet --toolexec ./x", "go list -exec ./x",
"cargo check", "cargo doc",
"uniq a b", "uniq -f 1 a b",
"file -C -m x", "file --compile -m x", "file -bC x",
"date -s 2020-01-01", "date --set=tomorrow", "date -us now",
"hostname evil", "hostname -F f",
"npm audit fix",
"kubectl get pods --kubeconfig=./k", "kubectl get pods --kubeconfig ./k",
"gofmt -cpuprofile out -l .",
}
for _, c := range escapes {
if base, sub, fields, ok := ClassifyReadOnlyCommand(c); ok && !ArgsMakeReadOnlyCommandWrite(base, sub, fields) {
t.Errorf("%q reads as a read", c)
}
}
reads := []string{
"git grep -n foo", "git grep -e x -- a.go", "git log --oneline", "git reflog", "git reflog show",
"rg --pre-glob '*.gz' x", "rg -n foo",
"sort -n f", "sort -r -k2 f", "uniq a", "uniq -c", "uniq -f 1 a",
"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",
}
for _, c := range reads {
base, sub, fields, ok := ClassifyReadOnlyCommand(c)
if !ok || ArgsMakeReadOnlyCommandWrite(base, sub, fields) {
t.Errorf("%q no longer reads as a read (classified %v)", c, ok)
}
}
}
+2 -19
View File
@@ -22,25 +22,8 @@ func ArgsMakeReadOnlyCommandWrite(base, sub string, fields []string) bool {
}
args = args[1:]
}
switch base {
case "find":
return hasAnyArg(args, "-exec", "-execdir", "-delete", "-ok", "-okdir", "-fls", "-fprint", "-fprint0", "-fprintf")
case "sort":
return hasArgWithPrefix(args, "-o") || hasAnyArg(args, "--output") || hasArgWithPrefix(args, "--output=")
case "git":
switch sub {
case "diff", "show", "log":
return hasAnyArg(args, "--output") || hasArgWithPrefix(args, "--output=")
case "tag":
return !gitTagIsListing(args)
}
case "go":
return sub == "env" && hasAnyArg(args, "-w", "-u")
case "gofmt":
// -w rewrites in place; -l, -d and the default all report to stdout.
return hasAnyArg(args, "-w", "--w")
case "env":
return envRunsAProgram(args)
if rule, ok := argRules[base]; ok {
return rule(sub, args)
}
return false
}
+5 -7
View File
@@ -15,10 +15,10 @@ import (
// 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. `sed`/`awk` are absent on purpose:
// they write through their own script language, which nothing here parses.
// rejected by ArgsMakeReadOnlyCommandWrite. Absent on purpose: `sed`/`awk`
// write through a script language nothing here parses; `less`/`man` run commands.
var ReadOnlyCommands = map[string]bool{
"cat": true, "head": true, "tail": true, "less": true, "more": true,
"cat": true, "head": true, "tail": true, "more": true,
"ls": true, "find": true, "locate": true, "which": true, "whereis": true, "type": true,
"grep": true, "egrep": true, "fgrep": true, "rg": true,
"echo": true, "printf": 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,
"man": true, "info": true, "help": true,
"info": 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
@@ -72,9 +72,7 @@ var ReadOnlyPrefixes = map[string]map[string]bool{
"ls": true, "list": true, "view": true, "info": true,
"outdated": true, "audit": true,
},
"cargo": {
"check": true, "doc": true, "search": true,
},
"cargo": {"search": true},
"docker": {
"ps": true, "images": true, "inspect": true, "logs": true,
"stats": true, "info": true, "version": true,
+1 -1
View File
@@ -15,7 +15,7 @@ func TestCommandIsReadOnly(t *testing.T) {
`grep 'a|b' file`, `printf "%s\n" "a && b"`,
// tooling probes.
"go version", "go env", "go list ./...", "go doc fmt",
"npm view react version", "npm outdated", "cargo check",
"npm view react version", "npm outdated", "cargo search serde",
"docker ps", "docker images", "kubectl get pods",
"node -v", "node --version", "python --version", "python3 --version",
// PowerShell permission-safe inspection commands.
+152
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"runtime"
"strings"
"sync"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/tool"
@@ -394,3 +395,154 @@ type approvalRequest struct {
// retains the ordinary four-choice UI and YOLO remains an explicit bypass.
requireHuman bool
}
// SharedHeadlessGate is the one gate every headless-only sub-agent surface
// (task, writer-capable skills, the planner) holds. They capture it once with
// no rebuild hook, so a posture switch reaches them only through Update here;
// otherwise they stay on whatever mode was active when they were built.
type SharedHeadlessGate struct {
mu sync.RWMutex
policy permission.Policy
gate *freshHumanHeadlessGate
}
// NewSharedHeadlessGate builds a shared gate holder from the base policy and
// the initial approval mode (see BuildHeadlessApprovalGate for the mode
// contract).
func NewSharedHeadlessGate(policy permission.Policy, mode string) *SharedHeadlessGate {
g := &SharedHeadlessGate{policy: policy}
g.Update(mode)
return g
}
// Update rebuilds the held gate for a new approval mode. Safe to call
// concurrently with Check (a turn may be mid-flight on another goroutine when
// the user switches modes).
func (g *SharedHeadlessGate) Update(mode string) {
g.set(BuildHeadlessApprovalGate(g.policy, mode))
}
// UpdateAttended is Update for a session a person is watching. A sub-agent
// cannot ask them, so a command the parent would put to them stays refused
// for the sub-agent too instead of Auto opening dynamic shell for it alone.
func (g *SharedHeadlessGate) UpdateAttended(mode string) {
next := BuildHeadlessApprovalGate(g.policy, mode)
if normalizeToolApprovalMode(mode) == ToolApprovalAuto {
next.gate.Policy.AllowDynamicBash = g.policy.AllowDynamicBash
}
g.set(next)
}
func (g *SharedHeadlessGate) set(next *freshHumanHeadlessGate) {
g.mu.Lock()
g.gate = next
g.mu.Unlock()
}
// RuleAllows reports whether a configured allow rule already covers this call.
// The fallback mode deliberately does not count: "auto approves writers" is a
// posture, while a matched rule is the user's own answer written down, and only
// the second one may stand in for asking again.
func (g *SharedHeadlessGate) RuleAllows(toolName string, args json.RawMessage, readOnly bool) bool {
// Neutralizing the writer fallback is what separates the two: with Mode set
// to Ask, an Allow can only have come from a rule that matched. Reading the
// rule list directly would have to re-implement bash's segment matching.
probe := g.policy
probe.Mode = permission.Ask
return probe.Decide(toolName, readOnly, args) == permission.Allow
}
func (g *SharedHeadlessGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
return g.current().Check(ctx, toolName, args, readOnly)
}
// Verdict is Check with the refusal's identity.
func (g *SharedHeadlessGate) Verdict(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (permission.Verdict, error) {
return g.current().Verdict(ctx, toolName, args, readOnly)
}
// DeniesWriters reports whether the current posture is read-only.
func (g *SharedHeadlessGate) DeniesWriters() bool { return g.current().DeniesWriters() }
func (g *SharedHeadlessGate) current() *freshHumanHeadlessGate {
g.mu.RLock()
defer g.mu.RUnlock()
return g.gate
}
func (g *SharedHeadlessGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
g.mu.RLock()
gate := g.gate
g.mu.RUnlock()
return gate.ExplicitlyDenies(toolName, args)
}
type freshHumanHeadlessGate struct {
gate *permission.Gate
dynamicBashBypass bool
allowLowRiskFreshAction func(toolName string, args json.RawMessage) bool
}
func (g *freshHumanHeadlessGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
v, err := g.Verdict(ctx, toolName, args, readOnly)
return v.Allow, v.Reason, err
}
// Verdict is Check with the refusal's identity. A read-only posture answers
// first, so no narrower refusal below it can stand in for the real cause.
func (g *freshHumanHeadlessGate) Verdict(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (permission.Verdict, error) {
if g.gate.DeniesWriters() {
if v, err := g.gate.Verdict(ctx, toolName, args, readOnly); err != nil || !v.Allow {
return v, err
}
}
if RequiresFreshHumanApprovalTool(toolName) {
if !g.gate.ExplicitlyDenies(toolName, args) && !g.gate.DeniesWriters() &&
g.allowLowRiskFreshAction != nil &&
g.allowLowRiskFreshAction(toolName, args) {
return permission.Verdict{Allow: true}, nil
}
return permission.Verdict{Reason: "this tool requires fresh human approval and cannot run in a non-interactive session. Use an interactive session or a user-initiated memory command.", Code: permission.RefusalUnattended}, nil
}
if strings.EqualFold(toolName, "bash") {
if blocker := permission.BashSubjectApprovalBlocker(permission.Subject(args)); blocker != permission.BashApprovalBlockerNone {
if g.gate.Policy.Decide(toolName, readOnly, args) != permission.Allow && !g.dynamicBashBypass {
return permission.Verdict{Reason: headlessBashBlockReason(blocker), Code: permission.RefusalUnattended}, nil
}
}
}
return g.gate.Verdict(ctx, toolName, args, readOnly)
}
// DeniesWriters reports a read-only posture.
func (g *freshHumanHeadlessGate) DeniesWriters() bool { return g.gate.DeniesWriters() }
func (g *freshHumanHeadlessGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
return g.gate.Policy.ExplicitlyDenies(toolName, args)
}
// Every blocked shape leads callers toward writing a script, so the writable
// boundary belongs in all of them: told only to use a file, models reach for
// /tmp and lose a second round to the sandbox.
const headlessBashBlockSuffix = " Scratch files belong under $TMPDIR, the session-private directory the host provides; a literal /tmp path is not writable, and anything else must be inside the workspace. The user can also switch to an interactive session or YOLO mode."
// headlessBashBlockReason names the shape that actually stopped the command.
// A caller told "inline interpreter code is blocked" about `env | grep` learns
// nothing and rewrites toward the wrong fix.
func headlessBashBlockReason(blocker permission.BashApprovalBlocker) string {
const lead = "this shell command requires human approval and cannot run in a non-interactive session. "
switch blocker {
case permission.BashApprovalBlockerInlineCode:
return lead + "It carries inline interpreter code (python -c, node -e, bash -c), which the host cannot audit; write the code to a file with write_file and run that file instead (e.g. write repro.py, then `python3 repro.py`)." + headlessBashBlockSuffix
case permission.BashApprovalBlockerNestedExecution:
return lead + "It nests another command through substitution ($(...), `...`, or <(...)), so what would actually run is not visible in the command; split it into separate calls and pass the values literally." + headlessBashBlockSuffix
case permission.BashApprovalBlockerDynamicName:
return lead + "The program it runs comes from a variable, so the host cannot tell what would execute; name the program literally." + headlessBashBlockSuffix
case permission.BashApprovalBlockerIndirectExecution:
return lead + "It runs through a wrapper (eval, source, xargs, env without a command, find -exec) that executes something the command itself does not name; call the target program directly." + headlessBashBlockSuffix
case permission.BashApprovalBlockerHereDocBody:
return lead + "It feeds a here-document, whose body is file content rather than arguments the host can read; write the file with write_file, then run whatever needs it." + headlessBashBlockSuffix
default:
return lead + "The host could not statically read what it would run; use a simpler literal command, or read_file/grep for inspection." + headlessBashBlockSuffix
}
}
@@ -6,6 +6,7 @@ import (
"fmt"
"strings"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/planmode"
"reasonix/internal/contract/provider"
@@ -462,7 +463,7 @@ func (c *Controller) ApplyToolApprovalMode(mode string) []string {
}
pending := c.approval.setMode(mode)
if c.subagentGate != nil {
c.subagentGate.Update(mode)
c.subagentGate.UpdateAttended(mode)
}
c.refreshInteractiveGate()
// Clear recovery cards dismissed by the mode switch outside the gate lock.
@@ -612,3 +613,48 @@ func (c *Controller) approvalRequestEvent(approval event.Approval) event.Event {
approval.Scope = c.approvalScope(approval.Tool)
return event.Event{Kind: event.ApprovalRequest, Approval: approval}
}
// ToolApprovalReadOnly refuses every call that is not a read, whatever the
// rules allow; no approval can talk a write past it.
const ToolApprovalReadOnly = "readOnly"
// PostureEvidence is what a terminal session's default posture is decided
// from. WritesConfined is the bash sandbox's Integrity claim for this build,
// as its backend reports it; Home is where the person recorded folder trust.
type PostureEvidence struct {
WritesConfined bool
Home string
}
// DefaultApprovalMode is the posture a session opens in when nobody named one:
// writes inside the workspace run without asking only where an OS sandbox
// confines them and the person trusted this folder. Everywhere else — no
// backend, bash off, Windows, an undecided or declined folder — it asks.
func DefaultApprovalMode(writesConfined bool, trust config.WorkspaceTrust) string {
if writesConfined && trust == config.WorkspaceTrusted {
return ToolApprovalAuto
}
return ToolApprovalAsk
}
// DefaultApprovalMode reads the trust record now, so a folder trusted after
// this controller was built counts.
func (c *Controller) DefaultApprovalMode() string {
return DefaultApprovalMode(c.posture.WritesConfined, c.WorkspaceTrust())
}
// 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 {
trust, _ := config.NewProjectGrantStore(c.posture.Home).Trust(c.WorkspaceRoot())
return trust
}
// WritesConfined reports this build's bash Integrity claim.
func (c *Controller) WritesConfined() bool { return c.posture.WritesConfined }
// SetWorkspaceTrust records the person's decision about this workspace under
// the same home DefaultApprovalMode reads it from.
func (c *Controller) SetWorkspaceTrust(trust config.WorkspaceTrust) error {
return config.NewProjectGrantStore(c.posture.Home).SetTrust(c.WorkspaceRoot(), trust)
}
-145
View File
@@ -1,145 +0,0 @@
package control
import (
"context"
"encoding/json"
"strings"
"sync"
"reasonix/internal/safety/permission"
)
// SharedHeadlessGate is the one gate every headless-only sub-agent surface
// (task, writer-capable skills, the planner) holds. They capture it once with
// no rebuild hook, so a posture switch reaches them only through Update here;
// otherwise they stay on whatever mode was active when they were built.
type SharedHeadlessGate struct {
mu sync.RWMutex
policy permission.Policy
gate *freshHumanHeadlessGate
}
// NewSharedHeadlessGate builds a shared gate holder from the base policy and
// the initial approval mode (see BuildHeadlessApprovalGate for the mode
// contract).
func NewSharedHeadlessGate(policy permission.Policy, mode string) *SharedHeadlessGate {
g := &SharedHeadlessGate{policy: policy}
g.Update(mode)
return g
}
// Update rebuilds the held gate for a new approval mode. Safe to call
// concurrently with Check (a turn may be mid-flight on another goroutine when
// the user switches modes).
func (g *SharedHeadlessGate) Update(mode string) {
next := BuildHeadlessApprovalGate(g.policy, mode)
g.mu.Lock()
g.gate = next
g.mu.Unlock()
}
// RuleAllows reports whether a configured allow rule already covers this call.
// The fallback mode deliberately does not count: "auto approves writers" is a
// posture, while a matched rule is the user's own answer written down, and only
// the second one may stand in for asking again.
func (g *SharedHeadlessGate) RuleAllows(toolName string, args json.RawMessage, readOnly bool) bool {
// Neutralizing the writer fallback is what separates the two: with Mode set
// to Ask, an Allow can only have come from a rule that matched. Reading the
// rule list directly would have to re-implement bash's segment matching.
probe := g.policy
probe.Mode = permission.Ask
return probe.Decide(toolName, readOnly, args) == permission.Allow
}
func (g *SharedHeadlessGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
return g.current().Check(ctx, toolName, args, readOnly)
}
// Verdict is Check with the refusal's identity.
func (g *SharedHeadlessGate) Verdict(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (permission.Verdict, error) {
return g.current().Verdict(ctx, toolName, args, readOnly)
}
// DeniesWriters reports whether the current posture is read-only.
func (g *SharedHeadlessGate) DeniesWriters() bool { return g.current().DeniesWriters() }
func (g *SharedHeadlessGate) current() *freshHumanHeadlessGate {
g.mu.RLock()
defer g.mu.RUnlock()
return g.gate
}
func (g *SharedHeadlessGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
g.mu.RLock()
gate := g.gate
g.mu.RUnlock()
return gate.ExplicitlyDenies(toolName, args)
}
type freshHumanHeadlessGate struct {
gate *permission.Gate
dynamicBashBypass bool
allowLowRiskFreshAction func(toolName string, args json.RawMessage) bool
}
func (g *freshHumanHeadlessGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
v, err := g.Verdict(ctx, toolName, args, readOnly)
return v.Allow, v.Reason, err
}
// Verdict is Check with the refusal's identity. A read-only posture answers
// first, so no narrower refusal below it can stand in for the real cause.
func (g *freshHumanHeadlessGate) Verdict(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (permission.Verdict, error) {
if g.gate.DeniesWriters() {
return g.gate.Verdict(ctx, toolName, args, readOnly)
}
if RequiresFreshHumanApprovalTool(toolName) {
if !g.gate.ExplicitlyDenies(toolName, args) &&
g.allowLowRiskFreshAction != nil &&
g.allowLowRiskFreshAction(toolName, args) {
return permission.Verdict{Allow: true}, nil
}
return permission.Verdict{Reason: "this tool requires fresh human approval and cannot run in a non-interactive session. Use an interactive session or a user-initiated memory command.", Code: permission.RefusalUnattended}, nil
}
if strings.EqualFold(toolName, "bash") {
if blocker := permission.BashSubjectApprovalBlocker(permission.Subject(args)); blocker != permission.BashApprovalBlockerNone {
if g.gate.Policy.Decide(toolName, readOnly, args) != permission.Allow && !g.dynamicBashBypass {
return permission.Verdict{Reason: headlessBashBlockReason(blocker), Code: permission.RefusalUnattended}, nil
}
}
}
return g.gate.Verdict(ctx, toolName, args, readOnly)
}
// DeniesWriters reports a read-only posture.
func (g *freshHumanHeadlessGate) DeniesWriters() bool { return g.gate.DeniesWriters() }
func (g *freshHumanHeadlessGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
return g.gate.Policy.ExplicitlyDenies(toolName, args)
}
// Every blocked shape leads callers toward writing a script, so the writable
// boundary belongs in all of them: told only to use a file, models reach for
// /tmp and lose a second round to the sandbox.
const headlessBashBlockSuffix = " Scratch files belong under $TMPDIR, the session-private directory the host provides; a literal /tmp path is not writable, and anything else must be inside the workspace. The user can also switch to an interactive session or YOLO mode."
// headlessBashBlockReason names the shape that actually stopped the command.
// A caller told "inline interpreter code is blocked" about `env | grep` learns
// nothing and rewrites toward the wrong fix.
func headlessBashBlockReason(blocker permission.BashApprovalBlocker) string {
const lead = "this shell command requires human approval and cannot run in a non-interactive session. "
switch blocker {
case permission.BashApprovalBlockerInlineCode:
return lead + "It carries inline interpreter code (python -c, node -e, bash -c), which the host cannot audit; write the code to a file with write_file and run that file instead (e.g. write repro.py, then `python3 repro.py`)." + headlessBashBlockSuffix
case permission.BashApprovalBlockerNestedExecution:
return lead + "It nests another command through substitution ($(...), `...`, or <(...)), so what would actually run is not visible in the command; split it into separate calls and pass the values literally." + headlessBashBlockSuffix
case permission.BashApprovalBlockerDynamicName:
return lead + "The program it runs comes from a variable, so the host cannot tell what would execute; name the program literally." + headlessBashBlockSuffix
case permission.BashApprovalBlockerIndirectExecution:
return lead + "It runs through a wrapper (eval, source, xargs, env without a command, find -exec) that executes something the command itself does not name; call the target program directly." + headlessBashBlockSuffix
case permission.BashApprovalBlockerHereDocBody:
return lead + "It feeds a here-document, whose body is file content rather than arguments the host can read; write the file with write_file, then run whatever needs it." + headlessBashBlockSuffix
default:
return lead + "The host could not statically read what it would run; use a simpler literal command, or read_file/grep for inspection." + headlessBashBlockSuffix
}
}
-48
View File
@@ -1,48 +0,0 @@
package control
import "reasonix/internal/contract/config"
// ToolApprovalReadOnly refuses every call that is not a read, whatever the
// rules allow; no approval can talk a write past it.
const ToolApprovalReadOnly = "readOnly"
// PostureEvidence is what a terminal session's default posture is decided
// from. WritesConfined is the bash sandbox's Integrity claim for this build,
// as its backend reports it; Home is where the person recorded folder trust.
type PostureEvidence struct {
WritesConfined bool
Home string
}
// DefaultApprovalMode is the posture a session opens in when nobody named one:
// writes inside the workspace run without asking only where an OS sandbox
// confines them and the person trusted this folder. Everywhere else — no
// backend, bash off, Windows, an undecided or declined folder — it asks.
func DefaultApprovalMode(writesConfined bool, trust config.WorkspaceTrust) string {
if writesConfined && trust == config.WorkspaceTrusted {
return ToolApprovalAuto
}
return ToolApprovalAsk
}
// DefaultApprovalMode reads the trust record now, so a folder trusted after
// this controller was built counts.
func (c *Controller) DefaultApprovalMode() string {
return DefaultApprovalMode(c.posture.WritesConfined, c.WorkspaceTrust())
}
// 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 {
trust, _ := config.NewProjectGrantStore(c.posture.Home).Trust(c.WorkspaceRoot())
return trust
}
// WritesConfined reports this build's bash Integrity claim.
func (c *Controller) WritesConfined() bool { return c.posture.WritesConfined }
// SetWorkspaceTrust records the person's decision about this workspace under
// the same home DefaultApprovalMode reads it from.
func (c *Controller) SetWorkspaceTrust(trust config.WorkspaceTrust) error {
return config.NewProjectGrantStore(c.posture.Home).SetTrust(c.WorkspaceRoot(), trust)
}
+37
View File
@@ -120,3 +120,40 @@ func TestHeadlessRefusalsNameNobodyAsTheCause(t *testing.T) {
t.Fatalf("a fresh human decision with nobody there is %+v", v)
}
}
// Under an attended Auto a sub-agent gets no more than its parent: a shell
// shape the parent would put to the person stays refused, since the sub-agent
// cannot ask. A headless Auto, which nobody watches, keeps opening it.
func TestAttendedAutoKeepsSubagentsOffDynamicShell(t *testing.T) {
ctx := context.Background()
inline := json.RawMessage(`{"command":"python3 -c 'print(1)'"}`)
gate := NewSharedHeadlessGate(permission.New("ask", nil, nil, nil), ToolApprovalAsk)
gate.UpdateAttended(ToolApprovalAuto)
if v, _ := gate.Verdict(ctx, "bash", inline, false); v.Allow {
t.Fatal("an attended Auto let a sub-agent run inline interpreter code its parent would ask about")
}
if v, _ := gate.Verdict(ctx, "write_file", json.RawMessage(`{"path":"a"}`), false); !v.Allow {
t.Fatalf("an attended Auto still lets a sub-agent write: %+v", v)
}
gate.Update(ToolApprovalAuto)
if v, _ := gate.Verdict(ctx, "bash", inline, false); !v.Allow {
t.Fatalf("a headless Auto opens dynamic shell as before: %+v", v)
}
configured := permission.New("ask", nil, nil, nil).WithAllowDynamicBashFallback(true)
opted := NewSharedHeadlessGate(configured, ToolApprovalAsk)
opted.UpdateAttended(ToolApprovalAuto)
if v, _ := opted.Verdict(ctx, "bash", inline, false); !v.Allow {
t.Fatalf("a configured allow_dynamic_bash still applies to sub-agents: %+v", v)
}
}
// A decision only a person may make stays theirs in read-only mode, even for a
// tool that calls itself a reader.
func TestReadOnlyKeepsFreshHumanDecisionsHuman(t *testing.T) {
gate := BuildHeadlessApprovalGate(permission.New("ask", nil, nil, nil), ToolApprovalReadOnly)
gate.allowLowRiskFreshAction = func(string, json.RawMessage) bool { return true }
v, _ := gate.Verdict(context.Background(), SandboxEscapeApprovalTool, json.RawMessage(`{}`), true)
if v.Allow || v.Code != permission.RefusalUnattended {
t.Fatalf("a fresh human decision passed a read-only gate: %+v", v)
}
}