diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b12e94..a5cc308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added (decision dashboard) +- Decision, receipt and chain pages in `agentctl-web`: `/decisions`, `/decisions/{seq}` and `/receipts`. They read a receipt export from `--export-dir` (offline, no cluster) or from the receipt-writer, and verify it in process. The badge says Verified only under a pinned `--trust-root`. See `docs/dashboard.md`. +- `make demo-dashboard` serves a sample export offline. `tools/demo-export` writes it. +- The chart wires the web UI to the receipt-writer when both are enabled, and admits the web pods through the writer NetworkPolicy. + Decision architecture series. The direct action path now decides in layers: invariants, policy packs, a threshold evaluator, an escalate-only decision model in shadow, and human approval. Each decision can get a signed receipt diff --git a/Makefile b/Makefile index 6188828..17873d1 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ SETUP_ENVTEST := $(LOCALBIN)/setup-envtest STATICCHECK := $(LOCALBIN)/staticcheck CONTROLLER_GEN := $(LOCALBIN)/controller-gen -.PHONY: help test validate test-unit test-go test-anf-snapshot vet-anf-snapshot verify-anf-snapshot test-python setup-envtest test-controller fmt fmt-anf-snapshot fmt-check fmt-check-anf-snapshot vet lint staticcheck-anf-snapshot build build-agentctl build-agentctl-web build-receipt-writer build-anf-snapshot install-agentctl scan-secrets clean-venv check-python-version helm-lint test-cluster test-smoke test-e2e-cluster manifests generate decision-train +.PHONY: help test validate test-unit test-go test-anf-snapshot vet-anf-snapshot verify-anf-snapshot test-python setup-envtest test-controller fmt fmt-anf-snapshot fmt-check fmt-check-anf-snapshot vet lint staticcheck-anf-snapshot build build-agentctl build-agentctl-web build-receipt-writer demo-dashboard build-anf-snapshot install-agentctl scan-secrets clean-venv check-python-version helm-lint test-cluster test-smoke test-e2e-cluster manifests generate decision-train .DEFAULT_GOAL := help @@ -177,6 +177,11 @@ build: @echo "Building Go binaries..." @$(GO) build ./... +demo-dashboard: ## Serve the decision dashboard offline from a sample export (127.0.0.1:8090) + @$(GO) run ./tools/demo-export -out bin/demo-dashboard + @echo "Serving http://127.0.0.1:8090 (Ctrl-C to stop)" + @$(GO) run ./cmd/agentctl-web --export-dir bin/demo-dashboard/export --trust-root bin/demo-dashboard/pinned-trust.json + decision-train: ## Seed DRAFT synthetic data and train the decision model twice; fails unless both artifacts match @bash tools/decision-train/run.sh diff --git a/charts/templates/receipt-writer.yaml b/charts/templates/receipt-writer.yaml index ba655e1..82572bb 100644 --- a/charts/templates/receipt-writer.yaml +++ b/charts/templates/receipt-writer.yaml @@ -166,7 +166,8 @@ spec: policyTypes: - Ingress - Egress - # Only operator pods may call the writer. The writer needs no egress. + # Only operator pods, and the web UI when it is on, may call the writer. The + # writer needs no egress. ingress: - from: - podSelector: @@ -174,6 +175,13 @@ spec: app.kubernetes.io/name: agentic-operator app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/component: operator + {{- if .Values.webUI.enabled }} + - podSelector: + matchLabels: + app.kubernetes.io/name: agentic-operator + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: webui + {{- end }} ports: - port: {{ $port }} protocol: TCP diff --git a/charts/templates/webui.yaml b/charts/templates/webui.yaml index b198892..64f93fc 100644 --- a/charts/templates/webui.yaml +++ b/charts/templates/webui.yaml @@ -1,4 +1,9 @@ {{- if .Values.webUI.enabled }} +{{- /* The writer token Secret and trust ConfigMap live in the release namespace. + Pods can only mount them from the same namespace. */}} +{{- if and (.Values.global.receipts).enabled (ne (include "agentic-operator.namespace" .) .Release.Namespace) }} +{{- fail "webUI with global.receipts.enabled needs global.namespace to match the release namespace, so the web pods can mount the writer token and trust root" }} +{{- end }} --- apiVersion: v1 kind: ServiceAccount @@ -98,6 +103,16 @@ spec: protocol: TCP args: - "--addr=:8090" + {{- with .Values.global.receipts }} + {{- if .enabled }} + # Decision, receipt and chain pages read the receipt-writer. + - {{ printf "--writer-url=http://%s.%s.svc:%d" (printf "%s-receipt-writer" $.Release.Name | trunc 63 | trimSuffix "-") $.Release.Namespace (int (.port | default 8080)) | quote }} + - {{ printf "--writer-token-file=/etc/receipts/token/%s" ((.token).key | default "token") | quote }} + {{- if (.trustRoot).existingConfigMap }} + - {{ printf "--trust-root=/etc/receipts/trust/%s" ((.trustRoot).key | default "trust.json") | quote }} + {{- end }} + {{- end }} + {{- end }} livenessProbe: httpGet: path: /healthz @@ -110,6 +125,17 @@ spec: port: http initialDelaySeconds: 3 periodSeconds: 10 + {{- if (.Values.global.receipts).enabled }} + volumeMounts: + - name: receipts-token + mountPath: /etc/receipts/token + readOnly: true + {{- if ((.Values.global.receipts).trustRoot).existingConfigMap }} + - name: receipts-trust + mountPath: /etc/receipts/trust + readOnly: true + {{- end }} + {{- end }} resources: {{- toYaml .Values.webUI.resources | nindent 12 }} securityContext: @@ -117,6 +143,23 @@ spec: capabilities: drop: ["ALL"] readOnlyRootFilesystem: true + {{- with .Values.global.receipts }} + {{- if .enabled }} + volumes: + - name: receipts-token + secret: + secretName: {{ (.token).existingSecret | default (printf "%s-receipt-writer-token" $.Release.Name | trunc 63 | trimSuffix "-") }} + defaultMode: 0444 + {{- if (.trustRoot).existingConfigMap }} + - name: receipts-trust + configMap: + name: {{ (.trustRoot).existingConfigMap }} + items: + - key: {{ (.trustRoot).key | default "trust.json" }} + path: {{ (.trustRoot).key | default "trust.json" }} + {{- end }} + {{- end }} + {{- end }} --- apiVersion: v1 kind: Service diff --git a/charts/tests/webui_decisions_test.yaml b/charts/tests/webui_decisions_test.yaml new file mode 100644 index 0000000..8b247c6 --- /dev/null +++ b/charts/tests/webui_decisions_test.yaml @@ -0,0 +1,164 @@ +# helm-unittest spec for the web UI decision pages (docs/architecture/demo-dashboard.md). +# Run with: helm unittest charts +suite: web ui decision pages +templates: + - webui.yaml + - receipt-writer.yaml +tests: + - it: gives the web UI no writer flags when receipts are off + set: + license: + key: "test" + webUI: + enabled: true + asserts: + - notContains: + path: spec.template.spec.containers[0].args + content: "--writer-token-file=/etc/receipts/token/token" + documentIndex: 3 + template: webui.yaml + - notExists: + path: spec.template.spec.volumes + documentIndex: 3 + template: webui.yaml + + - it: points the web UI at the writer with a pinned trust root + release: + name: rel + namespace: agentic-system + set: + license: + key: "test" + webUI: + enabled: true + global: + receipts: + enabled: true + signingKey: + existingSecret: receipt-signing-key + trustRoot: + existingConfigMap: receipt-trust-root + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: "--writer-url=http://rel-receipt-writer.agentic-system.svc:8080" + documentIndex: 3 + template: webui.yaml + - contains: + path: spec.template.spec.containers[0].args + content: "--writer-token-file=/etc/receipts/token/token" + documentIndex: 3 + template: webui.yaml + - contains: + path: spec.template.spec.containers[0].args + content: "--trust-root=/etc/receipts/trust/trust.json" + documentIndex: 3 + template: webui.yaml + - contains: + path: spec.template.spec.volumes + content: + name: receipts-token + secret: + secretName: rel-receipt-writer-token + defaultMode: 0444 + documentIndex: 3 + template: webui.yaml + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: receipts-trust + mountPath: /etc/receipts/trust + readOnly: true + documentIndex: 3 + template: webui.yaml + + - it: omits the trust flag when no trust root is pinned + release: + name: rel + namespace: agentic-system + set: + license: + key: "test" + webUI: + enabled: true + global: + receipts: + enabled: true + insecureNoPin: true + signingKey: + existingSecret: receipt-signing-key + asserts: + - notContains: + path: spec.template.spec.containers[0].args + content: "--trust-root=/etc/receipts/trust/trust.json" + documentIndex: 3 + template: webui.yaml + + - it: lets the web UI reach the writer only when it is enabled + release: + name: rel + namespace: agentic-system + set: + license: + key: "test" + webUI: + enabled: true + global: + receipts: + enabled: true + signingKey: + existingSecret: receipt-signing-key + trustRoot: + existingConfigMap: receipt-trust-root + asserts: + - lengthEqual: + path: spec.ingress[0].from + count: 2 + documentIndex: 3 + template: receipt-writer.yaml + - equal: + path: spec.ingress[0].from[1].podSelector.matchLabels["app.kubernetes.io/component"] + value: webui + documentIndex: 3 + template: receipt-writer.yaml + + - it: keeps the writer ingress to operator pods when the web UI is off + release: + name: rel + namespace: agentic-system + set: + license: + key: "test" + global: + receipts: + enabled: true + signingKey: + existingSecret: receipt-signing-key + trustRoot: + existingConfigMap: receipt-trust-root + asserts: + - lengthEqual: + path: spec.ingress[0].from + count: 1 + documentIndex: 3 + template: receipt-writer.yaml + + - it: fails when the web namespace differs from the writer namespace + release: + name: rel + namespace: other-ns + set: + license: + key: "test" + webUI: + enabled: true + global: + namespace: agentic-system + receipts: + enabled: true + signingKey: + existingSecret: receipt-signing-key + template: webui.yaml + asserts: + - failedTemplate: + errorPattern: "needs global.namespace to match the release namespace" diff --git a/cmd/agentctl-web/agentctl-web b/cmd/agentctl-web/agentctl-web new file mode 100755 index 0000000..19ce12d Binary files /dev/null and b/cmd/agentctl-web/agentctl-web differ diff --git a/cmd/agentctl-web/decisions.go b/cmd/agentctl-web/decisions.go new file mode 100644 index 0000000..36cfbfb --- /dev/null +++ b/cmd/agentctl-web/decisions.go @@ -0,0 +1,277 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decisionview" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +// maxTimelineRows caps one timeline page. +const maxTimelineRows = 100 + +// decisionPages serves the decision, receipt and chain views from a verified +// receipt export. It has its own template sets, one per page, so page blocks +// never collide with the other pages. +type decisionPages struct { + loader *decisionview.Loader + sets map[string]*template.Template +} + +var decisionPageFiles = []string{"home.html", "panel.html", "detail.html", "receipts.html"} + +// decisionFilters lists every receipt outcome, in the order the filter bar +// shows them. The query value is checked against this list only. +var decisionFilters = []decisionFilter{ + {"allow", "Allow"}, + {"deny", "Deny"}, + {"require_approval", "Require approval"}, + {"approved", "Approved"}, + {"rejected", "Rejected"}, + {"edited", "Edited"}, +} + +type decisionFilter struct{ Value, Label string } + +func validDecisionFilter(v string) bool { + for _, f := range decisionFilters { + if f.Value == v { + return true + } + } + return false +} + +func newDecisionPages(loader *decisionview.Loader, tmplFS fs.FS) (*decisionPages, error) { + funcs := template.FuncMap{ + "statusLabel": statusLabel, + "statusNote": statusNote, + "ts": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05Z") }, + "short": func(s string) string { + if len(s) > 12 { + return s[:12] + } + return s + }, + "join": func(s []string) string { return strings.Join(s, ", ") }, + "micro": func(v int64) string { return fmt.Sprintf("%d.%06d", v/1_000_000, v%1_000_000) }, + "outcome": func(s string) string { return strings.ReplaceAll(s, "_", " ") }, + "age": func(t time.Time) string { + d := time.Since(t).Round(time.Second) + if d < 0 { + d = 0 + } + return d.String() + }, + } + d := &decisionPages{loader: loader, sets: map[string]*template.Template{}} + for _, name := range decisionPageFiles { + files := []string{"decisions/layout.html", "decisions/panel.html"} + if name != "panel.html" { + files = append(files, "decisions/"+name) + } + t, err := template.New(name).Funcs(funcs).ParseFS(tmplFS, files...) + if err != nil { + return nil, fmt.Errorf("parse decisions/%s: %w", name, err) + } + d.sets[name] = t + } + return d, nil +} + +func (d *decisionPages) register(mux *http.ServeMux) { + mux.Handle("GET /decisions", securityHeaders(http.HandlerFunc(d.handleHome))) + mux.Handle("GET /decisions/panel", securityHeaders(http.HandlerFunc(d.handlePanel))) + mux.Handle("GET /decisions/{seq}", securityHeaders(http.HandlerFunc(d.handleDetail))) + mux.Handle("GET /receipts", securityHeaders(http.HandlerFunc(d.handleReceipts))) +} + +// securityHeaders locks the decision pages to their own origin. They load no +// external script, style, font or image, and the CSP enforces that. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} + +// decisionSourceConfig selects where the decision pages read receipts from. +type decisionSourceConfig struct { + ExportDir string + WriterURL string + TokenFile string + TrustRoot string + TTL time.Duration + HTTPTimeout time.Duration +} + +// enabled reports whether any source is configured. +func (c decisionSourceConfig) enabled() bool { return c.ExportDir != "" || c.WriterURL != "" } + +// newDecisionLoader builds the loader. An export directory and a writer URL are +// exclusive. A trust root is read from a file pinned out of band. Without one +// the pages still work and say the chain is consistent only. +func newDecisionLoader(c decisionSourceConfig) (*decisionview.Loader, error) { + if c.ExportDir != "" && c.WriterURL != "" { + return nil, errors.New("set either --export-dir or --writer-url, not both") + } + var src decisionview.Source + switch { + case c.ExportDir != "": + src = decisionview.DirSource(c.ExportDir) + case c.WriterURL != "": + if c.TokenFile == "" { + return nil, errors.New("--writer-token-file is required with --writer-url") + } + w, err := receipts.NewRemoteWriter(c.WriterURL, receipts.TokenFile(c.TokenFile), c.HTTPTimeout) + if err != nil { + return nil, err + } + src = decisionview.WriterSource{Writer: w} + default: + return nil, errors.New("no decision source configured") + } + var trusted []receiptspec.TrustedKey + if c.TrustRoot != "" { + data, err := os.ReadFile(c.TrustRoot) + if err != nil { + return nil, err + } + if trusted, err = receiptspec.LoadTrustedKeys(data); err != nil { + return nil, fmt.Errorf("trust root: %w", err) + } + } + ttl := c.TTL + if ttl <= 0 { + ttl = 2 * time.Second + } + return &decisionview.Loader{Source: src, Trusted: trusted, TTL: ttl}, nil +} + +type decisionPageData struct { + Title string + View decisionview.View + Rows []decisionview.Row + Filter string + Shown int + Detail *decisionview.Detail + Status string + NotFound bool + Filters []decisionFilter +} + +func (d *decisionPages) data(r *http.Request, title string) decisionPageData { + v := d.loader.Current(r.Context()) + return decisionPageData{Title: title, View: v, Status: string(v.Verification.Status()), Filters: decisionFilters} +} + +func statusLabel(s string) string { + switch decisionview.Status(s) { + case decisionview.StatusVerified: + return "Verified" + case decisionview.StatusPartial: + return "Partial" + case decisionview.StatusConsistent: + return "Consistent only" + default: + return "Failed" + } +} + +func statusNote(s string) string { + switch decisionview.Status(s) { + case decisionview.StatusVerified: + return "Signatures check out under a pinned key and a signed manifest proves nothing was removed." + case decisionview.StatusPartial: + return "Signatures check out under a pinned key. No signed manifest proves that no later receipts were removed." + case decisionview.StatusConsistent: + return "The chain is internally consistent, but the trust root came from the export itself. Nobody is authenticated. Pin a trust root to authenticate it." + default: + return "Verification failed. Do not trust the rows below." + } +} + +func filterRows(rows []decisionview.Row, outcome string) []decisionview.Row { + out := make([]decisionview.Row, 0, len(rows)) + for _, r := range rows { + if outcome != "" && r.Outcome != outcome { + continue + } + out = append(out, r) + if len(out) == maxTimelineRows { + break + } + } + return out +} + +func (d *decisionPages) timelineData(r *http.Request, title string) decisionPageData { + data := d.data(r, title) + if f := r.URL.Query().Get("outcome"); validDecisionFilter(f) { + data.Filter = f + } + data.Rows = filterRows(data.View.Rows, data.Filter) + data.Shown = len(data.Rows) + return data +} + +func (d *decisionPages) render(w http.ResponseWriter, page, block string, data decisionPageData, code int) { + var buf bytes.Buffer + if err := d.sets[page].ExecuteTemplate(&buf, block, data); err != nil { + slog.Error("render decisions page", "page", page, "error", err) + http.Error(w, "render failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + _, _ = w.Write(buf.Bytes()) +} + +func (d *decisionPages) handleHome(w http.ResponseWriter, r *http.Request) { + d.render(w, "home.html", "layout", d.timelineData(r, "Decisions"), http.StatusOK) +} + +func (d *decisionPages) handlePanel(w http.ResponseWriter, r *http.Request) { + d.render(w, "panel.html", "panel", d.timelineData(r, "Decisions"), http.StatusOK) +} + +func (d *decisionPages) handleDetail(w http.ResponseWriter, r *http.Request) { + data := d.data(r, "Decision") + seq, err := strconv.ParseUint(r.PathValue("seq"), 10, 64) + if err == nil { + if det, ok := data.View.Detail(seq); ok { + data.Detail = &det + } + } + if data.Detail == nil { + data.NotFound = true + d.render(w, "detail.html", "layout", data, http.StatusNotFound) + return + } + d.render(w, "detail.html", "layout", data, http.StatusOK) +} + +func (d *decisionPages) handleReceipts(w http.ResponseWriter, r *http.Request) { + d.render(w, "receipts.html", "layout", d.data(r, "Receipts"), http.StatusOK) +} diff --git a/cmd/agentctl-web/decisions_test.go b/cmd/agentctl-web/decisions_test.go new file mode 100644 index 0000000..ede14c0 --- /dev/null +++ b/cmd/agentctl-web/decisions_test.go @@ -0,0 +1,313 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +const xssAction = `` + +// decisionExport builds an export with an escalated decision a human approved, +// a deny by invariant, and a held decision whose action name is hostile. +func decisionExport(t *testing.T) receipts.Export { + t.Helper() + w, err := receipts.OpenLocal(t.TempDir(), ed25519.NewKeyFromSeed(bytes.Repeat([]byte{4}, 32))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = w.Close() }) + ctx := context.Background() + wl := receipts.Workload{Namespace: "ns", Name: "wl", UID: "uid"} + add := func(action string, layers decision.Layers, res decision.Result) { + rec, err := receipts.NewDecisionRecord(receipts.Params{Workload: wl, Action: action, Layers: layers, Result: res}) + if err != nil { + t.Fatal(err) + } + if _, err := w.Append(ctx, rec); err != nil { + t.Fatal(err) + } + } + add("scale", decision.Layers{ThresholdReasons: []string{"low confidence"}}, + decision.Result{Outcome: decision.RequireApproval, Layer: decision.LayerThreshold}) + human, err := receipts.NewHumanRecord(receipts.HumanParams{Workload: wl, Action: "scale", Approval: receipts.Approval{ + Label: receipts.LabelApprove, Approver: "alice", ApproverSHA256: strings.Repeat("a", 64), PendingID: "p1", OriginalSeq: 1, + }}) + if err != nil { + t.Fatal(err) + } + if _, err := w.Append(ctx, human); err != nil { + t.Fatal(err) + } + add("delete_all", decision.Layers{Invariants: []string{"INV-01: credential in the payload"}}, + decision.Result{Outcome: decision.Deny, Layer: decision.LayerInvariant}) + add(xssAction, decision.Layers{ThresholdReasons: []string{"low confidence"}}, + decision.Result{Outcome: decision.RequireApproval, Layer: decision.LayerThreshold}) + e, err := w.Export() + if err != nil { + t.Fatal(err) + } + return e +} + +// offlineServer serves an export directory the way --export-dir does. +func offlineServer(t *testing.T, e receipts.Export, pinned bool) *httptest.Server { + t.Helper() + dir := filepath.Join(t.TempDir(), "export") + if err := e.WriteDir(dir); err != nil { + t.Fatal(err) + } + cfg := decisionSourceConfig{ExportDir: dir} + if pinned { + pin := filepath.Join(t.TempDir(), "pinned-trust.json") + if err := os.WriteFile(pin, e.TrustRoot, 0o600); err != nil { + t.Fatal(err) + } + cfg.TrustRoot = pin + } + loader, err := newDecisionLoader(cfg) + if err != nil { + t.Fatal(err) + } + pages, err := newDecisionPages(loader, TemplatesFS()) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(offlineHandler(pages)) + t.Cleanup(srv.Close) + return srv +} + +func get(t *testing.T, srv *httptest.Server, path string) (int, string, http.Header) { + t.Helper() + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + res, err := client.Get(srv.URL + path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = res.Body.Close() }() + var buf bytes.Buffer + _, _ = buf.ReadFrom(res.Body) + return res.StatusCode, buf.String(), res.Header +} + +func mustContain(t *testing.T, body string, wants ...string) { + t.Helper() + for _, w := range wants { + if !strings.Contains(body, w) { + t.Errorf("body missing %q", w) + } + } +} + +func TestDecisionsPageVerifiedWithPinnedRoot(t *testing.T) { + srv := offlineServer(t, decisionExport(t), true) + code, body, hdr := get(t, srv, "/decisions") + if code != http.StatusOK { + t.Fatalf("status %d", code) + } + mustContain(t, body, ">Verified", "delete_all", "INV-01", "awaiting approval", "human: approve", "Not covered", "4 of 4 receipts verified", + `href="/theme/clawdlinux-theme.css"`, `href="/decisions/3"`) + if strings.Contains(body, "Do not trust the rows") { + t.Fatal("verified chain shows the failure note") + } + csp := hdr.Get("Content-Security-Policy") + if !strings.Contains(csp, "default-src 'self'") || !strings.Contains(csp, "frame-ancestors 'none'") { + t.Fatalf("CSP = %q", csp) + } + if hdr.Get("Cache-Control") != "no-store" || hdr.Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("headers = %v", hdr) + } +} + +func TestDecisionPagesLoadNothingExternal(t *testing.T) { + srv := offlineServer(t, decisionExport(t), true) + external := regexp.MustCompile(`(?i)(src|href|action)="https?://`) + for _, p := range []string{"/decisions", "/decisions/panel", "/decisions/3", "/receipts"} { + _, body, _ := get(t, srv, p) + if external.MatchString(body) { + t.Errorf("%s references an external URL", p) + } + if strings.Contains(body, "fonts.googleapis.com") { + t.Errorf("%s loads Google Fonts", p) + } + } +} + +func TestDecisionsPageWithoutPinnedRootIsConsistentOnly(t *testing.T) { + srv := offlineServer(t, decisionExport(t), false) + _, body, _ := get(t, srv, "/receipts") + mustContain(t, body, ">Consistent only", "Nobody is authenticated", "taken from the export itself") + if strings.Contains(body, ">Verified") { + t.Fatal("unpinned export shows a Verified badge") + } +} + +func TestDecisionsPageShowsFailureForTamperedRecord(t *testing.T) { + e := decisionExport(t) + e.RecordsJSONL = strings.Replace(e.RecordsJSONL, `"outcome":"deny"`, `"outcome":"allow"`, 1) + srv := offlineServer(t, e, true) + _, body, _ := get(t, srv, "/decisions") + mustContain(t, body, ">Failed", "Do not trust the rows below", "does not match its receipt") + if strings.Contains(body, ">Verified") { + t.Fatal("tampered export shows a Verified badge") + } +} + +func TestDecisionsFilter(t *testing.T) { + srv := offlineServer(t, decisionExport(t), true) + _, body, _ := get(t, srv, "/decisions?outcome=deny") + mustContain(t, body, "delete_all") + if strings.Contains(body, ">scale<") { + t.Fatal("deny filter shows an escalated action") + } + // An unknown filter is ignored, not echoed. + _, body, _ = get(t, srv, `/decisions?outcome=">`) + if strings.Contains(body, "") { + t.Fatal("filter value was reflected") + } + mustContain(t, body, "delete_all") +} + +func TestDecisionsFilterOffersEveryReceiptOutcome(t *testing.T) { + srv := offlineServer(t, decisionExport(t), true) + _, body, _ := get(t, srv, "/decisions") + for _, o := range []string{"allow", "deny", "require_approval", "approved", "rejected", "edited"} { + mustContain(t, body, "/decisions?outcome="+o) + } + // A human outcome filters, it does not fall back to the full list. + _, body, _ = get(t, srv, "/decisions?outcome=approved") + mustContain(t, body, `aria-current="page"`) + if strings.Contains(body, "delete_all") { + t.Fatal("approved filter shows a denied action") + } +} + +func TestDecisionDetail(t *testing.T) { + srv := offlineServer(t, decisionExport(t), true) + code, body, _ := get(t, srv, "/decisions/3") + if code != http.StatusOK { + t.Fatalf("status %d", code) + } + mustContain(t, body, "Decision 3", "INV-01", "credential in the payload", "invariant layer", "Entry hash", "Previous hash") + + _, body, _ = get(t, srv, "/decisions/2") + mustContain(t, body, "Human decision", "approve", `href="/decisions/1"`) + + for _, p := range []string{"/decisions/99", "/decisions/abc", "/decisions/-1"} { + if code, body, _ := get(t, srv, p); code != http.StatusNotFound || !strings.Contains(body, "No such decision") { + t.Errorf("%s: status %d", p, code) + } + } +} + +func TestDecisionPagesEscapeHostileActionNames(t *testing.T) { + srv := offlineServer(t, decisionExport(t), true) + for _, p := range []string{"/decisions", "/decisions/panel", "/decisions/4"} { + _, body, _ := get(t, srv, p) + if strings.Contains(body, xssAction) { + t.Errorf("%s renders the action name unescaped", p) + } + if !strings.Contains(body, "<script>") { + t.Errorf("%s does not show the escaped action name", p) + } + } +} + +func TestDecisionPanelIsAFragment(t *testing.T) { + srv := offlineServer(t, decisionExport(t), true) + _, body, _ := get(t, srv, "/decisions/panel") + if strings.Contains(body, "Failed", "Could not verify", "No decisions to show") +} + +func TestLayoutLinksToDecisionPagesOnlyWhenEnabled(t *testing.T) { + render := func(on bool) string { + server, err := NewServer(nil, nil, TemplatesFS()) + if err != nil { + t.Fatal(err) + } + server.decisionsOn = on + var buf bytes.Buffer + data := map[string]interface{}{"User": &UserInfo{Username: "u"}, "CSRFToken": "t", "Status": nil, "Workloads": nil} + if err := server.tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil { + t.Fatal(err) + } + return buf.String() + } + if body := render(false); strings.Contains(body, `href="/decisions"`) { + t.Fatal("decision links shown without a source") + } + if body := render(true); !strings.Contains(body, `href="/decisions"`) || !strings.Contains(body, `href="/receipts"`) { + t.Fatal("decision links missing when a source is configured") + } +} diff --git a/cmd/agentctl-web/handlers.go b/cmd/agentctl-web/handlers.go index b9fe3a0..22a44ef 100644 --- a/cmd/agentctl-web/handlers.go +++ b/cmd/agentctl-web/handlers.go @@ -23,6 +23,8 @@ type Server struct { demoPollTimeout time.Duration demoPodSource func(context.Context) ([]agentctl.RuntimePodRow, error) demoWorkloadSource func(context.Context) ([]agentctl.WorkloadRow, error) + // decisionsOn adds the Decisions and Receipts links to the navigation. + decisionsOn bool } type demoPageData struct { @@ -42,9 +44,11 @@ type demoPageData struct { // NewServer creates a Server with parsed templates. func NewServer(client *agentctl.Client, authz *Authorizer, tmplFS fs.FS) (*Server, error) { + var server *Server funcMap := template.FuncMap{ - "lower": strings.ToLower, - "safeText": agentctl.SafeText, + "decisionsOn": func() bool { return server != nil && server.decisionsOn }, + "lower": strings.ToLower, + "safeText": agentctl.SafeText, "fmtCost": func(f float64) string { return fmt.Sprintf("$%.4f", f) }, @@ -58,7 +62,7 @@ func NewServer(client *agentctl.Client, authz *Authorizer, tmplFS fs.FS) (*Serve return nil, fmt.Errorf("parse templates: %w", err) } - server := &Server{ + server = &Server{ client: client, authz: authz, tmpl: tmpl, diff --git a/cmd/agentctl-web/main.go b/cmd/agentctl-web/main.go index 160e92b..31df2f2 100644 --- a/cmd/agentctl-web/main.go +++ b/cmd/agentctl-web/main.go @@ -25,15 +25,55 @@ func main() { addr string kubeconfig string demoMode bool + decisions decisionSourceConfig ) flag.StringVar(&addr, "addr", ":8090", "HTTP listen address") flag.StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig (empty = in-cluster)") flag.BoolVar(&demoMode, "demo", false, "Run the booth demo UI without Kubernetes") + flag.StringVar(&decisions.ExportDir, "export-dir", "", "serve the decision pages offline from a receipt export directory (no cluster)") + flag.StringVar(&decisions.WriterURL, "writer-url", "", "receipt-writer base URL for the decision pages") + flag.StringVar(&decisions.TokenFile, "writer-token-file", "", "file holding the receipt-writer bearer token") + flag.StringVar(&decisions.TrustRoot, "trust-root", "", "trust file pinned out of band; without it the chain shows as consistent only") + flag.DurationVar(&decisions.HTTPTimeout, "writer-timeout", 5*time.Second, "HTTP timeout for the receipt-writer") flag.Parse() logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) + addrSet := false + flag.Visit(func(f *flag.Flag) { + if f.Name == "addr" { + addrSet = true + } + }) + + var decisionUI *decisionPages + if decisions.enabled() { + loader, err := newDecisionLoader(decisions) + if err != nil { + slog.Error("decision pages", "error", err) + os.Exit(2) + } + if decisionUI, err = newDecisionPages(loader, TemplatesFS()); err != nil { + slog.Error("decision pages", "error", err) + os.Exit(1) + } + if decisions.TrustRoot == "" { + slog.Warn("no --trust-root: the chain will show as consistent only, not authenticated") + } + } + + // An export directory alone is the offline path: no cluster, no auth, and it + // binds to localhost unless --addr says otherwise. + if decisions.ExportDir != "" && !demoMode { + if !addrSet { + addr = "127.0.0.1:8090" + } + slog.Info("starting agentctl-web offline decision pages", "version", version, "addr", addr, "export", decisions.ExportDir) + runHTTPServer(addr, offlineHandler(decisionUI)) + return + } + slog.Info("starting agentctl-web", "version", version, "addr", addr) if demoMode { @@ -133,6 +173,11 @@ func main() { mux.HandleFunc("GET /cost", srv.handleCost) mux.HandleFunc("GET /status", srv.handleStatus) + if decisionUI != nil { + decisionUI.register(mux) + srv.decisionsOn = true + } + // Middleware chain var handler http.Handler = mux handler = CSRFMiddleware(handler) @@ -143,6 +188,20 @@ func main() { runHTTPServer(addr, handler) } +// offlineHandler serves only the decision pages. It needs no cluster. +func offlineHandler(d *decisionPages) http.Handler { + mux := http.NewServeMux() + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(StaticFS())))) + mux.Handle("GET /theme/", http.StripPrefix("/theme/", http.FileServer(http.FS(webtheme.FS())))) + mux.HandleFunc("GET /healthz", handleHealthz) + mux.HandleFunc("GET /readyz", handleHealthz) + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/decisions", http.StatusSeeOther) + }) + d.register(mux) + return RequestIDMiddleware(AuditMiddleware(mux)) +} + func demoHandler(srv *Server) http.Handler { mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(StaticFS())))) diff --git a/cmd/agentctl-web/static/style.css b/cmd/agentctl-web/static/style.css index 55256a4..e089590 100644 --- a/cmd/agentctl-web/static/style.css +++ b/cmd/agentctl-web/static/style.css @@ -654,3 +654,48 @@ textarea:focus-visible { .workload-card { align-items: flex-start; flex-direction: column; } .workload-card-meta { justify-content: flex-start; max-width: 100%; } } + +/* Decision pages (docs/architecture/demo-dashboard.md) */ +.dv-h1 { font-size: 1.6rem; margin: 8px 0 6px; } +.dv-lede { color: var(--text-secondary); max-width: 760px; margin-bottom: 20px; } +.dv-chain { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); padding: 16px 18px; margin: 16px 0; } +.dv-chain p { margin: 6px 0; } +.dv-chain-title { display: flex; align-items: center; gap: 10px; font-size: 1.05rem; } +.dv-chain-verified { border-color: var(--success); } +.dv-chain-failed { border-color: var(--danger); } +.dv-chain-partial, .dv-chain-consistent { border-color: var(--warning); } +.dv-badge-verified { background: color-mix(in srgb, var(--success) 15%, transparent); color: var(--success); } +.dv-badge-partial, .dv-badge-consistent { background: color-mix(in srgb, var(--warning) 15%, transparent); color: var(--warning); } +.dv-badge-failed { background: color-mix(in srgb, var(--danger) 15%, transparent); color: var(--danger); } +.dv-outcome-allow { background: color-mix(in srgb, var(--success) 15%, transparent); color: var(--success); } +.dv-outcome-deny { background: color-mix(in srgb, var(--danger) 15%, transparent); color: var(--danger); } +.dv-outcome-require_approval { background: color-mix(in srgb, var(--warning) 15%, transparent); color: var(--warning); } +.dv-error { color: var(--danger); } +.dv-meta, .dv-note, .dv-caption { color: var(--text-muted); font-size: 0.85rem; } +.dv-note { margin-left: 8px; } +.dv-caption { text-align: left; padding: 6px 0; } +.dv-mono { font-family: var(--font-evidence); font-size: 0.85rem; word-break: break-all; } +.dv-small { font-size: 1.1rem; } +.dv-tiles { margin: 16px 0; } +.dv-filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; } +.dv-filter { padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-secondary); text-decoration: none; } +.dv-filter-active { border-color: var(--accent); color: var(--text); } +.dv-table { width: 100%; } +.dv-empty { color: var(--text-muted); padding: 24px 0; } +.dv-section { margin: 24px 0; } +.dv-section h2 { font-size: 1.05rem; margin-bottom: 8px; } +.dv-dl { display: grid; grid-template-columns: minmax(140px, 220px) 1fr; gap: 6px 16px; } +.dv-dl dt { color: var(--text-muted); } +.dv-dl dd { margin: 0; overflow-wrap: anywhere; } +.dv-pre { background: var(--bg-input); border: 1px solid var(--border-subtle); border-radius: var(--radius); padding: 12px; overflow-x: auto; font-family: var(--font-evidence); font-size: 0.85rem; } +.dv-notcovered { border: 1px dashed var(--border); border-radius: var(--radius); padding: 14px 18px; margin: 24px 0; color: var(--text-secondary); } +.dv-notcovered h2 { font-size: 1rem; margin-bottom: 6px; } +.dv-notcovered ul { margin-left: 20px; } +@media (max-width: 640px) { + .dv-dl { grid-template-columns: 1fr; } + .dv-table { display: block; overflow-x: auto; } +} + +.dv-outcome-allow, .dv-outcome-approved, .dv-outcome-edited { background: color-mix(in srgb, var(--success) 15%, transparent); color: var(--success); } +.dv-outcome-deny, .dv-outcome-rejected { background: color-mix(in srgb, var(--danger) 15%, transparent); color: var(--danger); } +.dv-outcome-require_approval { background: color-mix(in srgb, var(--warning) 15%, transparent); color: var(--warning); } diff --git a/cmd/agentctl-web/templates/decisions/detail.html b/cmd/agentctl-web/templates/decisions/detail.html new file mode 100644 index 0000000..3e44115 --- /dev/null +++ b/cmd/agentctl-web/templates/decisions/detail.html @@ -0,0 +1,111 @@ +{{define "title"}}{{if .Detail}}Decision {{.Detail.Row.Seq}}{{else}}Not found{{end}}{{end}} +{{define "content"}} +

Back to decisions

+{{if .NotFound}} +

No such decision

+

The chain has no decision with that sequence number.

+{{else}} +{{with .Detail}} +

Decision {{.Row.Seq}}

+

+ {{outcome .Row.Outcome}} + decided by the {{.Row.Layer}} layer for {{.Row.Namespace}}/{{.Row.Workload}} at {{ts .Row.Time}}. + {{if .Row.Held}}No human has decided it yet.{{end}} +

+ +
+

Action

+
+
Action
{{.Record.Action}}
+
Policy packs
{{if .Record.PolicyPacks}}{{join .Record.PolicyPacks}}{{else}}none{{end}}
+
Threshold mode
{{if .Record.ThresholdMode}}{{.Record.ThresholdMode}}{{else}}not recorded{{end}}
+
+
+ +
+

Why

+ {{if .Record.Reasons}} + + + + {{range .Record.Reasons}}{{end}} + +
RuleFinding
{{.RuleID}}{{.Reason}}
+ {{else}} +

No rule objected. The action was within the declared intent.

+ {{end}} +
+ +
+

What the platform observed

+
+
Destination host
{{if .Record.Observed.DestinationHost}}{{.Record.Observed.DestinationHost}}{{else}}none{{end}}
+
Data classes found
{{if .Record.Observed.DataClasses}}{{join .Record.Observed.DataClasses}}{{else}}none{{end}}
+
Tool
{{if .Record.Observed.Tool}}{{.Record.Observed.Tool}}{{else}}none{{end}}
+
Earlier actions
{{.Record.Observed.PriorActionCount}} ({{.Record.Observed.PriorDeniedCount}} denied)
+
+

The record holds class names and a host, never the raw payload.

+
+ +
+

What was declared

+
+
Decision type
{{if .Record.Declared.DecisionType}}{{.Record.Declared.DecisionType}}{{else}}not declared{{end}}
+
Allowed data classes
{{if .Record.Declared.AllowedDataClasses}}{{join .Record.Declared.AllowedDataClasses}}{{else}}none{{end}}
+
Allowed destinations
{{if .Record.Declared.AllowedDestinations}}{{join .Record.Declared.AllowedDestinations}}{{else}}none{{end}}
+
+
+ +{{if or .Record.Claimed.Confidence .Record.Claimed.ClusterHealth}} +
+

What the agent claimed

+
+
Confidence
{{if .Record.Claimed.Confidence}}{{.Record.Claimed.Confidence}}{{else}}not claimed{{end}}
+
Cluster health
{{if .Record.Claimed.ClusterHealth}}{{.Record.Claimed.ClusterHealth}}{{else}}not claimed{{end}}
+
+

Claims can only tighten a decision. They never loosen it.

+
+{{end}} + +{{with .Record.Model}} +
+

Decision model

+
+
Mode
{{.Mode}}
+
Model
{{.ID}} {{.Version}}
+
Risk
{{micro .RiskMicro}} against threshold {{micro .ThresholdMicro}}
+
Risk with claims
{{micro .ClaimedRiskMicro}}
+
Risk without claims
{{micro .BaselineRiskMicro}}
+
Outcome before the model
{{outcome .BaseOutcome}}
+
Outcome after the model
{{outcome .Outcome}}
+ {{if .Error}}
Scorer error
{{.Error}}
{{end}} +
+

The model can only escalate. In shadow mode it is recorded and changes nothing.

+
+{{end}} + +{{with .Record.Approval}} +
+

Human decision

+
+
Label
{{.Label}}
+
Approver
{{short .Approver}}
+
Answers decision
{{.OriginalSeq}}
+ {{if .EditedFields}}
Edited fields
{{join .EditedFields}}
{{end}} +
+

The approver is a digest of the identity the admission webhook stamped. The reason is hashed.

+
+{{end}} + +
+

Receipt

+
+
Signer
{{.SignerKID}}
+
Entry hash
{{.EntryHash}}
+
Previous hash
{{.PrevHash}}
+
+
+{{end}} +{{end}} +{{template "chainstatus" .}} +{{end}} diff --git a/cmd/agentctl-web/templates/decisions/home.html b/cmd/agentctl-web/templates/decisions/home.html new file mode 100644 index 0000000..1de3ce7 --- /dev/null +++ b/cmd/agentctl-web/templates/decisions/home.html @@ -0,0 +1,7 @@ +{{define "title"}}Decisions{{end}} +{{define "content"}} +

What the operator decided

+

Every row is a decision the operator made before an agent action ran, bound to a signed receipt. The chain badge says whether you can trust the rows.

+{{template "panel" .}} +{{template "notcovered" .}} +{{end}} diff --git a/cmd/agentctl-web/templates/decisions/layout.html b/cmd/agentctl-web/templates/decisions/layout.html new file mode 100644 index 0000000..9d2fe4e --- /dev/null +++ b/cmd/agentctl-web/templates/decisions/layout.html @@ -0,0 +1,31 @@ +{{define "layout"}} + + + + + {{template "title" .}} | Clawdlinux + + + + + + + +
+ {{template "content" .}} +
+ + + +{{end}} diff --git a/cmd/agentctl-web/templates/decisions/panel.html b/cmd/agentctl-web/templates/decisions/panel.html new file mode 100644 index 0000000..05ac89c --- /dev/null +++ b/cmd/agentctl-web/templates/decisions/panel.html @@ -0,0 +1,80 @@ +{{define "chainstatus"}} +
+

+ Receipt chain + {{statusLabel .Status}} +

+

{{statusNote .Status}}

+ {{if .View.Verification.Error}}

Could not verify: {{.View.Verification.Error}}

{{end}} + {{if .View.Verification.Reason}}

Chain check failed at seq {{.View.Verification.FailedAtSeq}}: {{.View.Verification.Reason}}

{{end}} + {{range .View.Verification.RecordFailures}}

Record seq {{.Seq}} does not match its receipt: {{.Reason}}

{{end}} + {{if .View.Err}}

Showing the last good data, loaded {{ts .View.LoadedAt}}. {{.View.Err}}

{{end}} +

+ {{.View.Verification.VerifiedCount}} of {{.View.Verification.TotalReceipts}} receipts verified, + head seq {{.View.Verification.HeadSeq}}, + loaded {{age .View.LoadedAt}} ago. +

+
+{{end}} + +{{define "notcovered"}} +
+

Not covered

+ +
+{{end}} + +{{define "panel"}} +
+ {{template "chainstatus" .}} + +
+
{{.View.Summary.Total}}
Decisions
+
{{index .View.Summary.ByOutcome "allow"}}
Allowed
+
{{index .View.Summary.ByOutcome "deny"}}
Denied
+
{{.View.Summary.Held}}
Awaiting approval
+
{{.View.Summary.Human}}
Human decisions
+
{{if .View.Summary.ModelModes}}{{join .View.Summary.ModelModes}}{{else}}off{{end}}
Model mode
+
+ + + + {{if .Rows}} + + + + + + + {{range .Rows}} + + + + + + + + + + {{end}} + +
Newest first. Showing {{.Shown}} of {{.View.Summary.Total}}.
SeqTimeWorkloadActionLayerOutcomeReasons
{{.Seq}}{{ts .Time}}{{.Namespace}}/{{.Workload}}{{.Action}}{{.Layer}} + {{outcome .Outcome}} + {{if .HumanLabel}}human: {{.HumanLabel}}{{end}} + {{if .Held}}awaiting approval{{end}} + {{if .ReasonCodes}}{{join .ReasonCodes}}{{else}}none{{end}}
+ {{else}} +

No decisions to show{{if .Filter}} for this filter{{end}}.

+ {{end}} +
+{{end}} diff --git a/cmd/agentctl-web/templates/decisions/receipts.html b/cmd/agentctl-web/templates/decisions/receipts.html new file mode 100644 index 0000000..d79215b --- /dev/null +++ b/cmd/agentctl-web/templates/decisions/receipts.html @@ -0,0 +1,27 @@ +{{define "title"}}Receipts{{end}} +{{define "content"}} +

Receipt chain

+

Each decision gets an Ed25519-signed receipt that links to the one before it. This page checks the chain in the dashboard process against a trust root you pinned.

+{{template "chainstatus" .}} + +
+

Head

+
+
Head seq
{{.View.Verification.HeadSeq}}
+
Head entry hash
{{if .View.Verification.HeadHash}}{{.View.Verification.HeadHash}}{{else}}none{{end}}
+
Receipts verified
{{.View.Verification.VerifiedCount}} of {{.View.Verification.TotalReceipts}}
+
Decision records bound
{{.View.Verification.RecordsBound}}
+
Signed manifest
{{if .View.Verification.HasManifest}}present{{else}}absent{{end}}
+
Completeness
{{if .View.Verification.Complete}}proven by the manifest{{else}}not proven{{end}}
+
Trust root
{{if .View.Verification.Authenticated}}pinned out of band{{else}}taken from the export itself{{end}}
+
+
+ +
+

Check it yourself

+

Export the chain and verify it on another machine with a trust file you hold:

+
agentctl receipts export --writer URL --token-file ./token --out ./export
+agentctl receipts verify ./export --trust-root ./pinned-trust.json
+
+{{template "notcovered" .}} +{{end}} diff --git a/cmd/agentctl-web/templates/layout.html b/cmd/agentctl-web/templates/layout.html index 029d413..a80ff6e 100644 --- a/cmd/agentctl-web/templates/layout.html +++ b/cmd/agentctl-web/templates/layout.html @@ -32,6 +32,10 @@ Workloads Cost Status + {{if decisionsOn}} + Decisions + Receipts + {{end}}