Phase 12 (ADR-0005). Capabilities are registered in code with parameter schemas, risk classes and default effects; sudo_shell and delete_database are locked to DENY and cannot be relaxed. Policies are scoped globally or per mind with capability, target glob, action kind and optional hour window; evaluation is deterministic, most specific wins, and ties go to the stricter effect. Invoke is the single entry point: it refuses dreaming and replay contexts, validates parameters (unknown keys, non http URLs and path traversal rejected), evaluates policy and session grants, hashes the exact request and records a policy_decision event. ALLOW executes with timeout and panic isolation and turns textual output into a tool-trust observation; ASK creates an approval request bound to the hash with an alert notification; DENY is counted and recorded. Operators approve once, approve for a time-boxed session grant, deny with a note, or modify parameters (re-validated, re-hashed, original kept in the append-only decision log). The self-model's capability list is recomputed from effective policy on start and on every policy change. The act process maps fetch requests to http.fetch and the action handler invokes through the layer; replies report capabilities and outcomes. Policy matrix and approval centre pages, dashboard count, API, CLI (approvals, policy, invoke) and docs/capabilities.md.
95 lines
4.2 KiB
Go
95 lines
4.2 KiB
Go
package capabilities
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/andydixon/noema/internal/security/httpclient"
|
|
)
|
|
|
|
// Builtins returns the capabilities every deployment has. Integrations add
|
|
// more. Locked capabilities exist so that policy can name them and the
|
|
// self-model can list them as unavailable; they never execute.
|
|
func Builtins() []Capability {
|
|
return []Capability{
|
|
{Name: "noema.note", Description: "Leave a note for the operator in the mind's own history.", Risk: RiskLow, TargetKind: "none",
|
|
Params: map[string]Param{"text": {Type: "string", Required: true, Description: "note text", MaxLen: 2000}}, DefaultEffect: Allow,
|
|
Executor: func(_ context.Context, inv Invocation) (Result, error) {
|
|
t, _ := inv.Params["text"].(string)
|
|
return Result{Summary: "note recorded", Data: map[string]any{"text": t}}, nil
|
|
}},
|
|
{Name: "http.fetch", Description: "Fetch a public HTTP(S) URL read-only (bounded, no private addresses) and return its text.", Risk: RiskMedium, TargetKind: "url",
|
|
Params: map[string]Param{"url": {Type: "url", Required: true, Description: "the URL to fetch", MaxLen: 2000}, "max_bytes": {Type: "int", Description: "response cap (default 64 KiB)"}}, DefaultEffect: Ask,
|
|
Executor: fetchURL},
|
|
{Name: "internet_search", Description: "Search the web (requires a configured search integration).", Risk: RiskMedium, TargetKind: "none",
|
|
Params: map[string]Param{"query": {Type: "string", Required: true, MaxLen: 500}}, DefaultEffect: Ask,
|
|
Executor: func(context.Context, Invocation) (Result, error) {
|
|
return Result{}, errors.New("no search integration configured")
|
|
}},
|
|
{Name: "send_email", Description: "Send an email (requires a mail integration).", Risk: RiskHigh, TargetKind: "address",
|
|
Params: map[string]Param{"to": {Type: "string", Required: true, MaxLen: 300}, "subject": {Type: "string", Required: true, MaxLen: 300}, "body": {Type: "string", Required: true, MaxLen: 20000}}, DefaultEffect: Ask,
|
|
Executor: func(context.Context, Invocation) (Result, error) {
|
|
return Result{}, errors.New("no mail integration configured")
|
|
}},
|
|
{Name: "sudo_shell", Description: "Privileged shell. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "host", Locked: true,
|
|
Params: map[string]Param{"command": {Type: "string", Required: true}}, Executor: never},
|
|
{Name: "delete_database", Description: "Destroy data stores. Locked to DENY; exists so it can be named, never run.", Risk: RiskCritical, TargetKind: "service", Locked: true,
|
|
Params: map[string]Param{"name": {Type: "string", Required: true}}, Executor: never},
|
|
}
|
|
}
|
|
|
|
func never(context.Context, Invocation) (Result, error) {
|
|
return Result{}, errors.New("locked capability invoked; this is a bug")
|
|
}
|
|
|
|
func fetchURL(ctx context.Context, inv Invocation) (Result, error) {
|
|
u, _ := inv.Params["url"].(string)
|
|
max := int64(64 << 10)
|
|
if n, ok := inv.Params["max_bytes"].(int); ok && n > 0 && n <= 1<<20 {
|
|
max = int64(n)
|
|
}
|
|
client := httpclient.New(httpclient.Options{Timeout: 20 * time.Second, MaxBody: max})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
req.Header.Set("User-Agent", "noema/1 (+capability http.fetch)")
|
|
req.Header.Set("Accept", "text/plain, text/html;q=0.9, application/json;q=0.8, */*;q=0.1")
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
body, err := httpclient.ReadAll(res.Body, max)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
text := string(body)
|
|
if strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
|
text = stripTags(text)
|
|
}
|
|
return Result{Summary: fmt.Sprintf("HTTP %d, %d bytes, %s", res.StatusCode, len(body), res.Header.Get("Content-Type")), Output: text, Data: map[string]any{"status": res.StatusCode, "content_type": res.Header.Get("Content-Type"), "bytes": len(body)}}, nil
|
|
}
|
|
|
|
// stripTags is a crude tag remover; the output is data, never rendered as HTML.
|
|
func stripTags(s string) string {
|
|
var b strings.Builder
|
|
in := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '<':
|
|
in = true
|
|
case r == '>':
|
|
in = false
|
|
b.WriteByte(' ')
|
|
case !in:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return strings.Join(strings.Fields(b.String()), " ")
|
|
}
|