Files
patterm/internal/policy/policy.go
2026-05-14 13:37:20 +01:00

167 lines
4.2 KiB
Go

// Package policy implements SPEC §9 permissions hooks.
//
// patterm doesn't enforce permissions on the agent's behalf — the
// orchestrator is the policy actor. But patterm ships a config that
// surfaces the project's deny-list to the orchestrator (via
// scratchpad_read("policy.md")) and exposes a Should() helper future
// MCP middleware can call to short-circuit obviously-dangerous prompts.
//
// File location: $XDG_CONFIG_HOME/patterm/policy.json (global default;
// per-project override at <project>/.patterm/policy.json is a v2
// follow-up).
package policy
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
"sync"
)
// Decision is what Should() returns for a candidate auto-answer.
type Decision string
const (
// Allow: the prompt is in the always-safe allowlist; auto-answer.
Allow Decision = "allow"
// PuntToHuman: the prompt matches the deny-list; the orchestrator
// MUST call request_human_attention instead of auto-answering.
PuntToHuman Decision = "punt"
// Unknown: no rule applies. SPEC §9 says default is to punt; we
// keep this distinct so callers know it's a default, not a match.
Unknown Decision = "unknown"
)
type Policy struct {
// Allowlist patterns: prompts matching ANY of these are safe to
// auto-answer.
AllowPatterns []string `json:"allow_patterns"`
// Deny patterns: prompts matching ANY of these MUST be punted to
// the human. Default seed below covers SPEC §9 examples (writes,
// deletes, sudo, package install, broad shell).
DenyPatterns []string `json:"deny_patterns"`
mu sync.Mutex
compiledAOK bool
allowRE []*regexp.Regexp
denyRE []*regexp.Regexp
}
// Default returns the seeded policy that ships out of the box.
func Default() *Policy {
return &Policy{
AllowPatterns: []string{
`(?i)read.* from .*\?`,
`(?i)open .* in editor\?`,
`(?i)show diff\?`,
},
DenyPatterns: []string{
`(?i)sudo`,
`(?i)rm -rf`,
`(?i)delete .*permanently`,
`(?i)install package`,
`(?i)pip install`,
`(?i)npm install -g`,
`(?i)curl .* \| .*sh`,
`(?i)wget .* \| .*sh`,
`(?i)force.push`,
`(?i)git push --force`,
`(?i)drop (table|database)`,
`(?i)\.ssh/`,
`(?i)\.aws/credentials`,
`(?i)\.env\b`,
},
}
}
// Load reads the user's policy, falling back to Default if absent.
// Errors other than ENOENT are returned.
func Load() (*Policy, error) {
p, err := PathFor()
if err != nil {
return nil, err
}
body, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
pol := Default()
_ = Save(pol)
return pol, nil
}
return nil, err
}
var pol Policy
if err := json.Unmarshal(body, &pol); err != nil {
return nil, err
}
return &pol, nil
}
// Save writes p to the standard location, creating directories.
func Save(p *Policy) error {
path, err := PathFor()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
body, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
body = append(body, '\n')
return os.WriteFile(path, body, 0o600)
}
func PathFor() (string, error) {
if h := os.Getenv("XDG_CONFIG_HOME"); h != "" {
return filepath.Join(h, "patterm", "policy.json"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "patterm", "policy.json"), nil
}
// Should classifies a candidate auto-answer prompt the orchestrator is
// reading from a sub-agent's grid.
func (p *Policy) Should(promptText string) Decision {
p.mu.Lock()
defer p.mu.Unlock()
p.ensureCompiledLocked()
for _, re := range p.denyRE {
if re.MatchString(promptText) {
return PuntToHuman
}
}
for _, re := range p.allowRE {
if re.MatchString(promptText) {
return Allow
}
}
return Unknown
}
func (p *Policy) ensureCompiledLocked() {
if p.compiledAOK {
return
}
p.allowRE = make([]*regexp.Regexp, 0, len(p.AllowPatterns))
for _, s := range p.AllowPatterns {
if re, err := regexp.Compile(s); err == nil {
p.allowRE = append(p.allowRE, re)
}
}
p.denyRE = make([]*regexp.Regexp, 0, len(p.DenyPatterns))
for _, s := range p.DenyPatterns {
if re, err := regexp.Compile(s); err == nil {
p.denyRE = append(p.denyRE, re)
}
}
p.compiledAOK = true
}