security: add the capability and policy layer as the only path to action
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.
This commit is contained in:
@@ -216,3 +216,11 @@ canvas.graph { width: 100%; height: 560px; display: block; border-radius: 8px; b
|
||||
.legend { display: flex; flex-wrap: wrap; gap: .6rem; padding: .4rem .3rem 0; font-size: .78rem; color: var(--muted); }
|
||||
.legend-item i { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: .3rem; vertical-align: middle; }
|
||||
.legend-item i.k-person { background: #2f6fed; } .legend-item i.k-system { background: #0ea5a4; } .legend-item i.k-project { background: #16a34a; } .legend-item i.k-document { background: #d97706; } .legend-item i.k-concept { background: #8b5cf6; } .legend-item i.k-repository { background: #64748b; } .legend-item i.k-organisation { background: #db2777; } .legend-item i.k-place { background: #0891b2; } .legend-item i.k-other { background: #9ca3af; }
|
||||
|
||||
/* Capabilities */
|
||||
.badge.rk-low { background: var(--ok-soft); color: var(--ok); } .badge.rk-medium { background: var(--warn-soft); color: var(--warn); } .badge.rk-high { background: var(--danger-soft); color: var(--danger); } .badge.rk-critical { background: var(--danger); color: #fff; }
|
||||
.badge.ef-allow { background: var(--ok-soft); color: var(--ok); } .badge.ef-ask { background: var(--warn-soft); color: var(--warn); } .badge.ef-deny { background: var(--danger-soft); color: var(--danger); }
|
||||
.badge.ap-executed, .badge.ap-approved { background: var(--ok-soft); color: var(--ok); } .badge.ap-denied, .badge.ap-failed { background: var(--danger-soft); color: var(--danger); } .badge.ap-expired { background: var(--surface-2); color: var(--muted); }
|
||||
.tablewrap { overflow-x: auto; }
|
||||
.approval { border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; margin-bottom: .8rem; }
|
||||
.approval-head { margin-bottom: .5rem; }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "content"}}
|
||||
<div class="pagehead"><h1>Approval centre</h1><p class="muted">Actions a mind wants to take that policy marks ASK. Approvals are bound to the exact request; modified parameters are re-validated and recorded.</p></div>
|
||||
{{$op := .Principal.User.Role.AtLeast "operator"}}
|
||||
<section class="card">
|
||||
<h2>Pending <span class="muted small">{{len .Data.Pending}}</span></h2>
|
||||
{{if .Data.Pending}}{{range .Data.Pending}}
|
||||
<div class="approval">
|
||||
<div class="approval-head"><span class="badge rk-{{.Risk}}">{{.Risk}}</span> <strong>{{index $.Data.MindNames .MindID}}</strong> wants <code>{{.Capability}}</code>{{if .Target}} on <span class="mono">{{.Target}}</span>{{end}} <span class="muted small">· requested {{ago .CreatedAt}} · expires {{.ExpiresAt.Local.Format "02 Jan 15:04"}}{{if .EventID}} · <a href="/events/{{.EventID}}">event</a>{{end}}</span></div>
|
||||
<div class="grid cols-2 tight">
|
||||
<div><h3>Reason</h3><p>{{if .Reason}}{{.Reason}}{{else}}<em class="muted">none given</em>{{end}}</p>
|
||||
{{if .Evidence}}<h3>Evidence</h3><ul class="plain small">{{range .Evidence}}<li><span class="badge">{{.Kind}}</span> {{if eq .Kind "event"}}<a href="/events/{{.ID}}">{{.ID}}</a>{{else}}{{.ID}}{{end}} {{.Note}}</li>{{end}}</ul>{{end}}</div>
|
||||
<div><h3>Parameters</h3><pre class="json">{{pretty (jsonOf .Params)}}</pre></div>
|
||||
</div>
|
||||
{{if $op}}
|
||||
<form method="post" action="/approvals/{{.ID}}/decide" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<details><summary class="small">Modify parameters before approving</summary><textarea name="params" rows="3" class="mono" placeholder="JSON object; leave empty to keep">{{pretty (jsonOf .Params)}}</textarea></details>
|
||||
<div class="row"><input name="note" placeholder="note (shown to the mind)" class="grow" maxlength="500">
|
||||
<button class="btn btn-primary" name="decision" value="approve">Approve once</button>
|
||||
<button class="btn" name="decision" value="session" title="Also allow matching requests for this session">Approve for session</button>
|
||||
<button class="btn btn-danger" name="decision" value="deny">Deny</button></div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>{{end}}
|
||||
{{else}}<p class="muted">Nothing is waiting.</p>{{end}}
|
||||
</section>
|
||||
<section class="grid cols-2">
|
||||
<div class="card"><h2>Decided recently</h2>
|
||||
{{if .Data.Decided}}<table class="table compact"><thead><tr><th>Status</th><th>Mind</th><th>Action</th><th>By</th><th>Note</th><th>When</th></tr></thead><tbody>{{range .Data.Decided}}<tr><td><span class="badge ap-{{.Status}}">{{.Status}}</span></td><td class="small">{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code> <span class="mono small">{{.Target}}</span>{{if .Error}}<div class="small danger">{{.Error}}</div>{{end}}</td><td class="small">{{.DecisionBy}}</td><td class="small">{{.DecisionNote}}</td><td class="small muted nowrap">{{ago .CreatedAt}}</td></tr>{{end}}</tbody></table>{{else}}<p class="muted">None.</p>{{end}}</div>
|
||||
<div class="card"><h2>Executions</h2>
|
||||
{{if .Data.Executions}}<table class="table compact"><thead><tr><th>Mind</th><th>Capability</th><th>Target</th><th>Result</th><th class="num">ms</th><th></th></tr></thead><tbody>{{range .Data.Executions}}<tr><td class="small">{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code></td><td class="mono small">{{.Target}}</td><td class="small">{{if .Error}}<span class="danger">{{trunc .Error 80}}</span>{{else}}{{payloadField .Result "summary"}}{{end}}</td><td class="num">{{.DurationMS}}</td><td>{{if .EventID}}<a class="small" href="/events/{{.EventID}}">event</a>{{end}}</td></tr>{{end}}</tbody></table>{{else}}<p class="muted">None yet.</p>{{end}}</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -3,7 +3,7 @@
|
||||
<section class="grid cols-3">
|
||||
<div class="card stat"><div class="stat-label">Active minds</div><div class="stat-value">{{len .Data.Minds}}</div><div class="muted small">{{range .Data.Minds}}<a href="/minds/{{.Slug}}">{{.DisplayName}}</a> {{else}}No minds yet. Create one under Minds.{{end}}</div></div>
|
||||
<div class="card stat"><div class="stat-label">System health</div><div class="stat-value">{{if .Data.Status.Database.OK}}<span class="ok">Healthy</span>{{else}}<span class="danger">Degraded</span>{{end}}</div><div class="muted small">Database {{if .Data.Status.Database.OK}}connected{{else}}unavailable{{end}} · up {{duration .Data.Status.UptimeSeconds}}</div></div>
|
||||
<div class="card stat"><div class="stat-label">Outstanding approvals</div><div class="stat-value">0</div><div class="muted small">Capability layer arrives in a later phase.</div></div>
|
||||
<div class="card stat"><div class="stat-label">Outstanding approvals</div><div class="stat-value">{{.Data.Pending}}</div><div class="muted small"><a href="/approvals">Approval centre</a> · <a href="/policies">policies</a></div></div>
|
||||
</section>
|
||||
<section class="grid cols-2">
|
||||
<div class="card">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{{define "content"}}
|
||||
<div class="pagehead"><h1>Capabilities and policies</h1><p class="muted">No action reaches the world except through a capability, and every capability is governed by ALLOW / ASK / DENY rules. The most specific rule wins; on ties the stricter effect wins. Locked capabilities cannot be relaxed.</p></div>
|
||||
{{$admin := .Principal.User.Role.AtLeast "administrator"}}
|
||||
<section class="card">
|
||||
<h2>Effective policy matrix <span class="muted small">mind × capability, for a generic target right now</span></h2>
|
||||
<div class="tablewrap"><table class="table compact matrix">
|
||||
<thead><tr><th>Capability</th><th>Risk</th><th>Default</th>{{range .Data.Minds}}<th>{{.DisplayName}}</th>{{end}}</tr></thead>
|
||||
<tbody>{{range $c := .Data.Capabilities}}<tr>
|
||||
<td><code>{{$c.Name}}</code>{{if $c.Locked}} <span class="badge danger">locked</span>{{end}}<div class="small muted">{{$c.Description}}</div></td>
|
||||
<td><span class="badge rk-{{$c.Risk}}">{{$c.Risk}}</span></td><td><span class="badge ef-{{$c.DefaultEffect}}">{{$c.DefaultEffect}}</span></td>
|
||||
{{range $m := $.Data.Minds}}{{$e := index $.Data.Matrix $m.ID $c.Name}}<td><span class="badge ef-{{$e.Effect}}" title="{{$e.Reason}}">{{$e.Effect}}</span></td>{{end}}
|
||||
</tr>{{end}}</tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
<section class="grid cols-3-1">
|
||||
<div class="card">
|
||||
<h2>Rules</h2>
|
||||
{{if .Data.Policies}}<table class="table compact"><thead><tr><th>Scope</th><th>Capability</th><th>Target</th><th>Action</th><th>Hours</th><th>Effect</th><th>Reason</th><th>By</th>{{if $admin}}<th></th>{{end}}</tr></thead>
|
||||
<tbody>{{range .Data.Policies}}<tr><td class="small">{{if .MindID}}{{index $.Data.MindNames .MindID}}{{else}}<em>global</em>{{end}}</td><td><code>{{.Capability}}</code></td><td class="mono small">{{.TargetPattern}}</td><td class="small">{{.ActionKind}}</td><td class="small">{{if .HoursFrom}}{{deref_int .HoursFrom}}–{{deref_int .HoursTo}}{{else}}always{{end}}</td><td><span class="badge ef-{{.Effect}}">{{.Effect}}</span></td><td class="small">{{.Reason}}</td><td class="small muted">{{.CreatedBy}}</td>
|
||||
{{if $admin}}<td><form method="post" action="/policies/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small btn-danger" data-confirm="Delete this rule?">Delete</button></form></td>{{end}}</tr>{{end}}</tbody></table>
|
||||
{{else}}<p class="muted">No rules yet: every capability uses its default (ASK for reads and writes, DENY for locked).</p>{{end}}
|
||||
{{if .Data.Grants}}<h3>Active session grants</h3><table class="table compact"><tbody>{{range .Data.Grants}}<tr><td>{{index $.Data.MindNames .MindID}}</td><td><code>{{.Capability}}</code></td><td class="mono small">{{.TargetPattern}}</td><td class="small muted">by {{.GrantedBy}} until {{.ExpiresAt.Local.Format "15:04"}}</td><td>{{if $.Principal.User.Role.AtLeast "operator"}}<form method="post" action="/policies/grants/{{.ID}}/revoke" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="btn small">Revoke</button></form>{{end}}</td></tr>{{end}}</tbody></table>{{end}}
|
||||
</div>
|
||||
{{if $admin}}
|
||||
<div class="card">
|
||||
<h2>Add rule</h2>
|
||||
<form method="post" action="/policies" class="stack">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label>Scope <select name="mind_id"><option value="">global</option>{{range .Data.Minds}}<option value="{{.ID}}">{{.DisplayName}}</option>{{end}}</select></label>
|
||||
<label>Capability <select name="capability"><option value="*">* (all)</option>{{range .Data.Capabilities}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></label>
|
||||
<label>Target pattern <input name="target_pattern" value="*" placeholder="web-* or https://example.com/*"></label>
|
||||
<label>Effect <select name="effect"><option value="ask">ask</option><option value="allow">allow</option><option value="deny">deny</option></select></label>
|
||||
<div class="grid cols-2 tight"><label>Hours from <input type="number" name="hours_from" min="0" max="23" placeholder="any"></label><label>to <input type="number" name="hours_to" min="0" max="23"></label></div>
|
||||
<label>Reason <input name="reason" maxlength="200"></label>
|
||||
<div><button class="btn btn-primary" type="submit">Add rule</button></div>
|
||||
</form>
|
||||
<p class="muted small">ALLOW rules on high-risk capabilities are shown with a warning in the matrix. sudo_shell and delete_database are locked to DENY.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user