// 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 /.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 }