167 lines
4.2 KiB
Go
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
|
|
}
|