feat(capabilities): metadata, frozen registry, scoped grants and requests
- Capabilities declare access (read/write, unknown fails closed as write), side effects, idempotency, timeout, cost estimate and the trust of their output (web content is low trust). A capability may derive its policy target from its parameters; a disagreeing target is refused, and approvals whose modified parameters change the target are refused. - The registry is frozen at start-up; registration afterwards panics. - Invocation.RequireApproval turns ALLOW into ASK for commitments; it can only tighten, and scoped grants never satisfy it. - Scoped grants (migration 0025): capability, target and parameter globs, access, expiry of at most seven days and an optional operation count consumed atomically; applied only where policy says ASK. A sweep marks lapsed grants and refreshes the self-model, which now lists live grants. - Capability requests carry scope, duration, operation cap, reason, evidence, risk, mitigation, fallback, alternatives and explicit decision history; they batch per intention, are capped per mind (operator attention budget) and can be decided only by a named person. Component names are refused as deciders, for approvals too, and as usernames. - API routes, OpenAPI entries, scheduler sweeps and approval-centre sections for requests and temporary grants.
This commit is contained in:
@@ -18,6 +18,8 @@ func (u *UI) capabilityRoutes(m *http.ServeMux) {
|
||||
m.Handle("POST /policies/grants/{id}/revoke", u.require(auth.RoleOperator, u.grantRevoke))
|
||||
m.Handle("GET /approvals", u.require(auth.RoleObserver, u.approvalsPage))
|
||||
m.Handle("POST /approvals/{id}/decide", u.require(auth.RoleOperator, u.approvalDecide))
|
||||
m.Handle("POST /capability-requests/{id}/decide", u.require(auth.RoleOperator, u.capabilityRequestDecide))
|
||||
m.Handle("POST /scoped-grants/{id}/revoke", u.require(auth.RoleOperator, u.scopedGrantRevoke))
|
||||
}
|
||||
|
||||
func (u *UI) mindNames(r *http.Request) (map[ids.ID]string, []any) {
|
||||
@@ -100,7 +102,63 @@ func (u *UI) approvalsPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
execs, _ := u.deps.Capabilities.Executions(r.Context(), ids.Nil, 30)
|
||||
u.render(w, r, "approvals", "Approval centre", map[string]any{"Pending": pending, "Decided": decided, "Executions": execs, "MindNames": names})
|
||||
requests, _ := u.deps.Capabilities.CapabilityRequests(r.Context(), ids.Nil, "pending", 100)
|
||||
var batches []requestBatch
|
||||
for _, req := range requests {
|
||||
if n := len(batches); n > 0 && batches[n-1].Key == req.BatchKey {
|
||||
batches[n-1].Requests = append(batches[n-1].Requests, req)
|
||||
continue
|
||||
}
|
||||
batches = append(batches, requestBatch{Key: req.BatchKey, MindID: req.MindID, IntentionID: req.IntentionID, Requests: []capabilities.CapabilityRequest{req}})
|
||||
}
|
||||
grants, _ := u.deps.Capabilities.ScopedGrants(r.Context(), ids.Nil, true)
|
||||
caps := map[string]capabilities.Capability{}
|
||||
for _, c := range u.deps.Capabilities.Registry.List() {
|
||||
caps[c.Name] = c
|
||||
}
|
||||
u.render(w, r, "approvals", "Approval centre", map[string]any{"Pending": pending, "Decided": decided, "Executions": execs, "MindNames": names, "Batches": batches, "Grants": grants, "Caps": caps})
|
||||
}
|
||||
|
||||
// requestBatch groups requests raised for one intention (or one mind in one
|
||||
// hour) so an operator decides related requests together.
|
||||
type requestBatch struct {
|
||||
Key string
|
||||
MindID ids.ID
|
||||
IntentionID ids.ID
|
||||
Requests []capabilities.CapabilityRequest
|
||||
}
|
||||
|
||||
func (u *UI) capabilityRequestDecide(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", "Bad request id.", "/approvals")
|
||||
return
|
||||
}
|
||||
r.ParseForm() //nolint:errcheck
|
||||
var n capabilities.Narrowing
|
||||
if v, err := strconv.Atoi(r.PostFormValue("duration_minutes")); err == nil && v > 0 {
|
||||
n.DurationSeconds = v * 60
|
||||
}
|
||||
if v, err := strconv.Atoi(r.PostFormValue("max_operations")); err == nil && v > 0 {
|
||||
n.MaxOperations = &v
|
||||
}
|
||||
n.TargetPattern = strings.TrimSpace(r.PostFormValue("target_pattern"))
|
||||
grant := r.PostFormValue("decision") == "grant"
|
||||
if _, err := u.studyF().DecideCapabilityRequest(r, id, grant, r.PostFormValue("note"), n); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/approvals")
|
||||
return
|
||||
}
|
||||
u.flash(w, r, "ok", map[bool]string{true: "Temporary grant made.", false: "Request denied."}[grant], "/approvals")
|
||||
}
|
||||
|
||||
func (u *UI) scopedGrantRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ids.Parse(r.PathValue("id"))
|
||||
if err != nil || u.deps.Capabilities.RevokeScopedGrant(r.Context(), id, username(r)) != nil {
|
||||
u.flash(w, r, "error", "Grant not found or already revoked.", "/approvals")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "scoped_grant.revoke", "scoped_grant", id.String(), nil)
|
||||
u.flash(w, r, "ok", "Temporary grant revoked.", "/approvals")
|
||||
}
|
||||
|
||||
func (u *UI) approvalDecide(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+15
-4
@@ -125,10 +125,21 @@ func (u *UI) parse(fsys fs.FS) error {
|
||||
"roles": auth.AllRoles,
|
||||
"date": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") },
|
||||
"ago": ago,
|
||||
"duration": func(s int64) string { return (time.Duration(s) * time.Second).Truncate(time.Second).String() },
|
||||
"bytes": humanBytes,
|
||||
"pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) },
|
||||
"list": func(xs ...string) []string { return xs },
|
||||
"duration": func(v any) string {
|
||||
var secs int64
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
secs = int64(x)
|
||||
case int64:
|
||||
secs = x
|
||||
case float64:
|
||||
secs = int64(x)
|
||||
}
|
||||
return (time.Duration(secs) * time.Second).Truncate(time.Second).String()
|
||||
},
|
||||
"bytes": humanBytes,
|
||||
"pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) },
|
||||
"list": func(xs ...string) []string { return xs },
|
||||
"conf": func(v any) string {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
|
||||
Reference in New Issue
Block a user