项目文件夹

文件
2026-07-13 13:00:08 +08:00

669 行
21 KiB
Go

// Package permission decides, per tool call, whether to allow it, deny it, or
// ask the user first. The core is a pure Policy (rule evaluation, no I/O); a
// Gate wraps a Policy with an optional interactive Approver and is what the
// agent consults at execute time. Keeping rule evaluation pure makes it
// trivially testable and keeps the agent independent of how "ask" is resolved.
package permission
import (
"context"
"encoding/json"
"strings"
"reasonix/internal/shellparse"
)
// Decision is the outcome of evaluating a tool call against a Policy.
type Decision int
const (
// Allow runs the tool without prompting.
Allow Decision = iota
// Ask defers to an interactive Approver (or, with none, resolves to Allow).
Ask
// Deny blocks the tool in every mode.
Deny
)
func (d Decision) String() string {
switch d {
case Allow:
return "allow"
case Ask:
return "ask"
case Deny:
return "deny"
default:
return "unknown"
}
}
// ParseDecision maps a config string to a Decision. Unknown / empty input
// defaults to Ask — the conservative posture for a writer fallback.
func ParseDecision(s string) Decision {
switch strings.ToLower(strings.TrimSpace(s)) {
case "allow":
return Allow
case "deny":
return Deny
default:
return Ask
}
}
// Rule matches tool calls. Tool is the tool name; Subject, when non-empty,
// constrains the call's subject. A glob Subject (see matchGlob) matches by
// wildcard; a Literal Subject matches by exact string equality. An empty Subject
// matches every call to Tool.
type Rule struct {
Tool string
Subject string
// Literal matches Subject by exact equality rather than as a glob, so a
// remembered concrete command keeps any '*'/'?' as ordinary characters
// instead of turning them into wildcards.
Literal bool
}
// ParseRule parses "ToolName", "ToolName(glob)", or the legacy
// "ToolName=literal" form. Surrounding whitespace is trimmed. The "=literal"
// form (taken when the '=' precedes any '(') matches the rest of the string
// verbatim — no globbing — and is kept for existing configs that were written
// before the Claude Code-style Tool(specifier) approval rules. ok is false for
// a malformed entry (empty tool name) so the caller can warn rather than
// silently install a rule that matches nothing.
func ParseRule(s string) (Rule, bool) {
s = strings.TrimSpace(s)
if s == "" {
return Rule{}, false
}
if eq := strings.IndexByte(s, '='); eq > 0 {
if paren := strings.IndexByte(s, '('); paren < 0 || eq < paren {
tool := strings.TrimSpace(s[:eq])
if tool == "" {
return Rule{}, false
}
return Rule{Tool: tool, Subject: s[eq+1:], Literal: true}, true
}
}
if i := strings.IndexByte(s, '('); i >= 0 && strings.HasSuffix(s, ")") {
tool := strings.TrimSpace(s[:i])
if tool == "" {
return Rule{}, false
}
return Rule{Tool: tool, Subject: s[i+1 : len(s)-1]}, true
}
return Rule{Tool: s}, true
}
func parseRules(ss []string) []Rule {
var out []Rule
for _, s := range ss {
if r, ok := ParseRule(s); ok {
out = append(out, r)
}
}
return out
}
// Policy is a set of rules plus the writer fallback mode. It is the pure,
// I/O-free heart of the permission layer.
type Policy struct {
// Mode is the fallback decision for writer tools when no rule matches.
// Read-only tools always fall back to Allow.
Mode Decision
Allow []Rule
Ask []Rule
Deny []Rule
}
// New builds a Policy from config string slices and a mode string ("ask" by
// default). Malformed rule strings are dropped.
func New(mode string, allow, ask, deny []string) Policy {
return Policy{
Mode: ParseDecision(mode),
Allow: parseRules(allow),
Ask: parseRules(ask),
Deny: parseRules(deny),
}
}
// Decide evaluates a tool call. readOnly is the tool's own classification; args
// is the raw JSON the model sent, from which the call's subject is extracted
// for glob matching. Calls with multiple subjects, such as move_file's source
// and destination paths, must be safe for every subject before the call is
// allowed. Precedence: deny > ask > allow > fallback (Allow for readers, Mode
// for writers).
func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision {
return p.DecideSubjects(toolName, readOnly, Subjects(args))
}
// DecideSubject evaluates a tool call when the caller already extracted the
// stable approval subject from args.
func (p Policy) DecideSubject(toolName string, readOnly bool, subject string) Decision {
if canonicalRuleTool(toolName) == "bash" {
switch {
case matchAny(p.Deny, toolName, subject):
return Deny
case matchAny(p.Ask, toolName, subject):
return Ask
case matchAny(p.Allow, toolName, subject):
return Allow
}
if parts := DecomposeBashCommand(subject); parts != nil {
return p.decideBashSegments(readOnly, parts)
}
}
switch {
case matchAny(p.Deny, toolName, subject):
return Deny
case matchAny(p.Ask, toolName, subject):
return Ask
case matchAny(p.Allow, toolName, subject):
return Allow
case readOnly:
return Allow
default:
return p.Mode
}
}
// decideBashSegments evaluates each simple-command segment of a compound bash
// invocation against the rule table independently. This lets prefix rules like
// `Bash(git push:*)` — created by the existing auto-save path for atomic
// commands — cover common compound flows (`git add . && git commit && git
// push`) without ever synthesizing a new prefix from a compound command.
//
// Precedence stays deny > ask > allow > fallback. Any single segment hitting
// deny denies the whole call; any segment needing approval turns the whole
// call into Ask; the whole call is Allow only if every segment is covered or
// writer fallback allows uncovered segments.
// A segment recognized as read-only by shellsafe (echo/ls/git status/...) is
// allowed on its own without a rule, matching the behavior of an atomic
// read-only bash call.
func (p Policy) decideBashSegments(readOnly bool, parts []string) Decision {
out := Allow
for _, sub := range parts {
segReadOnly := readOnly
if !segReadOnly {
if isReadOnlyBashSubject(sub) {
segReadOnly = true
}
}
switch {
case matchAny(p.Deny, "bash", sub):
return Deny
case matchAny(p.Ask, "bash", sub):
out = Ask
case matchAny(p.Allow, "bash", sub):
// covered
case segReadOnly:
// covered
default:
// Segment not covered by a rule or read-only classification: apply
// the same writer fallback used for atomic bash commands.
switch p.Mode {
case Deny:
return Deny
case Ask:
out = Ask
default:
// covered by fallback allow
}
}
}
return out
}
// DecideSubjects evaluates a tool call against every subject the call touches.
// This keeps two-path operations honest: a move is denied if either endpoint is
// denied, asks if either endpoint requires approval, and is allowed only when
// every endpoint is allowed under the same policy.
func (p Policy) DecideSubjects(toolName string, readOnly bool, subjects []string) Decision {
if len(subjects) == 0 {
return p.DecideSubject(toolName, readOnly, "")
}
out := Allow
for _, subject := range subjects {
switch p.DecideSubject(toolName, readOnly, subject) {
case Deny:
return Deny
case Ask:
out = Ask
}
}
return out
}
// matchAny reports whether any rule matches the (toolName, subject) pair. A
// subject-specific rule cannot match a call that exposes no subject.
func matchAny(rules []Rule, toolName, subject string) bool {
for _, r := range rules {
if !ruleToolMatches(r.Tool, toolName) {
continue
}
if r.Subject == "" {
return true
}
if subject == "" {
continue
}
if ruleSubjectMatches(r, subject) {
return true
}
}
return false
}
// RuleMatchesString reports whether one config-style rule string matches the
// given tool subject. It is used for session grants as well as persisted config
// rules so both paths share identical matching semantics.
func RuleMatchesString(rule, toolName, subject string) bool {
r, ok := ParseRule(rule)
return ok && matchAny([]Rule{r}, toolName, subject)
}
// RuleCoversString reports whether every call represented by candidate is
// already covered by existing. It intentionally proves only the cases Reasonix
// creates automatically: exact rules covered by broader globs or bare tool
// rules, exact duplicate globs, and bare tool rules covering subject rules.
func RuleCoversString(existing, candidate string) bool {
a, ok := ParseRule(existing)
if !ok {
return false
}
b, ok := ParseRule(candidate)
if !ok {
return false
}
if !ruleToolCompatible(a.Tool, b.Tool) {
return false
}
if a.Subject == "" {
return true
}
if b.Subject == "" {
return false
}
if bashRulePrefixBaseMatches(a, b) {
return true
}
if b.Literal || !hasGlobMeta(b.Subject) {
return ruleSubjectMatches(a, b.Subject)
}
return !a.Literal && a.Subject == b.Subject
}
func hasGlobMeta(s string) bool {
return strings.ContainsAny(s, "*?")
}
func bashRulePrefixBaseMatches(existing, candidate Rule) bool {
if canonicalRuleTool(existing.Tool) != "bash" || canonicalRuleTool(candidate.Tool) != "bash" {
return false
}
existingBase, ok := bashPrefixBase(existing.Subject)
if !ok {
return false
}
candidateBase, ok := bashPrefixBase(candidate.Subject)
return ok && existingBase == candidateBase
}
// subjectKeys are the JSON argument keys, in priority order, that carry a tool
// call's "subject" — the thing a Subject glob matches against. Generic so tools
// need not implement a permission-specific method: bash exposes command, the
// file tools expose path / file_path, grep & glob expose pattern.
var subjectKeys = []string{"command", "file_path", "path", "source_path", "destination_path", "pattern"}
// Subject extracts the primary matchable subject string from a call's raw JSON
// args, returning "" when none of the known keys is present (such a call only
// matches bare "ToolName" rules). Use Subjects for permission decisions that
// must account for every touched endpoint.
func Subject(args json.RawMessage) string {
subjects := Subjects(args)
if len(subjects) > 0 {
return subjects[0]
}
return ""
}
// Subjects extracts every matchable subject from a call's raw JSON args. Most
// tools expose one subject; move_file exposes both source_path and
// destination_path so path-scoped permission rules can protect either endpoint.
func Subjects(args json.RawMessage) []string {
if len(args) == 0 {
return nil
}
var m map[string]any
if err := json.Unmarshal(args, &m); err != nil {
return nil
}
src := stringArg(m, "source_path")
dst := stringArg(m, "destination_path")
if src != "" && dst != "" {
out := []string{src}
if dst != src {
out = append(out, dst)
}
return out
}
for _, k := range subjectKeys {
if s := stringArg(m, k); s != "" {
return []string{s}
}
}
return nil
}
func stringArg(m map[string]any, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
return ""
}
// matchGlob reports whether name matches pattern, where '*' matches any run of
// characters (including separators) and '?' matches exactly one. Unlike
// path.Match, '*' is not stopped by '/', which is what command-line and path
// prefixes ("rm -rf*", "/etc/*") intuitively expect. Linear time with
// backtracking, byte-oriented.
func matchGlob(pattern, name string) bool {
var px, nx, starPx, starNx int
starPx = -1
for nx < len(name) {
switch {
case px < len(pattern) && (pattern[px] == '?' || pattern[px] == name[nx]):
px++
nx++
case px < len(pattern) && pattern[px] == '*':
starPx = px
starNx = nx
px++
case starPx != -1:
px = starPx + 1
starNx++
nx = starNx
default:
return false
}
}
for px < len(pattern) && pattern[px] == '*' {
px++
}
return px == len(pattern)
}
// Approver resolves an Ask decision interactively. Implementations live in the
// front-end (the chat TUI); a non-interactive run passes a nil Approver, which
// the Gate treats as "allow" to preserve autonomous behaviour.
type Approver interface {
// Approve asks the user about a pending call. It returns whether to allow
// it and whether to remember that choice as a new rule. A non-nil err (e.g.
// the context was cancelled while waiting) aborts the turn.
Approve(ctx context.Context, toolName, subject string, args json.RawMessage) (allow, remember bool, err error)
}
// ReasonedApprover is the optional extension used by frontends that can return
// a denial reason to feed back to the model.
type ReasonedApprover interface {
ApproveWithReason(ctx context.Context, toolName, subject string, args json.RawMessage) (allow, remember bool, reason string, err error)
}
// Gate is what the agent consults at execute time: a Policy plus an optional
// Approver. It satisfies the agent's Gate interface structurally.
type Gate struct {
Policy Policy
Approver Approver
// OnRemember, when set, is invoked with a new allow rule the user chose to
// remember (e.g. "Bash(go build)"), so the front-end can persist it.
OnRemember func(rule string)
}
// NewGate wires a Policy to an Approver (nil for non-interactive use).
func NewGate(p Policy, a Approver) *Gate { return &Gate{Policy: p, Approver: a} }
// Check decides whether a tool call may run. It is the method the agent's Gate
// interface expects. A denied or refused call returns allow=false with a short
// reason the agent feeds back to the model.
func (g *Gate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
if toolName == "bash" && !readOnly {
subject := Subject(args)
if isReadOnlyBashSubject(subject) {
readOnly = true
}
}
switch g.Policy.Decide(toolName, readOnly, args) {
case Deny:
return false, "denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.", nil
case Ask:
if g.Approver == nil {
return true, "", nil // non-interactive: preserve autonomy
}
subject := Subject(args)
allow, remember, approverReason, err := g.approve(ctx, toolName, subject, args)
if err != nil {
return false, "approval aborted", err
}
if !allow {
reason := "the user declined this tool call — do not retry it; ask how they would like to proceed or choose another approach."
if approverReason != "" {
reason = approverReason
}
return false, reason, nil
}
if remember && g.OnRemember != nil {
// "Always allow" is tool-wide: persist the bare tool name so any
// later subject (a different file / command) is allowed without
// re-prompting. Deny rules still take precedence on every call.
g.OnRemember(toolName)
// Also add the rule to the in-memory Policy immediately so it
// takes effect in the current session without requiring a restart.
// The session-level grant (controller.granted) already covers the
// Approver path, but any code path that consults Policy.Decide()
// directly would miss the rule until the next controller build.
if rule, ok := ParseRule(toolName); ok {
g.Policy.Allow = append(g.Policy.Allow, rule)
}
}
return true, "", nil
default:
return true, "", nil
}
}
func (g *Gate) approve(ctx context.Context, toolName, subject string, args json.RawMessage) (bool, bool, string, error) {
if a, ok := g.Approver.(ReasonedApprover); ok {
return a.ApproveWithReason(ctx, toolName, subject, args)
}
allow, remember, err := g.Approver.Approve(ctx, toolName, subject, args)
return allow, remember, "", err
}
// rememberRule builds the rule string persisted when the user picks "always
// allow". Bash commands prefer a safe command prefix (e.g. go test:*) so
// "always allow" covers similar invocations with different arguments. File
// mutation tools are remembered tool-wide ("Edit") so approving one file edit
// covers all files. Other tools are remembered by tool name. Deny and ask rules keep their higher precedence.
func rememberRule(toolName, subject string) string {
return RememberRuleForScope(toolName, subject)
}
// RememberRuleForScope builds the rule string persisted when the user chooses
// an always-allow option. Bash commands prefer a safe prefix (go test:*) so
// similar invocations (different search terms, different test packages) match;
// when no safe prefix can be extracted the exact command is used. File
// mutation tools are always remembered tool-wide (Edit). Other tools use their
// bare tool name. Deny rules still take precedence on every call.
func RememberRuleForScope(toolName, subject string) string {
subject = strings.TrimSpace(subject)
if subject != "" && toolName == "bash" {
if pattern := BashCommandPrefix(subject); pattern != "" {
return "Bash(" + pattern + ")"
}
return "Bash(" + subject + ")"
}
if IsFileMutationTool(toolName) {
return "Edit"
}
return toolName
}
// SessionGrantKey returns the in-memory rule for "allow this session". Bash
// prefers a command prefix when one is available, falling back to the exact
// command when unsafe. File mutation tools share a single Edit grant.
func SessionGrantKey(toolName, subject string) string {
return SessionGrantRuleForScope(toolName, subject)
}
// SessionGrantRuleForScope returns the in-memory rule for a session grant.
// Bash prefers a command prefix when one is available; file mutation tools
// share a single Edit grant; all other tools return the bare tool name.
func SessionGrantRuleForScope(toolName, subject string) string {
subject = strings.TrimSpace(subject)
if toolName == "bash" && subject != "" {
if pattern := BashCommandPrefix(subject); pattern != "" {
return "Bash(" + pattern + ")"
}
return "Bash(" + subject + ")"
}
if IsFileMutationTool(toolName) {
return "Edit"
}
return toolName
}
// BashCommandPrefix returns a conservative prefix rule for "similar command"
// approvals. It avoids shell syntax and keeps the prefix at command-word
// boundaries, so approving "go test ./..." grants "go test:*" rather than a
// broader "go *".
func BashCommandPrefix(subject string) string {
cmd := strings.TrimSpace(subject)
if cmd == "" || containsShellSyntax(cmd) {
return ""
}
if BashDangerWarning(cmd) != "" {
return ""
}
fields, malformed := shellparse.StaticFields(cmd)
if malformed != "" {
return ""
}
if len(fields) < 2 {
return ""
}
base := strings.ToLower(fields[0])
if isPackageManagerRun(base) && len(fields) >= 3 && strings.ToLower(fields[1]) == "run" {
return fields[0] + " " + fields[1] + " " + fields[2] + ":*"
}
return fields[0] + " " + fields[1] + ":*"
}
func isPackageManagerRun(base string) bool {
switch base {
case "npm", "pnpm", "yarn", "bun":
return true
default:
return false
}
}
// IsFileMutationTool reports whether a built-in tool mutates workspace files.
func IsFileMutationTool(toolName string) bool {
switch toolName {
case "write_file", "edit_file", "multi_edit", "move_file", "notebook_edit", "delete_range", "delete_symbol":
return true
default:
return false
}
}
func ruleToolMatches(ruleTool, toolName string) bool {
ruleTool = canonicalRuleTool(ruleTool)
return ruleTool == toolName || (ruleTool == "file_mutation" && IsFileMutationTool(toolName))
}
func ruleToolCompatible(existingTool, candidateTool string) bool {
existingTool = canonicalRuleTool(existingTool)
candidateTool = canonicalRuleTool(candidateTool)
return existingTool == candidateTool ||
(existingTool == "file_mutation" && (candidateTool == "file_mutation" || IsFileMutationTool(candidateTool)))
}
func canonicalRuleTool(toolName string) string {
switch strings.TrimSpace(toolName) {
case "Bash", "bash":
return "bash"
case "Edit", "edit", "file_mutation":
return "file_mutation"
default:
return toolName
}
}
func ruleSubjectMatches(rule Rule, subject string) bool {
if rule.Subject == "" {
return true
}
if subject == "" {
return false
}
if rule.Literal {
return rule.Subject == subject
}
if canonicalRuleTool(rule.Tool) == "bash" {
if base, ok := bashColonPrefixBase(rule.Subject); ok {
return bashPrefixMatches(base, subject)
}
if base, ok := legacyBashSpaceStarPrefixBase(rule.Subject); ok {
return bashPrefixMatches(base, subject)
}
}
return matchGlob(rule.Subject, subject)
}
func bashColonPrefixBase(pattern string) (string, bool) {
if !strings.HasSuffix(pattern, ":*") {
return "", false
}
base := strings.TrimSuffix(pattern, ":*")
return base, base != ""
}
func legacyBashSpaceStarPrefixBase(pattern string) (string, bool) {
if !strings.HasSuffix(pattern, " *") {
return "", false
}
base := strings.TrimSuffix(pattern, " *")
return base, base != ""
}
func bashPrefixBase(pattern string) (string, bool) {
if base, ok := bashColonPrefixBase(pattern); ok {
return base, true
}
return legacyBashSpaceStarPrefixBase(pattern)
}
func bashPrefixMatches(base, subject string) bool {
if normalized, ok := normalizeBashSafeRedirectsForMatch(subject); ok {
subject = normalized
}
fields, malformed := shellparse.StaticFields(subject)
if malformed != "" {
return false
}
baseFields, malformed := shellparse.StaticFields(base)
if malformed != "" || len(baseFields) == 0 || len(fields) < len(baseFields) {
return false
}
for i, want := range baseFields {
if fields[i] != want {
return false
}
}
return true
}