From 027dc3a0f3bdf56ebfbef0a62cdcd4d24880218b Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Thu, 1 Oct 2026 23:41:11 +0530 Subject: [PATCH 01/54] docs: align claims with code, add decision architecture ADR Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- CHANGELOG.md | 1 + README.md | 28 ++++++++----- ROADMAP.md | 17 ++++---- config/policies/README.md | 13 +++--- config/policies/kustomization.yaml | 1 + docs/04-architecture.md | 4 ++ docs/07-security.md | 11 +++-- docs/SHOWCASE-DEMO-WALKTHROUGH.md | 2 +- docs/architecture/decision-architecture.md | 47 ++++++++++++++++++++++ docs/security/README.md | 6 +-- docs/security/cross-cluster-identity.md | 6 +-- docs/security/threat-model.md | 13 ++++-- pkg/opa/evaluator.go | 16 ++++++-- 13 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 docs/architecture/decision-architecture.md diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3471f..1b2754f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `agentctl doctor sandbox` to verify RuntimeClass and ready-node sandbox evidence before workload deployment. ### Changed +- Docs: removed claims not backed by code. Added the decision architecture ADR (`docs/architecture/decision-architecture.md`). Rego files are labelled samples; the action path is a Go threshold evaluator. - Changed the internal Go `CostReporter.RecordUsage` and `Provider.CallModel` interfaces to carry operation IDs for idempotency. This breaks out-of-tree implementations. - Added the optional `litellm.anthropicKey` chart value. Use `litellm.existingSecret` for booth and production deployments. diff --git a/README.md b/README.md index 1f97177..53d82c0 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,14 @@ The repository currently ships the `AgentWorkload` lifecycle, runtime adapters, The target contract connects caller identity, declared access, action policy, approval, cost, outcome, and independently verifiable evidence in one transaction. That target is the product direction, not a claim about the current end-to-end path. +How an action is decided, as a target design (see [decision architecture](docs/architecture/decision-architecture.md)): + +1. Invariants in code. Credentials never reach the agent. Egress only to declared destinations. +2. A decision model scores each action. It can only escalate to a human at first. +3. Humans approve or reject. Every decision is a signed, labelled example. + +Today the repo ships a threshold evaluator, not this model. The ADR lists what is built and what is not. + | Capability | Current repository state | |---|---| | Runtime isolation | gVisor `RuntimeClass` mutation for labeled pods; nodes must provide `runsc` | @@ -131,7 +139,7 @@ It labels current-run, configuration-only, and prior-run evidence separately. ## Agent-callable API (MCP) -Clawdlinux's `AgentWorkload` CRD is already an agent-readable interface — agents +Clawdlinux's `AgentWorkload` CRD is already an agent-readable interface. Agents can read the schema and reason about the spec. `agentctl mcp serve` is the **wire-protocol** surface so an external orchestrator agent (Claude Desktop, Cursor, ChatGPT, custom Python) can provision its own Clawdlinux execution @@ -152,12 +160,12 @@ Full reference in [`docs/agentctl/mcp.md`](docs/agentctl/mcp.md). Examples in ## Quick Start -**Option A — One command (requires kind + helm):** +**Option A: One command (requires kind + helm):** ```bash curl -sSL https://raw.githubusercontent.com/Clawdlinux/agentic-operator-core/main/scripts/install.sh | bash ``` -**Option B — Step by step:** +**Option B: Step by step:** ```bash git clone https://github.com/Clawdlinux/agentic-operator-core cd agentic-operator-core @@ -180,7 +188,7 @@ kubectl apply -f config/agentworkload_example.yaml kubectl -n agentic-system get agentworkloads -w ``` -**Option C — GitHub Codespaces (zero local setup):** +**Option C: GitHub Codespaces (zero local setup):** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/Clawdlinux/agentic-operator-core?devcontainer_path=.devcontainer/devcontainer.json) @@ -283,7 +291,7 @@ assets/ Branding assets (logo, etc.) | [Architecture](docs/04-architecture.md) | System design deep dive | | [Multi-tenancy](docs/05-multi-tenancy.md) | Tenant isolation and quota enforcement | | [Cost Management](docs/06-cost-management.md) | Per-workload billing and chargeback | -| [Security](docs/07-security.md) | Cilium, OPA, RBAC, and egress hardening | +| [Security](docs/07-security.md) | Cilium, action rules, RBAC, and egress hardening | | [Troubleshooting](docs/10-troubleshooting.md) | Common issues and fixes | --- @@ -327,18 +335,18 @@ See [ROADMAP.md](ROADMAP.md) for the public roadmap and quarterly milestones. Design proposals in flight live in [`docs/rfcs/`](docs/rfcs/). Currently in design: -- **[RFC-0001: Cross-Cluster Agent Identity Federation (SPIFFE/SPIRE)](docs/rfcs/0001-cross-cluster-agent-identity.md)** — multi-cluster identity for agents in air-gapped and regulated environments. Validation gate: 6+ use cases or 1 paying customer. _GitHub Discussion opens shortly; track status in epic [#146](https://github.com/Clawdlinux/agentic-operator-core/issues/146)._ +- **[RFC-0001: Cross-Cluster Agent Identity Federation (SPIFFE/SPIRE)](docs/rfcs/0001-cross-cluster-agent-identity.md)**: multi-cluster identity for agents in air-gapped and regulated environments. Validation gate: 6+ use cases or 1 paying customer. _GitHub Discussion opens shortly; track status in epic [#146](https://github.com/Clawdlinux/agentic-operator-core/issues/146)._ --- ## Community -- **Discord** — [Join our Discord](https://discord.gg/r4QhZJQgV) for questions, discussions, and design partner conversations -- **Issues** — [Report bugs or request features](https://github.com/Clawdlinux/agentic-operator-core/issues) -- **Releases** — [Subscribe to releases](https://github.com/Clawdlinux/agentic-operator-core/releases) for changelog updates +- **Discord**: [Join our Discord](https://discord.gg/r4QhZJQgV) for questions, discussions, and design partner conversations +- **Issues**: [Report bugs or request features](https://github.com/Clawdlinux/agentic-operator-core/issues) +- **Releases**: [Subscribe to releases](https://github.com/Clawdlinux/agentic-operator-core/releases) for changelog updates --- ## License -Apache License 2.0 — See [LICENSE](LICENSE). +Apache License 2.0. See [LICENSE](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index 8c7f53e..4bf0d0d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Public roadmap for Clawdlinux. Updated quarterly. For the architecture and use case, read [docs/DESIGN.md](docs/DESIGN.md) first. +Public roadmap for Clawdlinux. Updated quarterly. No dates are committed. For the architecture and use case, read [docs/DESIGN.md](docs/DESIGN.md) first. ## The problem we are solving @@ -19,7 +19,7 @@ and cost components. Same-run capture and enforcement parity are not complete. - **Mar to Apr 2026.** Core platform hardened: Cilium FQDN egress generation, LiteLLM routing, MinIO artifacts, Python LangGraph runtime with A2A, full-cycle integration tests, Helm umbrella chart. - **May 2026.** ACP (Agent Contract Protocol) spun out as its own repo and spec. Benchmarks landed: 64.7% to 97.4% token reduction vs raw MCP, one round trip. Briefly explored a consumer AgentOS direction; reversed within the month. Enterprise K8s is the business. - **Jun 2026.** Positioning locked: we sell the governance and evidence plane, not a runtime. Runtime adapters, gVisor mutation, and offline JSONL verification primitives shipped. Governance parity and same-run capture remained incomplete. -- **Jul 2026.** Demo gate (`scripts/demo-booth.sh`) reproducible on kind. Focus: validation conversations and the Jul 22 Agentic Summit booth. Fintech is the anchor vertical. +- **Jul 2026.** Demo gate (`scripts/demo-booth.sh`) reproducible on kind. Focus: validation conversations. Fintech is the anchor vertical. ## Scope note @@ -46,16 +46,16 @@ Strong CNCF base runtimes for agents on Kubernetes exist and are improving. Claw - [x] HMAC hash-chain and offline JSONL `audit-verify` - [x] gVisor RuntimeClass + pod admission injector for labeled agent pods - [x] Runtime adapter interface (AgentWorkload, BYO pods, external runtimes) -- [x] Rego policy samples and an in-process Go action evaluator +- [x] Rego policy samples (not evaluated) and an in-process Go threshold evaluator - [x] Grafana observability and per-workload cost dashboards - [x] Reproducible booth demo gate on kind -## Now (Q3 2026) +## Now Priority order. Validation before features. -- [ ] 10 qualified platform/security conversations by Sep 15, including 3 active production-review blockers and 1 scoped design-partner engagement. This is the one-time reset of the missed Jul 15 criterion. -- [ ] Jul 22 Agentic Summit booth demo, fintech use case +- [ ] Qualified platform and security conversations, including active production-review blockers and 1 scoped design-partner engagement +- [ ] Fintech booth demo use case - [ ] Air-gapped install smoke test in CI - [ ] Webhook admission controller for CRD validation - [ ] Per-runtime sandbox label guide @@ -63,8 +63,11 @@ Priority order. Validation before features. - [ ] Runtime governance-label parity and enforcing-CNI packet tests - [ ] ACP RemoteMCPServer wrapper example - [ ] Homebrew tap for agentctl +- [ ] Observed decision inputs. The platform measures destination, data classes and caller. Agent self-report only tightens. +- [ ] Rule engine (OPA Go library) with invariants and policy packs +- [ ] Decision model: escalate-only scoring, shadow mode first. See [decision architecture](docs/architecture/decision-architecture.md) -## Later (Q4 2026+, all validation-gated) +## Later (all validation-gated) - [ ] Cross-cluster agent identity federation (SPIFFE/SPIRE). RFC: [docs/rfcs/0001-cross-cluster-agent-identity.md](docs/rfcs/0001-cross-cluster-agent-identity.md). Gate: 6+ distinct external use cases or 1 paying customer request. - [ ] Multi-cluster federation diff --git a/config/policies/README.md b/config/policies/README.md index 8c65fcc..bacb85e 100644 --- a/config/policies/README.md +++ b/config/policies/README.md @@ -1,12 +1,15 @@ -# OPA Policy Library +# Rego policy samples (not evaluated yet) -Reusable policy samples for common AgentWorkload guardrails. +These are sample Rego files. The operator does not execute them. The direct +action path uses a Go threshold evaluator. A real rule engine is on the +[roadmap](../../ROADMAP.md). See the +[decision architecture](../../docs/architecture/decision-architecture.md). ## What is included -- `samples/budget-cap.rego` enforces monthly or per-run budget ceilings. -- `samples/egress-allowlist.rego` blocks outbound domains outside an allow-list. -- `samples/model-allowlist.rego` restricts providers and models to approved sets. +- `samples/budget-cap.rego` is a sample rule for monthly or per-run budget ceilings. +- `samples/egress-allowlist.rego` is a sample rule for outbound domains outside an allow-list. +- `samples/model-allowlist.rego` is a sample rule for restricting providers and models to approved sets. ## Package into a ConfigMap diff --git a/config/policies/kustomization.yaml b/config/policies/kustomization.yaml index a5fac63..b638050 100644 --- a/config/policies/kustomization.yaml +++ b/config/policies/kustomization.yaml @@ -1,3 +1,4 @@ +# Sample Rego files only. The operator does not evaluate them yet. apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 24dd1d7..1a9e883 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -121,4 +121,8 @@ Cost reporting interface: Audit capture is not part of either complete path today. +For how an action is decided (invariants, decision model, human approval) and +what is built versus planned, see +[decision architecture](architecture/decision-architecture.md). + For detailed flows, see respective controller documentation. diff --git a/docs/07-security.md b/docs/07-security.md index c6de713..682022c 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -48,7 +48,12 @@ opaPolicy: strict ``` It evaluates action type, caller-supplied confidence, cluster health, and strict -or permissive mode. Read-only actions use a separate allow path. +or permissive mode. Read-only actions use a separate allow path. Confidence comes +from the `propose_action` tool reply. Cluster health comes from the MCP status +reply and defaults to 75 when absent. The platform measures neither, so both are +agent-claimed inputs. +They can tighten a decision. They must never loosen one. See +[decision architecture](architecture/decision-architecture.md). The repository also ships Rego samples in a ConfigMap. The direct action path does not execute those Rego files or call a real OPA engine today. Treat them as @@ -67,7 +72,7 @@ The umbrella Helm chart ships a default-deny egress NetworkPolicy for pods labeled `app.kubernetes.io/part-of: agentic-operator`. Toggle via `networkPolicy.enabled` (default `true`). Allow-listed: kube-dns, the in-cluster LiteLLM proxy, Postgres, MinIO, Browserless (when enabled), and -external OPA (when configured). Operator-supplied additions go under +an external OPA service (when `networkPolicy.opa` is set; the egress rule only, no policy evaluation). Operator-supplied additions go under `networkPolicy.additionalAllowedHosts`. Verified by helm-unittest in [`charts/tests/networkpolicy_test.yaml`](../charts/tests/networkpolicy_test.yaml). Managed workload namespaces require separate policy application and matching labels. @@ -98,7 +103,7 @@ project, specifically the platform-default seccomp filter shipped in [`pkg/seccomp/seccomp_amd64.go`](https://github.com/google/gvisor/tree/master/pkg/seccomp) plus the per-runtime additions documented in [`runsc/boot/filter`](https://github.com/google/gvisor/tree/master/runsc/boot/filter). -We do not maintain a fork — we deliberately track upstream so security fixes land +We do not maintain a fork; we deliberately track upstream so security fixes land without lag. Kata Containers may be evaluated separately when the customer requires a diff --git a/docs/SHOWCASE-DEMO-WALKTHROUGH.md b/docs/SHOWCASE-DEMO-WALKTHROUGH.md index 2f5a248..ee2b6c9 100644 --- a/docs/SHOWCASE-DEMO-WALKTHROUGH.md +++ b/docs/SHOWCASE-DEMO-WALKTHROUGH.md @@ -236,7 +236,7 @@ Protect the closing slide. Under time pressure, cut the opener slide first. - Do not say replay is live. - Do not claim same-run signed attestation is complete. - Do not claim customers, revenue, deployments, or production adoption. -- Do not mention OPA unless someone asks about it. +- If asked about OPA: the Rego files are samples that nothing evaluates. The action path is a Go threshold check on agent-claimed inputs. - Do not call configuration evidence runtime proof. ## One-line close diff --git a/docs/architecture/decision-architecture.md b/docs/architecture/decision-architecture.md new file mode 100644 index 0000000..e3fc74b --- /dev/null +++ b/docs/architecture/decision-architecture.md @@ -0,0 +1,47 @@ +# ADR: how Clawdlinux decides allow / deny / approve + +Status: proposed 2026-10-01. Source of truth for docs, deck, site, goal prompt. If another doc disagrees, this wins until changed here. + +## Decision +Three layers. Model scores. Rules bound. Humans resolve uncertainty. Everything signed. + +1. Invariants (code, tiny, deterministic). Things that must never happen whatever any model says. Credentials never reach agent. Egress only to declared destinations. Personal data never leaves cluster to undeclared destination. Receipts required or workload fails closed. Target: under 15 invariants. Model cannot override. +2. Decision model (learned, scores each action). Takes observed facts plus declared purpose plus history. Returns risk score, per-option weights, reason codes, calibration. Phase A escalate-only: can push to approval, never turn deny into allow. Phase B bounded autonomy: may auto-allow inside invariants when calibrated on approval history above threshold, per action class, with rollback. +3. Human approval. Resolves low-confidence and high-impact. Each decision is a labelled example (approve, reject, edit) in the signed approval dataset. + +## Inputs, by trust +- Declared (manifest, human-reviewed in PR): purpose, decision type, allowed data classes, allowed destinations. +- Observed (platform measures): destination, detected data classes (deterministic detectors), tokens, tool, caller identity, history. +- Agent-claimed: self-reported confidence and intent. Logged in receipt. Used only to tighten. Never loosens. + +## Why not model-only +Auditor needs reproducible "why". Model gate is itself a model that must be validated. Prompt injection moves model verdicts. So model never owns the floor. + +## Why not rules-only +Rule count explodes, edge cases never end. Policy documents are prose. So model compiles policy prose into rule drafts (human reviews diff once) and scores the gray area. + +## Receipt must record for every model decision +model id and version, input hash, option set and order, per-option probabilities, threshold used, outcome, layer that decided. Replay with same inputs must reproduce the score (pin version, temperature 0, fixed seed). Air-gap: model runs in cluster (self-hosted small model). No hosted scoring API in the default path. + +## Today vs target (verified in code 2026-10-01) +- pkg/opa/evaluator.go: hardcoded Go thresholds, not OPA. go.mod has no OPA. .rego ships as ConfigMap, unevaluated. +- Input confidence comes from the propose_action tool reply. cluster_health comes from the MCP status reply, default 75 when absent (controller ~L436-538). Platform measures neither. +- "Destructive" = string match on action name. +- pkg/evaluation/scorer.go: keyword heuristic, post hoc, blocks nothing. +- AgentGate: deterministic, fine as invariant layer example. +- No learned gate exists. Layer 2 is new work. + +## Build order +1. Measure, stop trusting agent self-report (observed inputs). +2. Real rule engine (OPA Go lib) for invariants and packs. +3. Receipts for every decision (shared receiptspec). +4. Approval dataset (already in goal prompt phase 4). +5. Decision model phase A (escalate-only), evaluated offline on approval dataset first, shadow mode before it can affect anything. +6. Phase B only after shadow results and calibration are documented. + +## Candidate model types (decide by spike, not now) +Small fine-tuned classifier on structured features (cheap, fast, auditable features). Small local LLM judge with typed options (Jev-style, AgentJev-0.6B is Apache-2.0). Start with the classifier. LLM judge as second signal. Both escalate-only at first. + +## Open +- Feature set and labels: seeded from approval dataset, but dataset is empty until first PoC. Need synthetic seed from founder-written scenarios (50 to 100) to bootstrap shadow mode. +- Who owns threshold changes (customer security team) and how they are versioned. diff --git a/docs/security/README.md b/docs/security/README.md index a74732a..98a1728 100644 --- a/docs/security/README.md +++ b/docs/security/README.md @@ -11,6 +11,6 @@ Security guides, threat models, and identity architecture for Clawdlinux. ## Related -- [Security policy](../../SECURITY.md) — vulnerability reporting and SLAs -- [`docs/07-security.md`](../07-security.md) — runtime security guide (RBAC, action policy, licensing, and enforcement prerequisites) -- [RFC index](../rfcs/README.md) — open design proposals +- [Security policy](../../SECURITY.md): vulnerability reporting and SLAs +- [`docs/07-security.md`](../07-security.md): runtime security guide (RBAC, action policy, licensing, and enforcement prerequisites) +- [RFC index](../rfcs/README.md): open design proposals diff --git a/docs/security/cross-cluster-identity.md b/docs/security/cross-cluster-identity.md index 3180053..1c53742 100644 --- a/docs/security/cross-cluster-identity.md +++ b/docs/security/cross-cluster-identity.md @@ -33,6 +33,6 @@ Comment on the [discussion](https://github.com/Clawdlinux/agentic-operator-core/ ## See also -- [RFC-0001](../rfcs/0001-cross-cluster-agent-identity.md) — full design proposal -- [Threat model](threat-model.md) — security boundaries this design must preserve -- [A2A architecture](../a2a-architecture.md) — current agent-to-agent protocol +- [RFC-0001](../rfcs/0001-cross-cluster-agent-identity.md): full design proposal +- [Threat model](threat-model.md): security boundaries this design must preserve +- [A2A architecture](../a2a-architecture.md): current agent-to-agent protocol diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index e167afe..81c758e 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -1,10 +1,10 @@ -# Threat Model — Clawdlinux +# Threat Model: Clawdlinux > Last updated: 2026-04-21 ## Scope -This document describes the security boundaries, threat vectors, and mitigations for the Clawdlinux platform — a Kubernetes-native system for scheduling, isolating, and managing AI agent workloads. +This document describes the security boundaries, threat vectors, and mitigations for the Clawdlinux platform, a Kubernetes-native system for scheduling, isolating, and managing AI agent workloads. ## Trust Boundaries @@ -86,6 +86,13 @@ This document describes the security boundaries, threat vectors, and mitigations | Agent calls unauthorized tools | `toolProfile` is enforced in selected runtime paths, not universally | Partial | | Tenant escalates to cluster admin | RBAC: tenant SA can only CRUD AgentWorkload in own namespace | Implemented | +## Decision Inputs + +| Threat | Mitigation | Status | +|--------|-----------|--------| +| Agent self-reported inputs (`confidence`, `cluster_health`) drive the action decision | Treated as agent-claimed. They may tighten a decision, never loosen it. Platform-observed inputs are planned. See [decision architecture](../architecture/decision-architecture.md) | Open | +| Prompt injection moves a model gate verdict | A model never owns the floor. Invariants in code bound every decision. No model gate exists today | Design | + ## Network Security ### Egress Control @@ -109,7 +116,7 @@ Agent pods are restricted to allowlisted domains. Two layers of enforcement: External destinations require explicit chart or optional Cilium configuration. -> **Note**: Cilium FQDN egress requires Cilium CNI. Vanilla Kubernetes installs use standard NetworkPolicy (namespace-level ingress/egress only) — that is what the chart ships by default. +> **Note**: Cilium FQDN egress requires Cilium CNI. Vanilla Kubernetes installs use standard NetworkPolicy (namespace-level ingress/egress only): that is what the chart ships by default. ### Identity Boundary diff --git a/pkg/opa/evaluator.go b/pkg/opa/evaluator.go index dd89fcb..07770a8 100644 --- a/pkg/opa/evaluator.go +++ b/pkg/opa/evaluator.go @@ -14,6 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ +// Package opa holds a hardcoded Go threshold evaluator. It is not an OPA +// engine: it does not load or run Rego, and go.mod has no OPA dependency. +// Its confidence and cluster health inputs are claimed by the agent, so they +// may tighten a decision but must never loosen one. See +// docs/architecture/decision-architecture.md. package opa import ( @@ -22,8 +27,9 @@ import ( "strings" ) -// PolicyEvaluator simulates OPA policy evaluation logic -// In production, this would call the actual OPA engine via REST API or embedded Wasm module +// PolicyEvaluator applies fixed Go thresholds to an action. It does not +// evaluate Rego. Planned replacement: a threshold package under pkg/rules plus +// a real rule engine, per the decision architecture ADR. type PolicyEvaluator struct { // policies would be loaded from compiled OPA modules in production } @@ -45,7 +51,11 @@ type EvaluationResult struct { Reasons []string `json:"reasons"` // Why it was allowed or denied } -// NewPolicyEvaluator creates a new OPA policy evaluator +// NewPolicyEvaluator creates the threshold evaluator. +// +// Planned for replacement by pkg/rules/threshold. The formal deprecation +// marker is withheld until the controller call site migrates, because +// staticcheck would fail the build on that caller. func NewPolicyEvaluator() *PolicyEvaluator { return &PolicyEvaluator{} } From 4b02cca16da27518701c8e5503c4dbd35a1208e4 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Thu, 1 Oct 2026 23:48:13 +0530 Subject: [PATCH 02/54] feat(decision): add observed decision inputs and declared intent Split decision inputs by trust: declared, observed, agent-claimed. Add pkg/dataclass deterministic detectors (email, phone, aadhaar, pan, iban, card). Add pkg/decision/input, pkg/decision/observe, and decision.Combine so claimed values can only tighten. Add optional spec.declaredIntent to AgentWorkload. On the legacy action path, undeclared data classes or destinations deny in strict mode and escalate otherwise. Behaviour without declaredIntent is unchanged. Docs: docs/policy-input.md, sample manifest, ADR and README links. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- CHANGELOG.md | 1 + README.md | 1 + api/v1alpha1/agentworkload_types.go | 30 ++ api/v1alpha1/zz_generated.deepcopy.go | 30 ++ ...agentic.clawdlinux.org_agentworkloads.yaml | 31 ++ .../agentworkload_declared_intent.yaml | 23 ++ docs/architecture/decision-architecture.md | 3 +- docs/policy-input.md | 86 +++++ .../controller/agentworkload_controller.go | 27 ++ .../agentworkload_controller_opa_test.go | 69 ++++ pkg/dataclass/dataclass.go | 323 ++++++++++++++++++ pkg/dataclass/dataclass_test.go | 141 ++++++++ pkg/decision/decision.go | 27 ++ pkg/decision/decision_test.go | 30 ++ pkg/decision/input/input.go | 134 ++++++++ pkg/decision/input/input_test.go | 61 ++++ pkg/decision/observe/observe.go | 78 +++++ pkg/decision/observe/observe_test.go | 83 +++++ 18 files changed, 1177 insertions(+), 1 deletion(-) create mode 100644 config/samples/agentworkload_declared_intent.yaml create mode 100644 docs/policy-input.md create mode 100644 pkg/dataclass/dataclass.go create mode 100644 pkg/dataclass/dataclass_test.go create mode 100644 pkg/decision/decision.go create mode 100644 pkg/decision/decision_test.go create mode 100644 pkg/decision/input/input.go create mode 100644 pkg/decision/input/input_test.go create mode 100644 pkg/decision/observe/observe.go create mode 100644 pkg/decision/observe/observe_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b2754f..4b63d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a working booth prepare/present flow with real provider and cost proof. - Added `agentctl doctor sandbox` to verify RuntimeClass and ready-node sandbox evidence before workload deployment. +- Added optional `spec.declaredIntent` on `AgentWorkload` and observed decision inputs. The legacy action path now denies or escalates actions that send undeclared data classes or call undeclared hosts. Agent-claimed confidence and cluster health can only tighten. New packages: `pkg/dataclass`, `pkg/decision`, `pkg/decision/input`, `pkg/decision/observe`. See `docs/policy-input.md`. ### Changed - Docs: removed claims not backed by code. Added the decision architecture ADR (`docs/architecture/decision-architecture.md`). Rego files are labelled samples; the action path is a Go threshold evaluator. diff --git a/README.md b/README.md index 53d82c0..bef2dc3 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,7 @@ assets/ Branding assets (logo, etc.) | [Multi-tenancy](docs/05-multi-tenancy.md) | Tenant isolation and quota enforcement | | [Cost Management](docs/06-cost-management.md) | Per-workload billing and chargeback | | [Security](docs/07-security.md) | Cilium, action rules, RBAC, and egress hardening | +| [Policy input](docs/policy-input.md) | Declared, observed, and agent-claimed decision inputs | | [Troubleshooting](docs/10-troubleshooting.md) | Common issues and fixes | --- diff --git a/api/v1alpha1/agentworkload_types.go b/api/v1alpha1/agentworkload_types.go index b118917..d44b12c 100644 --- a/api/v1alpha1/agentworkload_types.go +++ b/api/v1alpha1/agentworkload_types.go @@ -139,6 +139,36 @@ type AgentWorkloadSpec struct { // persona defines optional runtime identity, style, memory scope, and tool access policy. // +optional Persona *AgentPersona `json:"persona,omitempty"` + + // declaredIntent states what this workload is for and what it may touch. + // When set, observed data classes and destinations outside it deny or + // escalate the action. See docs/policy-input.md. + // +optional + DeclaredIntent *DeclaredIntent `json:"declaredIntent,omitempty"` +} + +// DeclaredIntent is the human-reviewed purpose and allow lists for a workload. +// Once any field is set, an empty allow list allows nothing. +type DeclaredIntent struct { + // purpose is a short human statement of what the workload does. + // +kubebuilder:validation:MaxLength=1024 + // +optional + Purpose string `json:"purpose,omitempty"` + + // decisionType names the kind of decision the agent makes (e.g. "refund"). + // +kubebuilder:validation:MaxLength=128 + // +optional + DecisionType string `json:"decisionType,omitempty"` + + // allowedDataClasses lists data classes the workload may send. + // Known classes: email, phone, aadhaar, pan, iban, card. + // +optional + AllowedDataClasses []string `json:"allowedDataClasses,omitempty"` + + // allowedDestinations lists hosts the workload may call. Exact host or + // "*.example.com" for any subdomain. + // +optional + AllowedDestinations []string `json:"allowedDestinations,omitempty"` } // AgentPersona defines agent identity and behavior controls for runtime execution. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index f70db43..d24ecee 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -525,6 +525,11 @@ func (in *AgentWorkloadSpec) DeepCopyInto(out *AgentWorkloadSpec) { *out = new(AgentPersona) (*in).DeepCopyInto(*out) } + if in.DeclaredIntent != nil { + in, out := &in.DeclaredIntent, &out.DeclaredIntent + *out = new(DeclaredIntent) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentWorkloadSpec. @@ -615,6 +620,31 @@ func (in *ArgoWorkflowRef) DeepCopy() *ArgoWorkflowRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeclaredIntent) DeepCopyInto(out *DeclaredIntent) { + *out = *in + if in.AllowedDataClasses != nil { + in, out := &in.AllowedDataClasses, &out.AllowedDataClasses + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AllowedDestinations != nil { + in, out := &in.AllowedDestinations, &out.AllowedDestinations + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeclaredIntent. +func (in *DeclaredIntent) DeepCopy() *DeclaredIntent { + if in == nil { + return nil + } + out := new(DeclaredIntent) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LLMProvider) DeepCopyInto(out *LLMProvider) { *out = *in diff --git a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml index d76695b..259d9e7 100644 --- a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml +++ b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml @@ -83,6 +83,37 @@ spec: - team - delegation type: string + declaredIntent: + description: |- + declaredIntent states what this workload is for and what it may touch. + When set, observed data classes and destinations outside it deny or + escalate the action. See docs/policy-input.md. + properties: + allowedDataClasses: + description: |- + allowedDataClasses lists data classes the workload may send. + Known classes: email, phone, aadhaar, pan, iban, card. + items: + type: string + type: array + allowedDestinations: + description: |- + allowedDestinations lists hosts the workload may call. Exact host or + "*.example.com" for any subdomain. + items: + type: string + type: array + decisionType: + description: decisionType names the kind of decision the agent + makes (e.g. "refund"). + maxLength: 128 + type: string + purpose: + description: purpose is a short human statement of what the workload + does. + maxLength: 1024 + type: string + type: object jobId: description: jobId uniquely identifies this agent workload job type: string diff --git a/config/samples/agentworkload_declared_intent.yaml b/config/samples/agentworkload_declared_intent.yaml new file mode 100644 index 0000000..2dedce8 --- /dev/null +++ b/config/samples/agentworkload_declared_intent.yaml @@ -0,0 +1,23 @@ +apiVersion: agentic.clawdlinux.org/v1alpha1 +kind: AgentWorkload +metadata: + name: declared-intent-demo + namespace: agentic-system + annotations: + demo.clawdlinux.org/description: "Declared intent. Actions that send undeclared data classes or call undeclared hosts are denied in strict mode." +spec: + objective: "triage refund requests and draft replies" + workloadType: generic + agents: + - "refund-agent" + opaPolicy: strict + autoApproveThreshold: "0.95" + mcpServerEndpoint: "https://mcp.refunds.internal:8443" + declaredIntent: + purpose: "Triage refund tickets. No payment data leaves the cluster." + decisionType: "refund-triage" + allowedDataClasses: + - email + allowedDestinations: + - mcp.refunds.internal + - "*.svc.cluster.local" diff --git a/docs/architecture/decision-architecture.md b/docs/architecture/decision-architecture.md index e3fc74b..f22afa1 100644 --- a/docs/architecture/decision-architecture.md +++ b/docs/architecture/decision-architecture.md @@ -32,7 +32,8 @@ model id and version, input hash, option set and order, per-option probabilities - No learned gate exists. Layer 2 is new work. ## Build order -1. Measure, stop trusting agent self-report (observed inputs). +1. Measure, stop trusting agent self-report (observed inputs). See + [policy input](../policy-input.md). 2. Real rule engine (OPA Go lib) for invariants and packs. 3. Receipts for every decision (shared receiptspec). 4. Approval dataset (already in goal prompt phase 4). diff --git a/docs/policy-input.md b/docs/policy-input.md new file mode 100644 index 0000000..5c1724a --- /dev/null +++ b/docs/policy-input.md @@ -0,0 +1,86 @@ +# Policy input + +Every action decision reads inputs from three sources. Each source has a +different trust level. Source of truth for the design: +[decision architecture](architecture/decision-architecture.md). + +| Class | Who sets it | Can it loosen a decision? | +|---|---|---| +| Declared | A human, in the manifest, reviewed in a PR | Yes, it defines what is allowed | +| Observed | The platform, measured at decision time | Yes, but only within Declared | +| Agent-claimed | The agent or its MCP server | No. It can only tighten | + +Code: `pkg/decision/input` (types), `pkg/decision/observe` (builder), +`pkg/dataclass` (detectors), `pkg/decision.Combine` (tighten-only merge). + +## Declared + +Set in `spec.declaredIntent` on an `AgentWorkload`. All fields optional. + +| Field | Meaning | +|---|---| +| `purpose` | Short human statement of what the workload does. Recorded only. | +| `decisionType` | Kind of decision the agent makes, e.g. `refund-triage`. Recorded only. | +| `allowedDataClasses` | Data classes the workload may send. Known: `email`, `phone`, `aadhaar`, `pan`, `iban`, `card`. | +| `allowedDestinations` | Hosts the workload may call. Exact host, or `*.example.com` for any subdomain. The apex does not match the wildcard. | + +Rules: +- If `declaredIntent` is absent, nothing changes. The legacy path behaves as before. +- If `declaredIntent` is present, an empty allow list allows nothing. +- Matching is case-insensitive. + +Sample: [`config/samples/agentworkload_declared_intent.yaml`](../config/samples/agentworkload_declared_intent.yaml). + +## Observed + +Built by `observe.Observe` from values the controller already holds. + +| Field | Source today | +|---|---| +| `destination` | Host part of `spec.mcpServerEndpoint`. The proposed action is sent there via `execute_action`. | +| `dataClasses` | `pkg/dataclass` run over the `propose_action` reply (action, description, params). Class names only. Matched content is never stored or logged. | +| `tool` | The proposed action name. | +| `callerIdentity` | Empty. `AgentWorkload` has no caller or actor field today. | +| `priorActionCount` | `len(status.executedActions) + len(status.proposedActions)`. | +| `priorDeniedCount` | Proposed actions with `approved: false`. | + +Detectors are deterministic. Aadhaar requires a valid Verhoeff checksum. Card +requires Luhn. IBAN requires mod-97. Phone is pattern based (E.164 with `+`, +10 digit Indian mobile, US `(415) 555-0100`) and will have false positives. +Scanning is bounded: depth 8, 64 KiB per string, 1 MiB and 10000 nodes per +value. Map keys are not scanned. + +### Not observed yet + +Be clear about the gaps: +- Caller identity. No field carries it. A future change can read the + requesting user from an admission webhook or a signed annotation. +- Real egress. Destination is the configured MCP endpoint. Hosts the MCP + server itself calls are not seen by this check. +- Tokens and cost per action. +- Data sent before the decision. The objective already goes to the MCP server + in `propose_action`, before any check runs. +- Cluster health. The platform does not measure it. See below. +- Orchestrated runtimes (`spec.orchestration`). This check runs only on the + legacy direct-action path. + +## Agent-claimed + +| Field | Source today | +|---|---| +| `confidence` | `confidence` in the `propose_action` reply. | +| `clusterHealth` | `cluster_health` in the MCP `get_status` reply. Nil when absent. The threshold evaluator then uses a default of 75. | +| `intent` | `description` in the `propose_action` reply. | + +These values feed the existing threshold evaluator in `pkg/opa`. They are +logged as agent-claimed. They may only tighten an outcome. +`decision.Combine(rulesDenied, thresholdAllowed)` returns +`!rulesDenied && thresholdAllowed`. A high claimed confidence cannot turn a +declared-intent violation into an allow. + +## Outcome + +When `declaredIntent` is set and any violation is found: +- strict policy: phase `PolicyDenied`, violation reasons appended to the + `PolicyDenied` condition message. +- permissive or unset policy: phase `PendingApproval`. diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index 0727295..b417207 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -40,6 +40,9 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + decisioninput "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/observe" "github.com/Clawdlinux/agentic-operator-core/pkg/evaluation" "github.com/Clawdlinux/agentic-operator-core/pkg/finops" "github.com/Clawdlinux/agentic-operator-core/pkg/llm" @@ -434,12 +437,14 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Extract cluster health from status // Default to 75 if not provided by MCP, but log a warning clusterHealth := 75.0 + var claimedHealth *float64 if rawHealth, ok := status["cluster_health"]; ok { health, err := parseFlexibleFloat(rawHealth) if err != nil { log.Info("Warning: MCP status has invalid 'cluster_health' field, using default", "default", clusterHealth, "value", rawHealth) } else { clusterHealth = health + claimedHealth = &health } } else { log.Info("Warning: MCP status missing 'cluster_health' field, using default", "default", clusterHealth) @@ -550,6 +555,28 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques log.Info("OPA evaluation result", "allowed", opaResult.Allowed, "confidence", opaResult.Confidence, "reasons", opaResult.Reasons) + // Both threshold inputs come from the agent's MCP server, not the platform. + log.Info("agent-claimed decision inputs", "claimedConfidence", confidence, "claimedClusterHealth", claimedHealth, "clusterHealthUsed", clusterHealth) + + if workload.Spec.DeclaredIntent != nil { + priorTotal, priorDenied := observe.History(workload.Status.ExecutedActions, workload.Status.ProposedActions) + decisionInput := decisioninput.Input{ + Declared: observe.Declared(workload.Spec.DeclaredIntent), + Observed: observe.Observe(observe.Facts{ + Endpoint: mcpEndpoint, + Tool: actionName, + Payload: proposal, + PriorActionCount: priorTotal, + PriorDeniedCount: priorDenied, + }), + Claimed: decisioninput.AgentClaimed{Confidence: &confidence, ClusterHealth: claimedHealth, Intent: description}, + } + violations := decisionInput.Violations() + opaResult.Allowed = decision.Combine(len(violations) > 0, opaResult.Allowed) + opaResult.Reasons = append(opaResult.Reasons, violations...) + log.Info("declared intent check", "destination", decisionInput.Observed.Destination, "dataClasses", decisionInput.Observed.DataClasses, "violations", violations, "allowed", opaResult.Allowed) + } + // Step 5: Handle action execution or approval pending action := agenticv1alpha1.Action{ Name: actionName, diff --git a/internal/controller/agentworkload_controller_opa_test.go b/internal/controller/agentworkload_controller_opa_test.go index b9265ff..c7d5e3d 100644 --- a/internal/controller/agentworkload_controller_opa_test.go +++ b/internal/controller/agentworkload_controller_opa_test.go @@ -116,3 +116,72 @@ func findCondition(conditions []metav1.Condition, conditionType string) *metav1. } return nil } + +func TestReconcile_DeclaredIntentTightensOnly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policy string + destinations []string + wantPhase string + wantReason string + }{ + {"declared destination allows high confidence", "strict", []string{"127.0.0.1"}, "Completed", ""}, + {"undeclared destination denies in strict", "strict", []string{"mcp.internal"}, "PolicyDenied", `observed destination "127.0.0.1" not declared`}, + {"undeclared destination escalates in permissive", "permissive", []string{"mcp.internal"}, "PendingApproval", ""}, + } + for i, tc := range tests { + tc, i := tc, i + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + scheme := newControllerTestScheme(t) + // High claimed confidence and health: thresholds alone would allow. + server := newMockMCPServer(mockMCPScenario{confidence: "0.98", clusterHealth: 95.0}) + defer server.Close() + + objective := "optimize resources" + policy := tc.policy + name := "declared-intent-" + string(rune('a'+i)) + workload := &agenticv1alpha1.AgentWorkload{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: agenticv1alpha1.AgentWorkloadSpec{ + MCPServerEndpoint: &server.URL, + Objective: &objective, + OPAPolicy: &policy, + DeclaredIntent: &agenticv1alpha1.DeclaredIntent{ + Purpose: "resource tuning", + AllowedDestinations: tc.destinations, + }, + }, + } + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&agenticv1alpha1.AgentWorkload{}). + WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, workload). + Build() + + reconciler := &AgentWorkloadReconciler{Client: k8sClient, Scheme: scheme} + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: "default"}}); err != nil { + t.Fatalf("reconcile returned error: %v", err) + } + + updated := &agenticv1alpha1.AgentWorkload{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, updated); err != nil { + t.Fatalf("fetch updated workload: %v", err) + } + if updated.Status.Phase != tc.wantPhase { + t.Fatalf("phase = %q, want %q", updated.Status.Phase, tc.wantPhase) + } + if tc.wantReason != "" { + condition := findCondition(updated.Status.Conditions, "PolicyDenied") + if condition == nil || !strings.Contains(condition.Message, tc.wantReason) { + t.Fatalf("PolicyDenied condition = %#v, want reason %q", condition, tc.wantReason) + } + } + }) + } +} diff --git a/pkg/dataclass/dataclass.go b/pkg/dataclass/dataclass.go new file mode 100644 index 0000000..0c7f88a --- /dev/null +++ b/pkg/dataclass/dataclass.go @@ -0,0 +1,323 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package dataclass runs deterministic detectors for personal and financial +// data classes. It returns class names only. It never returns or logs the +// matched content. +package dataclass + +import ( + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" +) + +// Class is a detected data class name. +type Class string + +// Supported data classes. +const ( + Email Class = "email" + Phone Class = "phone" + Aadhaar Class = "aadhaar" + PAN Class = "pan" + IBAN Class = "iban" + Card Class = "card" +) + +// Limits keep scanning bounded on hostile input. +const ( + // MaxDepth is the deepest nesting DetectValue descends into. + MaxDepth = 8 + // MaxStringBytes is the prefix of each string that is scanned. + MaxStringBytes = 64 << 10 + // MaxTotalBytes is the total string budget for one DetectValue call. + MaxTotalBytes = 1 << 20 + // MaxNodes is the node budget for one DetectValue call. + MaxNodes = 10000 +) + +var ( + emailRe = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`) + phoneIntlRe = regexp.MustCompile(`\+[0-9](?:[ -]?[0-9]){7,14}`) + phoneINRe = regexp.MustCompile(`\b[6-9][0-9]{9}\b`) + phoneUSRe = regexp.MustCompile(`\([0-9]{3}\) ?[0-9]{3}-[0-9]{4}`) + aadhaarRe = regexp.MustCompile(`\b[2-9][0-9]{3}[ -]?[0-9]{4}[ -]?[0-9]{4}\b`) + panRe = regexp.MustCompile(`\b[A-Z]{5}[0-9]{4}[A-Z]\b`) + ibanRe = regexp.MustCompile(`\b[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}\b`) + cardRe = regexp.MustCompile(`\b[0-9](?:[ -]?[0-9]){12,18}\b`) +) + +// Detect returns the sorted, deduplicated classes found in s. +func Detect(s string) []Class { + set := map[Class]struct{}{} + detectInto(s, set) + return sorted(set) +} + +// DetectValue walks JSON-like values (map[string]any, []any, strings, numbers) +// and returns the sorted, deduplicated classes found in string and numeric +// leaves. Map keys are not scanned. +func DetectValue(v any) []Class { + set := map[Class]struct{}{} + b := &budget{bytes: MaxTotalBytes, nodes: MaxNodes} + walk(v, 0, b, set) + return sorted(set) +} + +type budget struct { + bytes int + nodes int +} + +func walk(v any, depth int, b *budget, set map[Class]struct{}) { + if depth > MaxDepth || b.nodes <= 0 || b.bytes <= 0 { + return + } + b.nodes-- + switch t := v.(type) { + case string: + if len(t) > b.bytes { + t = t[:b.bytes] + } + b.bytes -= len(t) + detectInto(t, set) + case json.Number: + walk(t.String(), depth, b, set) + case int, int32, int64, uint, uint32, uint64: + walk(fmt.Sprint(t), depth, b, set) + case float64: + // Large integers survive as float64 from encoding/json without UseNumber. + walk(fmt.Sprintf("%.0f", t), depth, b, set) + case []any: + for _, e := range t { + walk(e, depth+1, b, set) + } + case []string: + for _, e := range t { + walk(e, depth+1, b, set) + } + case map[string]any: + for _, k := range sortedKeys(t) { + walk(t[k], depth+1, b, set) + } + case map[string]string: + keys := make([]string, 0, len(t)) + for k := range t { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + walk(t[k], depth+1, b, set) + } + } +} + +func detectInto(s string, set map[Class]struct{}) { + if len(s) > MaxStringBytes { + s = s[:MaxStringBytes] + } + if emailRe.MatchString(s) { + set[Email] = struct{}{} + } + if phoneIntlRe.MatchString(s) || phoneUSRe.MatchString(s) || anyIsolated(s, phoneINRe, nil) { + set[Phone] = struct{}{} + } + if anyIsolated(s, aadhaarRe, func(d string) bool { return verhoeffValid(d) }) { + set[Aadhaar] = struct{}{} + } + if panRe.MatchString(s) { + set[PAN] = struct{}{} + } + if anyIBAN(s) { + set[IBAN] = struct{}{} + } + if anyIsolated(s, cardRe, luhnValid) { + set[Card] = struct{}{} + } +} + +// anyIsolated reports whether some match of re stands alone (no adjacent digit +// group) and, after removing separators, passes check (nil means accept). +func anyIsolated(s string, re *regexp.Regexp, check func(string) bool) bool { + for _, loc := range re.FindAllStringIndex(s, -1) { + if !isolated(s, loc[0], loc[1]) { + continue + } + if check == nil || check(digitsOnly(s[loc[0]:loc[1]])) { + return true + } + } + return false +} + +func isolated(s string, start, end int) bool { + if start > 0 { + p := s[start-1] + if isDigit(p) { + return false + } + if (p == ' ' || p == '-') && start > 1 && isDigit(s[start-2]) { + return false + } + } + if end < len(s) { + n := s[end] + if isDigit(n) { + return false + } + if (n == ' ' || n == '-') && end+1 < len(s) && isDigit(s[end+1]) { + return false + } + } + return true +} + +func anyIBAN(s string) bool { + for _, m := range ibanRe.FindAllString(s, -1) { + // Greedy matching can swallow a following uppercase word, so try each + // group boundary from longest to shortest. + cut := len(m) + for cut > 0 { + if ibanValid(strings.ReplaceAll(m[:cut], " ", "")) { + return true + } + i := strings.LastIndexByte(m[:cut], ' ') + if i < 0 { + break + } + cut = i + } + } + return false +} + +func ibanValid(s string) bool { + if len(s) < 15 || len(s) > 34 { + return false + } + r := s[4:] + s[:4] + rem := 0 + for i := 0; i < len(r); i++ { + c := r[i] + switch { + case isDigit(c): + rem = (rem*10 + int(c-'0')) % 97 + case c >= 'A' && c <= 'Z': + v := int(c-'A') + 10 + rem = (rem*100 + v) % 97 + default: + return false + } + } + return rem == 1 +} + +func luhnValid(d string) bool { + if len(d) < 13 || len(d) > 19 { + return false + } + sum := 0 + double := false + for i := len(d) - 1; i >= 0; i-- { + n := int(d[i] - '0') + if double { + n *= 2 + if n > 9 { + n -= 9 + } + } + sum += n + double = !double + } + return sum%10 == 0 +} + +var verhoeffD = [10][10]int{ + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, + {1, 2, 3, 4, 0, 6, 7, 8, 9, 5}, + {2, 3, 4, 0, 1, 7, 8, 9, 5, 6}, + {3, 4, 0, 1, 2, 8, 9, 5, 6, 7}, + {4, 0, 1, 2, 3, 9, 5, 6, 7, 8}, + {5, 9, 8, 7, 6, 0, 4, 3, 2, 1}, + {6, 5, 9, 8, 7, 1, 0, 4, 3, 2}, + {7, 6, 5, 9, 8, 2, 1, 0, 4, 3}, + {8, 7, 6, 5, 9, 3, 2, 1, 0, 4}, + {9, 8, 7, 6, 5, 4, 3, 2, 1, 0}, +} + +var verhoeffP = [8][10]int{ + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, + {1, 5, 7, 6, 2, 8, 3, 0, 9, 4}, + {5, 8, 0, 3, 7, 9, 6, 1, 4, 2}, + {8, 9, 1, 6, 0, 4, 3, 5, 2, 7}, + {9, 4, 5, 3, 1, 2, 6, 8, 7, 0}, + {4, 2, 8, 6, 5, 7, 3, 9, 0, 1}, + {2, 7, 9, 3, 8, 0, 6, 4, 1, 5}, + {7, 0, 4, 6, 9, 1, 3, 2, 5, 8}, +} + +func verhoeffValid(d string) bool { + if len(d) != 12 { + return false + } + c := 0 + for i := 0; i < len(d); i++ { + c = verhoeffD[c][verhoeffP[i%8][int(d[len(d)-1-i]-'0')]] + } + return c == 0 +} + +func digitsOnly(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if isDigit(s[i]) { + b.WriteByte(s[i]) + } + } + return b.String() +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func sorted(set map[Class]struct{}) []Class { + out := make([]Class, 0, len(set)) + for c := range set { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// Strings converts classes to plain strings. +func Strings(cs []Class) []string { + out := make([]string, len(cs)) + for i, c := range cs { + out[i] = string(c) + } + return out +} diff --git a/pkg/dataclass/dataclass_test.go b/pkg/dataclass/dataclass_test.go new file mode 100644 index 0000000..ecba5df --- /dev/null +++ b/pkg/dataclass/dataclass_test.go @@ -0,0 +1,141 @@ +package dataclass + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// verhoeffAppend returns base plus its Verhoeff check digit. +func verhoeffAppend(base string) string { + for d := byte('0'); d <= '9'; d++ { + if verhoeffValid(base + string(d)) { + return base + string(d) + } + } + panic("no check digit") +} + +func TestDetect(t *testing.T) { + aadhaar := verhoeffAppend("23412341234") + // Flip the check digit to break Verhoeff. + badAadhaar := aadhaar[:11] + string('0'+(aadhaar[11]-'0'+1)%10) + + tests := []struct { + name string + in string + want []Class + }{ + {"empty", "", nil}, + {"plain text", "restart the deployment in namespace prod", nil}, + {"email", "contact ops.team+alerts@example.co.uk now", []Class{Email}}, + {"not email", "user at example dot com", nil}, + {"phone e164", "call +91 98765 43210", []Class{Phone}}, + {"phone india bare", "mobile 9876543210 today", []Class{Phone}}, + {"phone us", "(415) 555-0100", []Class{Phone}}, + {"short number not phone", "replicas 12345", nil}, + {"aadhaar plain", "id " + aadhaar, []Class{Aadhaar}}, + {"aadhaar spaced", "id " + aadhaar[:4] + " " + aadhaar[4:8] + " " + aadhaar[8:], []Class{Aadhaar}}, + {"aadhaar hyphen", aadhaar[:4] + "-" + aadhaar[4:8] + "-" + aadhaar[8:], []Class{Aadhaar}}, + {"aadhaar bad checksum", "id " + badAadhaar, nil}, + {"aadhaar first digit 1", verhoeffAppend("13412341234"), nil}, + {"pan", "PAN ABCDE1234F on file", []Class{PAN}}, + {"pan lowercase no match", "abcde1234f", nil}, + {"iban compact", "send to GB82WEST12345698765432", []Class{IBAN}}, + {"iban spaced", "GB82 WEST 1234 5698 7654 32 THANKS", []Class{IBAN}}, + {"iban de", "DE89370400440532013000", []Class{IBAN}}, + {"iban bad checksum", "GB83WEST12345698765432", nil}, + {"card", "4111111111111111", []Class{Card}}, + {"card spaced", "card 4111 1111 1111 1111 exp", []Class{Card}}, + {"card hyphen", "5500-0000-0000-0004", []Class{Card}}, + {"card bad luhn", "4111111111111112", nil}, + {"multi", "a@b.io and 4111111111111111", []Class{Card, Email}}, + {"dedupe", "a@b.io c@d.io", []Class{Email}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := Detect(tc.in) + if len(got) == 0 && len(tc.want) == 0 { + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Detect = %v, want %v", got, tc.want) + } + }) + } +} + +func TestDetectRandomTwelveDigitsFailingVerhoeff(t *testing.T) { + // Every check digit except the valid one must not match aadhaar. + base := "98765432109" + valid := verhoeffAppend(base) + for d := byte('0'); d <= '9'; d++ { + s := base + string(d) + has := false + for _, c := range Detect(s) { + if c == Aadhaar { + has = true + } + } + if has != (s == valid) { + t.Fatalf("%s: aadhaar match = %v, want %v", s[:4]+"...", has, s == valid) + } + } +} + +func TestDetectValue(t *testing.T) { + var decoded any + if err := json.Unmarshal([]byte(`{"to":"x@y.com","items":[{"pan":"ABCDE1234F"}],"n":4111111111111111}`), &decoded); err != nil { + t.Fatal(err) + } + + deep := any("a@b.io") + for i := 0; i < MaxDepth+2; i++ { + deep = []any{deep} + } + + tests := []struct { + name string + in any + want []Class + }{ + {"nil", nil, nil}, + {"string", "a@b.io", []Class{Email}}, + {"decoded json", decoded, []Class{Card, Email, PAN}}, + {"json number", json.Number("4111111111111111"), []Class{Card}}, + {"keys not scanned", map[string]any{"a@b.io": "ok"}, nil}, + {"string map", map[string]string{"k": "ABCDE1234F"}, []Class{PAN}}, + {"string slice", []string{"x", "GB82WEST12345698765432"}, []Class{IBAN}}, + {"too deep", deep, nil}, + {"unsupported type", struct{ S string }{"a@b.io"}, nil}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := DetectValue(tc.in) + if len(got) == 0 && len(tc.want) == 0 { + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("DetectValue = %v, want %v", got, tc.want) + } + }) + } +} + +func TestDetectValueSizeLimited(t *testing.T) { + big := strings.Repeat("x", MaxTotalBytes) + if got := DetectValue([]any{big, "a@b.io"}); len(got) != 0 { + t.Fatalf("budget not enforced: %v", got) + } + long := strings.Repeat("y", MaxStringBytes) + " a@b.io" + if got := Detect(long); len(got) != 0 { + t.Fatalf("string cap not enforced: %v", got) + } +} + +func TestStrings(t *testing.T) { + if got := Strings([]Class{Card, Email}); !reflect.DeepEqual(got, []string{"card", "email"}) { + t.Fatalf("Strings = %v", got) + } +} diff --git a/pkg/decision/decision.go b/pkg/decision/decision.go new file mode 100644 index 0000000..438784a --- /dev/null +++ b/pkg/decision/decision.go @@ -0,0 +1,27 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package decision combines decision layers. Agent-claimed inputs can only +// tighten an outcome. See docs/architecture/decision-architecture.md. +package decision + +// Combine returns whether an action is allowed. rulesDenied comes from +// declared and observed checks. thresholdAllowed comes from the threshold +// evaluator, which reads agent-claimed values. A rules deny always wins, so +// no claimed value can turn a deny into an allow. +func Combine(rulesDenied bool, thresholdAllowed bool) bool { + return !rulesDenied && thresholdAllowed +} diff --git a/pkg/decision/decision_test.go b/pkg/decision/decision_test.go new file mode 100644 index 0000000..7044a66 --- /dev/null +++ b/pkg/decision/decision_test.go @@ -0,0 +1,30 @@ +package decision + +import "testing" + +func TestCombineClaimsCannotLoosen(t *testing.T) { + tests := []struct { + rulesDenied bool + thresholdAllowed bool + want bool + }{ + {false, false, false}, + {false, true, true}, + {true, false, false}, + {true, true, false}, + } + for _, tc := range tests { + got := Combine(tc.rulesDenied, tc.thresholdAllowed) + if got != tc.want { + t.Fatalf("Combine(%v, %v) = %v, want %v", tc.rulesDenied, tc.thresholdAllowed, got, tc.want) + } + // The claim-driven input can never produce an allow the rules denied. + if tc.rulesDenied && got { + t.Fatalf("claims loosened a rules deny") + } + // The claim-driven input can never produce more than it asked for. + if got && !tc.thresholdAllowed { + t.Fatalf("outcome exceeds threshold result") + } + } +} diff --git a/pkg/decision/input/input.go b/pkg/decision/input/input.go new file mode 100644 index 0000000..2ee0a6e --- /dev/null +++ b/pkg/decision/input/input.go @@ -0,0 +1,134 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package input holds decision inputs split by trust: declared by a human in +// the manifest, observed by the platform, and claimed by the agent. See +// docs/policy-input.md. +package input + +import ( + "fmt" + "net" + "net/url" + "sort" + "strings" +) + +// Declared comes from spec.declaredIntent and is reviewed in a PR. +type Declared struct { + Purpose string + DecisionType string + AllowedDataClasses []string + AllowedDestinations []string +} + +// Observed is measured by the platform, not reported by the agent. +type Observed struct { + Destination string + DataClasses []string + Tool string + CallerIdentity string + PriorActionCount int + PriorDeniedCount int +} + +// AgentClaimed is self-reported by the agent. It may only tighten a decision. +type AgentClaimed struct { + Confidence *float64 + ClusterHealth *float64 + Intent string +} + +// Input is the full decision input. +type Input struct { + Declared Declared + Observed Observed + Claimed AgentClaimed +} + +// IsZero reports whether nothing was declared. +func (d Declared) IsZero() bool { + return d.Purpose == "" && d.DecisionType == "" && + len(d.AllowedDataClasses) == 0 && len(d.AllowedDestinations) == 0 +} + +// Violations returns reasons the observed facts fall outside the declared +// intent. It returns nil when nothing is declared; callers treat that as +// undeclared. Once anything is declared, an empty allow list allows nothing. +func (in Input) Violations() []string { + if in.Declared.IsZero() { + return nil + } + var out []string + + allowed := map[string]bool{} + for _, c := range in.Declared.AllowedDataClasses { + allowed[strings.ToLower(strings.TrimSpace(c))] = true + } + var undeclared []string + for _, c := range in.Observed.DataClasses { + if !allowed[strings.ToLower(c)] { + undeclared = append(undeclared, c) + } + } + if len(undeclared) > 0 { + sort.Strings(undeclared) + out = append(out, fmt.Sprintf("observed data classes not declared: %s", strings.Join(undeclared, ","))) + } + + if host := Host(in.Observed.Destination); host != "" && !hostAllowed(host, in.Declared.AllowedDestinations) { + out = append(out, fmt.Sprintf("observed destination %q not declared", host)) + } + return out +} + +// Host extracts the lower-case host from a URL or host[:port] string. +func Host(dest string) string { + dest = strings.TrimSpace(dest) + if dest == "" { + return "" + } + if strings.Contains(dest, "://") { + u, err := url.Parse(dest) + if err != nil { + return "" + } + return strings.ToLower(u.Hostname()) + } + if h, _, err := net.SplitHostPort(dest); err == nil { + return strings.ToLower(h) + } + return strings.ToLower(strings.Trim(dest, "[]")) +} + +func hostAllowed(host string, patterns []string) bool { + for _, p := range patterns { + p = strings.ToLower(strings.TrimSpace(p)) + if p == "" { + continue + } + if strings.HasPrefix(p, "*.") { + if suffix := p[1:]; strings.HasSuffix(host, suffix) && len(host) > len(suffix) { + return true + } + continue + } + if host == p { + return true + } + } + return false +} diff --git a/pkg/decision/input/input_test.go b/pkg/decision/input/input_test.go new file mode 100644 index 0000000..947b8fd --- /dev/null +++ b/pkg/decision/input/input_test.go @@ -0,0 +1,61 @@ +package input + +import ( + "reflect" + "testing" +) + +func TestViolations(t *testing.T) { + declared := Declared{ + Purpose: "refund triage", + AllowedDataClasses: []string{"email"}, + AllowedDestinations: []string{"mcp.internal", "*.corp.example.com"}, + } + tests := []struct { + name string + in Input + want []string + }{ + {"undeclared returns nil", Input{Observed: Observed{Destination: "evil.io", DataClasses: []string{"card"}}}, nil}, + {"all within", Input{Declared: declared, Observed: Observed{Destination: "https://mcp.internal:8000", DataClasses: []string{"email"}}}, nil}, + {"wildcard subdomain", Input{Declared: declared, Observed: Observed{Destination: "a.b.corp.example.com"}}, nil}, + {"wildcard does not match apex", Input{Declared: declared, Observed: Observed{Destination: "corp.example.com"}}, + []string{`observed destination "corp.example.com" not declared`}}, + {"suffix trick rejected", Input{Declared: declared, Observed: Observed{Destination: "evilcorp.example.com"}}, + []string{`observed destination "evilcorp.example.com" not declared`}}, + {"case insensitive host", Input{Declared: declared, Observed: Observed{Destination: "HTTPS://MCP.Internal"}}, nil}, + {"undeclared class", Input{Declared: declared, Observed: Observed{Destination: "mcp.internal", DataClasses: []string{"pan", "card", "email"}}}, + []string{"observed data classes not declared: card,pan"}}, + {"both", Input{Declared: declared, Observed: Observed{Destination: "x.io:443", DataClasses: []string{"iban"}}}, + []string{"observed data classes not declared: iban", `observed destination "x.io" not declared`}}, + {"declared empty lists allow nothing", Input{Declared: Declared{Purpose: "p"}, Observed: Observed{Destination: "mcp.internal", DataClasses: []string{"email"}}}, + []string{"observed data classes not declared: email", `observed destination "mcp.internal" not declared`}}, + {"no destination observed", Input{Declared: Declared{Purpose: "p"}}, nil}, + {"claims ignored", Input{Declared: declared, Observed: Observed{Destination: "x.io"}, Claimed: AgentClaimed{Intent: "trust me"}}, + []string{`observed destination "x.io" not declared`}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.in.Violations(); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Violations = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestHost(t *testing.T) { + tests := map[string]string{ + "": "", + "https://mcp-server:8000": "mcp-server", + "http://127.0.0.1:9000/x": "127.0.0.1", + "api.example.com:443": "api.example.com", + "API.Example.com": "api.example.com", + "https://[::1]:8443/path": "::1", + " https://h.example.com ": "h.example.com", + } + for in, want := range tests { + if got := Host(in); got != want { + t.Errorf("Host(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/pkg/decision/observe/observe.go b/pkg/decision/observe/observe.go new file mode 100644 index 0000000..fefcd22 --- /dev/null +++ b/pkg/decision/observe/observe.go @@ -0,0 +1,78 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package observe builds observed decision inputs from facts the controller +// already holds. It is pure: no cluster, no network. +package observe + +import ( + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/dataclass" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +// Facts are plain values the controller passes in. +type Facts struct { + // Endpoint is the URL the action will be sent to. Only the host is kept. + Endpoint string + // Tool is the action or tool name being called. + Tool string + // CallerIdentity is the identity that triggered the action, if known. + CallerIdentity string + // Payload is scanned for data classes. Matched content is never kept. + Payload any + // PriorActionCount and PriorDeniedCount come from workload status. + PriorActionCount int + PriorDeniedCount int +} + +// Observe converts facts into an input.Observed. +func Observe(f Facts) input.Observed { + return input.Observed{ + Destination: input.Host(f.Endpoint), + DataClasses: dataclass.Strings(dataclass.DetectValue(f.Payload)), + Tool: f.Tool, + CallerIdentity: f.CallerIdentity, + PriorActionCount: f.PriorActionCount, + PriorDeniedCount: f.PriorDeniedCount, + } +} + +// History counts prior actions recorded in workload status. total is executed +// plus proposed. denied is proposed actions marked not approved. +func History(executed, proposed []agenticv1alpha1.Action) (total, denied int) { + total = len(executed) + len(proposed) + for _, a := range proposed { + if a.Approved != nil && !*a.Approved { + denied++ + } + } + return total, denied +} + +// Declared converts the CRD declared intent into an input.Declared. A nil +// intent yields the zero value, which means undeclared. +func Declared(d *agenticv1alpha1.DeclaredIntent) input.Declared { + if d == nil { + return input.Declared{} + } + return input.Declared{ + Purpose: d.Purpose, + DecisionType: d.DecisionType, + AllowedDataClasses: append([]string(nil), d.AllowedDataClasses...), + AllowedDestinations: append([]string(nil), d.AllowedDestinations...), + } +} diff --git a/pkg/decision/observe/observe_test.go b/pkg/decision/observe/observe_test.go new file mode 100644 index 0000000..e8e36e4 --- /dev/null +++ b/pkg/decision/observe/observe_test.go @@ -0,0 +1,83 @@ +package observe + +import ( + "reflect" + "testing" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +func TestObserve(t *testing.T) { + tests := []struct { + name string + in Facts + want input.Observed + }{ + {"empty", Facts{}, input.Observed{DataClasses: []string{}}}, + { + "full", + Facts{ + Endpoint: "https://mcp-server:8000", + Tool: "refund", + CallerIdentity: "system:serviceaccount:ops:bot", + Payload: map[string]any{"action": "refund", "description": "refund a@b.io card 4111111111111111"}, + PriorActionCount: 3, + PriorDeniedCount: 1, + }, + input.Observed{ + Destination: "mcp-server", + DataClasses: []string{"card", "email"}, + Tool: "refund", + CallerIdentity: "system:serviceaccount:ops:bot", + PriorActionCount: 3, + PriorDeniedCount: 1, + }, + }, + {"bad endpoint", Facts{Endpoint: "://"}, input.Observed{DataClasses: []string{}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := Observe(tc.in); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Observe = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestHistory(t *testing.T) { + yes, no := true, false + tests := []struct { + name string + executed []agenticv1alpha1.Action + proposed []agenticv1alpha1.Action + wantTotal, wantD int + }{ + {"none", nil, nil, 0, 0}, + {"mixed", []agenticv1alpha1.Action{{Name: "a"}}, []agenticv1alpha1.Action{{Approved: &no}, {Approved: &yes}, {}}, 4, 1}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + total, denied := History(tc.executed, tc.proposed) + if total != tc.wantTotal || denied != tc.wantD { + t.Fatalf("History = %d,%d want %d,%d", total, denied, tc.wantTotal, tc.wantD) + } + }) + } +} + +func TestDeclared(t *testing.T) { + if got := Declared(nil); !got.IsZero() { + t.Fatalf("nil intent should be zero, got %#v", got) + } + d := &agenticv1alpha1.DeclaredIntent{ + Purpose: "p", + DecisionType: "refund", + AllowedDataClasses: []string{"email"}, + AllowedDestinations: []string{"mcp.internal"}, + } + want := input.Declared{Purpose: "p", DecisionType: "refund", AllowedDataClasses: []string{"email"}, AllowedDestinations: []string{"mcp.internal"}} + if got := Declared(d); !reflect.DeepEqual(got, want) { + t.Fatalf("Declared = %#v, want %#v", got, want) + } +} From 91098e6cc2cf530eb58735133adcb2f82f6d9985 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 00:07:03 +0530 Subject: [PATCH 03/54] feat(decision): add invariants, OPA rule engine, and policy packs Invariants INV-01..05 in pkg/invariants. INV-01 denies credential-shaped values even without declaredIntent. New dataclass class: credential. pkg/rules/engine embeds OPA v1.19.1 (last release on Go 1.25). Packs dpdp-in@v0.1.0 and gdpr-eu@v0.1.0 ship via go:embed with Rego unit tests run under go test. Packs only add deny or require_approval. pkg/decision.Decide merges layers, strictest wins. Pack approval never auto-allows. Opt in with spec.policyPacks. Unknown packs are rejected by the webhook and fail closed in the controller. Packs on orchestrated runtimes fail closed; invariants and packs run on the direct path only. pkg/opa moved to pkg/rules/threshold. pkg/opa kept as deprecated aliases. agentctl migrated to tablewriter v1, which OPA requires. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- CHANGELOG.md | 4 + README.md | 1 + ROADMAP.md | 6 +- api/v1alpha1/agentworkload_types.go | 10 + api/v1alpha1/agentworkload_webhook.go | 9 + api/v1alpha1/agentworkload_webhook_test.go | 31 ++ api/v1alpha1/zz_generated.deepcopy.go | 5 + cmd/agentctl/commands.go | 6 +- cmd/agentctl/root.go | 6 +- ...agentic.clawdlinux.org_agentworkloads.yaml | 12 + config/policies/README.md | 6 +- docs/03-configuration.md | 7 +- docs/07-security.md | 30 +- docs/architecture/decision-architecture.md | 14 +- docs/architecture/invariants.md | 35 +++ docs/policy-input.md | 47 ++- docs/policy-packs.md | 107 +++++++ go.mod | 90 ++++-- go.sum | 213 +++++++++---- .../controller/agentworkload_controller.go | 131 ++++++-- .../agentworkload_controller_opa_test.go | 99 ++++++ ...d_controller_reconcile_integration_test.go | 7 +- pkg/dataclass/dataclass.go | 33 +- pkg/dataclass/dataclass_test.go | 50 +++ pkg/decision/decision.go | 67 +++++ pkg/decision/decision_test.go | 59 +++- pkg/decision/input/input.go | 44 ++- pkg/decision/observe/observe_test.go | 5 + pkg/invariants/invariants.go | 144 +++++++++ pkg/invariants/invariants_test.go | 110 +++++++ pkg/opa/opa.go | 55 ++++ pkg/opa/opa_test.go | 16 + pkg/rules/engine/engine.go | 284 ++++++++++++++++++ pkg/rules/engine/engine_test.go | 173 +++++++++++ pkg/rules/packs/dpdp-in@v0.1.0/README.md | 23 ++ pkg/rules/packs/dpdp-in@v0.1.0/dpdp.rego | 35 +++ pkg/rules/packs/dpdp-in@v0.1.0/dpdp_test.rego | 56 ++++ pkg/rules/packs/gdpr-eu@v0.1.0/README.md | 21 ++ pkg/rules/packs/gdpr-eu@v0.1.0/gdpr.rego | 31 ++ pkg/rules/packs/gdpr-eu@v0.1.0/gdpr_test.rego | 47 +++ pkg/rules/packs/packs.go | 68 +++++ pkg/rules/packs/packs_test.go | 76 +++++ pkg/{opa => rules/threshold}/evaluator.go | 14 +- .../threshold}/evaluator_test.go | 2 +- pkg/{opa => rules/threshold}/policies.rego | 0 scripts/review_code.sh | 8 +- 46 files changed, 2112 insertions(+), 185 deletions(-) create mode 100644 docs/architecture/invariants.md create mode 100644 docs/policy-packs.md create mode 100644 pkg/invariants/invariants.go create mode 100644 pkg/invariants/invariants_test.go create mode 100644 pkg/opa/opa.go create mode 100644 pkg/opa/opa_test.go create mode 100644 pkg/rules/engine/engine.go create mode 100644 pkg/rules/engine/engine_test.go create mode 100644 pkg/rules/packs/dpdp-in@v0.1.0/README.md create mode 100644 pkg/rules/packs/dpdp-in@v0.1.0/dpdp.rego create mode 100644 pkg/rules/packs/dpdp-in@v0.1.0/dpdp_test.rego create mode 100644 pkg/rules/packs/gdpr-eu@v0.1.0/README.md create mode 100644 pkg/rules/packs/gdpr-eu@v0.1.0/gdpr.rego create mode 100644 pkg/rules/packs/gdpr-eu@v0.1.0/gdpr_test.rego create mode 100644 pkg/rules/packs/packs.go create mode 100644 pkg/rules/packs/packs_test.go rename pkg/{opa => rules/threshold}/evaluator.go (93%) rename pkg/{opa => rules/threshold}/evaluator_test.go (99%) rename pkg/{opa => rules/threshold}/policies.rego (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b63d0e..314d82d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a working booth prepare/present flow with real provider and cost proof. - Added `agentctl doctor sandbox` to verify RuntimeClass and ready-node sandbox evidence before workload deployment. - Added optional `spec.declaredIntent` on `AgentWorkload` and observed decision inputs. The legacy action path now denies or escalates actions that send undeclared data classes or call undeclared hosts. Agent-claimed confidence and cluster health can only tighten. New packages: `pkg/dataclass`, `pkg/decision`, `pkg/decision/input`, `pkg/decision/observe`. See `docs/policy-input.md`. +- Added invariants (`pkg/invariants`, INV-01 to INV-05), an embedded OPA rule engine (`pkg/rules/engine`, OPA v1.19.1), and opt-in policy packs `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` via `spec.policyPacks`. Packs only add deny or require approval. Packs are engineering controls, not legal advice. The `credential` data class is now detected and always denied (INV-01), even without `declaredIntent`. Direct action path only. See `docs/policy-packs.md`. ### Changed +- Moved the threshold evaluator from `pkg/opa` to `pkg/rules/threshold`. `pkg/opa` remains as a deprecated alias package. +- `agentctl` table output now uses tablewriter v1 (box-drawn borders). OPA v1.19.1 requires it. +- An unknown or unenforceable `spec.policyPacks` entry fails the workload closed with condition `PolicyPackInvalid`. The validating webhook also rejects unknown packs. - Docs: removed claims not backed by code. Added the decision architecture ADR (`docs/architecture/decision-architecture.md`). Rego files are labelled samples; the action path is a Go threshold evaluator. - Changed the internal Go `CostReporter.RecordUsage` and `Provider.CallModel` interfaces to carry operation IDs for idempotency. This breaks out-of-tree implementations. - Added the optional `litellm.anthropicKey` chart value. Use `litellm.existingSecret` for booth and production deployments. diff --git a/README.md b/README.md index bef2dc3..745a83c 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,7 @@ assets/ Branding assets (logo, etc.) | [Cost Management](docs/06-cost-management.md) | Per-workload billing and chargeback | | [Security](docs/07-security.md) | Cilium, action rules, RBAC, and egress hardening | | [Policy input](docs/policy-input.md) | Declared, observed, and agent-claimed decision inputs | +| [Policy packs](docs/policy-packs.md) | Invariants and opt-in Rego packs (`dpdp-in`, `gdpr-eu`) | | [Troubleshooting](docs/10-troubleshooting.md) | Common issues and fixes | --- diff --git a/ROADMAP.md b/ROADMAP.md index 4bf0d0d..9e7234d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,6 +50,10 @@ Strong CNCF base runtimes for agents on Kubernetes exist and are improving. Claw - [x] Grafana observability and per-workload cost dashboards - [x] Reproducible booth demo gate on kind +## Shipped (decision architecture) + +- [x] Rule engine (OPA Go library) with invariants and policy packs. 5 invariants, `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` packs. Direct action path only; orchestrated runtimes reject packs. See [policy packs](docs/policy-packs.md) + ## Now Priority order. Validation before features. @@ -64,7 +68,7 @@ Priority order. Validation before features. - [ ] ACP RemoteMCPServer wrapper example - [ ] Homebrew tap for agentctl - [ ] Observed decision inputs. The platform measures destination, data classes and caller. Agent self-report only tightens. -- [ ] Rule engine (OPA Go library) with invariants and policy packs +- [ ] Invariants and policy packs on orchestrated runtimes (`spec.orchestration`) - [ ] Decision model: escalate-only scoring, shadow mode first. See [decision architecture](docs/architecture/decision-architecture.md) ## Later (all validation-gated) diff --git a/api/v1alpha1/agentworkload_types.go b/api/v1alpha1/agentworkload_types.go index d44b12c..c218b58 100644 --- a/api/v1alpha1/agentworkload_types.go +++ b/api/v1alpha1/agentworkload_types.go @@ -145,6 +145,16 @@ type AgentWorkloadSpec struct { // escalate the action. See docs/policy-input.md. // +optional DeclaredIntent *DeclaredIntent `json:"declaredIntent,omitempty"` + + // policyPacks lists Rego policy packs to evaluate, each as name@vX.Y.Z. + // Packs only add deny or require-approval. They are engineering controls, + // not legal advice. Enforced on the direct action path only. + // See docs/policy-packs.md. + // +kubebuilder:validation:MaxItems=8 + // +kubebuilder:validation:items:Pattern=`^(dpdp-in|gdpr-eu)@v[0-9]+\.[0-9]+\.[0-9]+$` + // +listType=set + // +optional + PolicyPacks []string `json:"policyPacks,omitempty"` } // DeclaredIntent is the human-reviewed purpose and allow lists for a workload. diff --git a/api/v1alpha1/agentworkload_webhook.go b/api/v1alpha1/agentworkload_webhook.go index fbfa3f2..de31991 100644 --- a/api/v1alpha1/agentworkload_webhook.go +++ b/api/v1alpha1/agentworkload_webhook.go @@ -32,6 +32,8 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/Clawdlinux/agentic-operator-core/pkg/rules/packs" ) var agentworkloadlog = logf.Log.WithName("agentworkload-resource") @@ -180,6 +182,13 @@ func (r *AgentWorkload) validate() error { } } + // 7. Validate policyPacks against shipped packs at exact versions. + for _, p := range r.Spec.PolicyPacks { + if !packs.Valid(p) { + allErrs = append(allErrs, fmt.Sprintf("policyPacks: unknown pack %q, known: %v", p, packs.Known())) + } + } + // Combine errors if len(allErrs) > 0 { errMsg := strings.Join(allErrs, "; ") diff --git a/api/v1alpha1/agentworkload_webhook_test.go b/api/v1alpha1/agentworkload_webhook_test.go index 4e86b40..f2b968c 100644 --- a/api/v1alpha1/agentworkload_webhook_test.go +++ b/api/v1alpha1/agentworkload_webhook_test.go @@ -308,3 +308,34 @@ func TestValidateMCPEndpoint(t *testing.T) { } } } + +func TestWebhook_PolicyPacks(t *testing.T) { + tests := []struct { + name string + packs []string + wantErr bool + }{ + {"none", nil, false}, + {"known", []string{"dpdp-in@v0.1.0", "gdpr-eu@v0.1.0"}, false}, + {"unknown version", []string{"dpdp-in@v9.9.9"}, true}, + {"unknown name", []string{"hipaa@v0.1.0"}, true}, + {"no version", []string{"gdpr-eu"}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := &AgentWorkload{Spec: AgentWorkloadSpec{ + MCPServerEndpoint: stringPtr("https://localhost:8000"), + Objective: stringPtr("test objective"), + Agents: []string{"agent1"}, + PolicyPacks: tc.packs, + }} + err := w.ValidateCreate() + if (err != nil) != tc.wantErr { + t.Fatalf("ValidateCreate err = %v, wantErr %v", err, tc.wantErr) + } + if err != nil && !strings.Contains(err.Error(), "policyPacks") { + t.Fatalf("error should name policyPacks: %v", err) + } + }) + } +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d24ecee..7de6ab5 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -530,6 +530,11 @@ func (in *AgentWorkloadSpec) DeepCopyInto(out *AgentWorkloadSpec) { *out = new(DeclaredIntent) (*in).DeepCopyInto(*out) } + if in.PolicyPacks != nil { + in, out := &in.PolicyPacks, &out.PolicyPacks + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentWorkloadSpec. diff --git a/cmd/agentctl/commands.go b/cmd/agentctl/commands.go index 6902dcd..83eacae 100644 --- a/cmd/agentctl/commands.go +++ b/cmd/agentctl/commands.go @@ -9,6 +9,7 @@ import ( agentctl "github.com/Clawdlinux/agentic-operator-core/pkg/agentctl" "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/tw" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -306,9 +307,8 @@ func runWorkflows(_ context.Context, opts *cliOptions, cmd *cobra.Command) error case "json", "yaml": return agentctl.PrintStructured(w, workflows, opts.Output) default: - tbl := tablewriter.NewWriter(w) - tbl.SetHeader([]string{"WORKFLOW", "DESCRIPTION", "DAG SHAPE", "EXAMPLE"}) - tbl.SetAutoWrapText(false) + tbl := tablewriter.NewTable(w, tablewriter.WithRowAutoWrap(tw.WrapNone)) + tbl.Header([]string{"WORKFLOW", "DESCRIPTION", "DAG SHAPE", "EXAMPLE"}) for _, wf := range workflows { tbl.Append([]string{wf.Name, wf.Description, wf.DAGShape, wf.Example}) } diff --git a/cmd/agentctl/root.go b/cmd/agentctl/root.go index c9770e7..1f83187 100644 --- a/cmd/agentctl/root.go +++ b/cmd/agentctl/root.go @@ -164,7 +164,7 @@ func newGetWorkloadsCommand(opts *cliOptions) *cobra.Command { if allNamespaces { headers = append([]string{"NAMESPACE"}, headers...) } - tbl.SetHeader(headers) + tbl.Header(headers) for _, row := range rows { rec := []string{row.Name, agentctl.SafeText(row.Status, "unknown"), agentctl.SafeText(row.Model, "n/a"), fmt.Sprintf("$%.4f", row.CostToday), row.Age} if allNamespaces { @@ -215,7 +215,7 @@ func newDescribeWorkloadCommand(opts *cliOptions) *cobra.Command { if len(detail.Steps) > 0 { _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Recent workflow steps:") tbl := tablewriter.NewWriter(cmd.OutOrStdout()) - tbl.SetHeader([]string{"STEP", "PHASE", "STARTED", "ENDED"}) + tbl.Header([]string{"STEP", "PHASE", "STARTED", "ENDED"}) for _, step := range detail.Steps { tbl.Append([]string{step.Name, step.Phase, step.StartedAt, step.EndedAt}) } @@ -319,7 +319,7 @@ func newCostSummaryCommand(opts *cliOptions) *cobra.Command { if allNamespaces { headers = append([]string{"NAMESPACE"}, headers...) } - tbl.SetHeader(headers) + tbl.Header(headers) for _, row := range rows { rec := []string{agentctl.SafeText(row.Workload, "unknown"), agentctl.SafeText(row.Model, "unknown"), strconv.FormatInt(row.TokensToday, 10), fmt.Sprintf("$%.4f", row.CostToday), fmt.Sprintf("$%.4f", row.CostMTD)} if allNamespaces { diff --git a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml index 259d9e7..9840f4c 100644 --- a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml +++ b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml @@ -218,6 +218,18 @@ spec: type: string type: array type: object + policyPacks: + description: |- + policyPacks lists Rego policy packs to evaluate, each as name@vX.Y.Z. + Packs only add deny or require-approval. They are engineering controls, + not legal advice. Enforced on the direct action path only. + See docs/policy-packs.md. + items: + pattern: ^(dpdp-in|gdpr-eu)@v[0-9]+\.[0-9]+\.[0-9]+$ + type: string + maxItems: 8 + type: array + x-kubernetes-list-type: set providers: description: |- providers defines LLM providers available for this workload diff --git a/config/policies/README.md b/config/policies/README.md index bacb85e..4c2e310 100644 --- a/config/policies/README.md +++ b/config/policies/README.md @@ -1,8 +1,8 @@ # Rego policy samples (not evaluated yet) -These are sample Rego files. The operator does not execute them. The direct -action path uses a Go threshold evaluator. A real rule engine is on the -[roadmap](../../ROADMAP.md). See the +These are sample Rego files. The operator does not execute them. The operator +evaluates only the shipped policy packs in `pkg/rules/packs`. See +[policy packs](../../docs/policy-packs.md) and the [decision architecture](../../docs/architecture/decision-architecture.md). ## What is included diff --git a/docs/03-configuration.md b/docs/03-configuration.md index 9bb9590..8208d1c 100644 --- a/docs/03-configuration.md +++ b/docs/03-configuration.md @@ -57,9 +57,12 @@ Policy objects are configuration. Packet enforcement depends on the cluster CNI. ## Action Policy `AgentWorkload.spec.opaPolicy` selects strict or permissive behavior in the -legacy in-process Go evaluator. The direct action path does not execute Rego. +legacy in-process Go threshold evaluator (`pkg/rules/threshold`). +`spec.policyPacks` opts into Rego policy packs run by the embedded OPA engine. +See [policy packs](policy-packs.md). -Rego samples under `pkg/opa` and `config/policies` are integration assets. +Rego samples under `pkg/rules/threshold` and `config/policies` are integration +assets. They are not evaluated. ## Model Routing diff --git a/docs/07-security.md b/docs/07-security.md index 682022c..a5e55e7 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -41,23 +41,35 @@ Verify rendered Roles and bindings against the customer's tenancy model before p ## Action Policy And Rego Assets -The legacy direct-action path uses an in-process Go evaluator selected by: +The legacy direct-action path decides in 3 layers. The strictest result wins. + +1. Invariants in `pkg/invariants`. Deterministic Go. Always on. Example: a + credential-shaped value in the proposed action is denied. See + [invariants](architecture/invariants.md). +2. Policy packs in `pkg/rules/packs`, evaluated by the embedded OPA Go library + in `pkg/rules/engine`. Opt in with `spec.policyPacks`. Packs only add deny + or require approval. See [policy packs](policy-packs.md). +3. A Go threshold evaluator in `pkg/rules/threshold`, selected by: ```yaml opaPolicy: strict ``` -It evaluates action type, caller-supplied confidence, cluster health, and strict -or permissive mode. Read-only actions use a separate allow path. Confidence comes -from the `propose_action` tool reply. Cluster health comes from the MCP status -reply and defaults to 75 when absent. The platform measures neither, so both are -agent-claimed inputs. +The threshold evaluator reads action type, caller-supplied confidence, cluster +health, and strict or permissive mode. Read-only actions use a separate allow +path. Confidence comes from the `propose_action` tool reply. Cluster health +comes from the MCP status reply and defaults to 75 when absent. The platform +measures neither, so both are agent-claimed inputs. They can tighten a decision. They must never loosen one. See [decision architecture](architecture/decision-architecture.md). -The repository also ships Rego samples in a ConfigMap. The direct action path -does not execute those Rego files or call a real OPA engine today. Treat them as -policy assets and integration examples. +The engine evaluates the shipped packs only. The Rego samples under +`config/policies` and `pkg/rules/threshold/policies.rego` are still not +evaluated. Treat them as policy assets and integration examples. + +Orchestrated workloads (`spec.orchestration`) do not run invariants or packs +yet. A workload that sets `spec.policyPacks` on that path fails closed with +condition `PolicyPackInvalid`. ## Network Isolation diff --git a/docs/architecture/decision-architecture.md b/docs/architecture/decision-architecture.md index f22afa1..3a93501 100644 --- a/docs/architecture/decision-architecture.md +++ b/docs/architecture/decision-architecture.md @@ -24,8 +24,18 @@ Rule count explodes, edge cases never end. Policy documents are prose. So model model id and version, input hash, option set and order, per-option probabilities, threshold used, outcome, layer that decided. Replay with same inputs must reproduce the score (pin version, temperature 0, fixed seed). Air-gap: model runs in cluster (self-hosted small model). No hosted scoring API in the default path. ## Today vs target (verified in code 2026-10-01) -- pkg/opa/evaluator.go: hardcoded Go thresholds, not OPA. go.mod has no OPA. .rego ships as ConfigMap, unevaluated. -- Input confidence comes from the propose_action tool reply. cluster_health comes from the MCP status reply, default 75 when absent (controller ~L436-538). Platform measures neither. +- Threshold evaluator moved from pkg/opa to pkg/rules/threshold. Still + hardcoded Go thresholds. pkg/opa is a deprecated alias package. +- go.mod now has OPA (v1.19.1). pkg/rules/engine embeds it and evaluates the + shipped packs (dpdp-in, gdpr-eu) when spec.policyPacks is set. The + config/policies and pkg/rules/threshold .rego samples are still unevaluated. +- Invariants (pkg/invariants, INV-01 to INV-05) run on every direct-path + action. See [invariants](invariants.md). INV-05 is wired but off until + receipts ship. +- Layers combine in pkg/decision.Decide. Strictest wins. +- Gap: the runtime-adapter path (reconcileViaRuntime) runs neither invariants + nor packs. Packs on that path fail closed. +- Input confidence comes from the propose_action tool reply. cluster_health comes from the MCP status reply, default 75 when absent. Platform measures neither. - "Destructive" = string match on action name. - pkg/evaluation/scorer.go: keyword heuristic, post hoc, blocks nothing. - AgentGate: deterministic, fine as invariant layer example. diff --git a/docs/architecture/invariants.md b/docs/architecture/invariants.md new file mode 100644 index 0000000..b4f00c8 --- /dev/null +++ b/docs/architecture/invariants.md @@ -0,0 +1,35 @@ +# Invariants + +Invariants are the deterministic floor of the +[decision architecture](decision-architecture.md). They live in +`pkg/invariants`. Pure Go. No model, no network. Each has a stable ID. + +An invariant finding always denies. Policy packs cannot override it. Agent +claims cannot override it. The threshold evaluator cannot override it. In +strict mode the workload goes to `PolicyDenied`. In permissive or unset mode it +goes to `PendingApproval`. The action is never executed by the controller. + +The set stays under 15 entries. IDs are never reused or renumbered. + +| ID | Blocks | Applies when | Input trust class | +|---|---|---|---| +| INV-01 | Credentials reaching the agent. A credential-shaped value (AWS access key ID, PEM private key, JWT, `Bearer` token, GitHub `ghp_` or `github_pat_` token) in the proposed action. | Always, with or without `declaredIntent`. Declaring `credential` as allowed does not lift it. | Observed (`pkg/dataclass` class `credential`) | +| INV-02 | Egress to a destination not in `allowedDestinations`. | `declaredIntent` has any field set. An empty allow list allows nothing. | Declared vs observed | +| INV-03 | Personal data (`email`, `phone`, `aadhaar`, `pan`, `iban`, `card`) sent to a destination not in `allowedDestinations`. | `declaredIntent` has any field set. | Declared vs observed | +| INV-04 | Observed data classes outside `allowedDataClasses`. | `declaredIntent` has any field set. | Declared vs observed | +| INV-05 | Deciding without a receipt when receipts are required. Fails closed if no receipt writer is available. | `ReceiptsRequired` is true. It is false today, so INV-05 never fires yet. | Platform | + +Reasons never contain matched content. They name the class or host only. + +## Where invariants run + +- Legacy direct-action path: every action, before packs and thresholds. +- Runtime-adapter path (`spec.orchestration`): not yet. This is a known gap. + +## Known limits + +- Detection is pattern based. A credential in an unknown format is missed. +- Destination is the configured MCP endpoint, not every host the MCP server + calls. See [policy input](../policy-input.md). +- INV-01 only sees the `propose_action` reply. The objective was already sent + to the MCP server before any check runs. diff --git a/docs/policy-input.md b/docs/policy-input.md index 5c1724a..f090d54 100644 --- a/docs/policy-input.md +++ b/docs/policy-input.md @@ -11,7 +11,8 @@ different trust level. Source of truth for the design: | Agent-claimed | The agent or its MCP server | No. It can only tighten | Code: `pkg/decision/input` (types), `pkg/decision/observe` (builder), -`pkg/dataclass` (detectors), `pkg/decision.Combine` (tighten-only merge). +`pkg/dataclass` (detectors), `pkg/invariants` (always-on checks), +`pkg/rules/engine` (policy packs), `pkg/decision.Decide` (strictest wins). ## Declared @@ -19,13 +20,14 @@ Set in `spec.declaredIntent` on an `AgentWorkload`. All fields optional. | Field | Meaning | |---|---| -| `purpose` | Short human statement of what the workload does. Recorded only. | -| `decisionType` | Kind of decision the agent makes, e.g. `refund-triage`. Recorded only. | -| `allowedDataClasses` | Data classes the workload may send. Known: `email`, `phone`, `aadhaar`, `pan`, `iban`, `card`. | +| `purpose` | Short human statement of what the workload does. Policy packs require it when they see personal data. | +| `decisionType` | Kind of decision the agent makes, e.g. `refund-triage`. Packs treat `automated` as needing human approval when personal data is present. | +| `allowedDataClasses` | Data classes the workload may send. Known: `email`, `phone`, `aadhaar`, `pan`, `iban`, `card`. Declaring `credential` has no effect: INV-01 always denies it. | | `allowedDestinations` | Hosts the workload may call. Exact host, or `*.example.com` for any subdomain. The apex does not match the wildcard. | Rules: -- If `declaredIntent` is absent, nothing changes. The legacy path behaves as before. +- If `declaredIntent` is absent, declared-intent checks (INV-02 to INV-04) + do not run. INV-01 (credentials) still runs. - If `declaredIntent` is present, an empty allow list allows nothing. - Matching is case-insensitive. @@ -47,6 +49,9 @@ Built by `observe.Observe` from values the controller already holds. Detectors are deterministic. Aadhaar requires a valid Verhoeff checksum. Card requires Luhn. IBAN requires mod-97. Phone is pattern based (E.164 with `+`, 10 digit Indian mobile, US `(415) 555-0100`) and will have false positives. +Credential is pattern based: AWS access key IDs, PEM private key headers, +JWT-shaped tokens, `Bearer` tokens of 20+ characters, and GitHub `ghp_` and +`github_pat_` tokens. Scanning is bounded: depth 8, 64 KiB per string, 1 MiB and 10000 nodes per value. Map keys are not scanned. @@ -61,8 +66,9 @@ Be clear about the gaps: - Data sent before the decision. The objective already goes to the MCP server in `propose_action`, before any check runs. - Cluster health. The platform does not measure it. See below. -- Orchestrated runtimes (`spec.orchestration`). This check runs only on the - legacy direct-action path. +- Orchestrated runtimes (`spec.orchestration`). Invariants and packs run only + on the legacy direct-action path. Packs set on an orchestrated workload + fail closed with condition `PolicyPackInvalid`. ## Agent-claimed @@ -72,15 +78,24 @@ Be clear about the gaps: | `clusterHealth` | `cluster_health` in the MCP `get_status` reply. Nil when absent. The threshold evaluator then uses a default of 75. | | `intent` | `description` in the `propose_action` reply. | -These values feed the existing threshold evaluator in `pkg/opa`. They are -logged as agent-claimed. They may only tighten an outcome. -`decision.Combine(rulesDenied, thresholdAllowed)` returns -`!rulesDenied && thresholdAllowed`. A high claimed confidence cannot turn a -declared-intent violation into an allow. +These values feed the threshold evaluator in `pkg/rules/threshold`. They are +logged as agent-claimed. They may only tighten an outcome. A high claimed +confidence cannot turn an invariant or pack finding into an allow. ## Outcome -When `declaredIntent` is set and any violation is found: -- strict policy: phase `PolicyDenied`, violation reasons appended to the - `PolicyDenied` condition message. -- permissive or unset policy: phase `PendingApproval`. +`decision.Decide` merges the layers. The strictest result wins. + +| Result | strict | permissive or unset | +|---|---|---| +| Any invariant finding ([list](architecture/invariants.md)) | `PolicyDenied` | `PendingApproval` | +| Any pack deny or pack error | `PolicyDenied` | `PendingApproval` | +| Threshold deny | `PolicyDenied` | `PendingApproval` | +| Pack require approval, nothing denies | `PendingApproval` | `PendingApproval` | +| Nothing objects | action executes | action executes | + +Under strict, reasons with rule IDs (for example `INV-02: ...` or +`gdpr-eu@v0.1.0/GDPR-EU-01: ...`) are appended to the `PolicyDenied` +condition message. A pack approval sets condition `ApprovalRequired`. In +permissive mode a deny sets no condition, as before. See +[policy packs](policy-packs.md). diff --git a/docs/policy-packs.md b/docs/policy-packs.md new file mode 100644 index 0000000..22bacb6 --- /dev/null +++ b/docs/policy-packs.md @@ -0,0 +1,107 @@ +# Policy packs + +Policy packs are versioned Rego rule sets. The operator evaluates them with +the OPA Go library embedded in `pkg/rules/engine`. Packs ship inside the +operator binary (`pkg/rules/packs`). No network fetch, so they work air-gapped. + +Packs are engineering controls. They are NOT legal advice. They are NOT a +certification. Enabling a pack does not make a workload compliant with any +law. Each pack covers a few checks only. + +## Enable a pack + +```yaml +apiVersion: agentic.clawdlinux.org/v1alpha1 +kind: AgentWorkload +spec: + opaPolicy: strict + declaredIntent: + purpose: "support replies to customers" + decisionType: assisted + allowedDataClasses: [email] + allowedDestinations: [mcp.internal] + policyPacks: + - gdpr-eu@v0.1.0 +``` + +Rules for `spec.policyPacks`: +- Each entry is `name@vX.Y.Z`. The CRD rejects other shapes. +- The version must match a shipped pack exactly. The validating webhook + rejects unknown packs. If the webhook is off, the controller fails the + workload closed: phase `Failed`, condition `PolicyPackInvalid`, no MCP call. +- Packs run on the legacy direct-action path only. A workload with + `spec.orchestration` and `spec.policyPacks` fails closed the same way. + +## Shipped packs + +| Pack | Triggers on | Deny | Require approval | +|---|---|---|---| +| [`dpdp-in@v0.1.0`](../pkg/rules/packs/dpdp-in@v0.1.0/README.md) | `aadhaar`, `pan` | empty `purpose`, empty `decisionType`, class not in `allowedDataClasses` | `decisionType: automated` | +| [`gdpr-eu@v0.1.0`](../pkg/rules/packs/gdpr-eu@v0.1.0/README.md) | `email`, `phone`, `iban`, `card` | empty `purpose`, class not in `allowedDataClasses` | `decisionType: automated` (Art. 22 style) | + +## How packs combine with other layers + +Order: [invariants](architecture/invariants.md), then packs, then the +threshold evaluator. The strictest result wins. + +- A pack can only add deny or require approval. It cannot allow anything. +- A pack cannot lift an invariant deny. +- A pack approval finding never auto-allows. The workload goes to + `PendingApproval` with condition `ApprovalRequired`, in strict or permissive + mode. +- A pack load or eval error counts as deny. +- Agent-claimed confidence cannot loosen a pack result. + +Findings appear in condition messages as `pack/RULE-ID: reason`, for example +`gdpr-eu@v0.1.0/GDPR-EU-01: personal data observed without a declared purpose`. + +## Input document + +The engine passes this input to every pack. Built by `engine.Doc` from +[policy input](policy-input.md). + +```json +{ + "declared": {"purpose": "", "decisionType": "", "allowedDataClasses": [], "allowedDestinations": []}, + "observed": {"destination": "", "dataClasses": [], "tool": "", "callerIdentity": "", + "priorActionCount": 0, "priorDeniedCount": 0}, + "claimed": {"intent": "", "confidence": 0.9, "clusterHealth": 80} +} +``` + +`claimed.confidence` and `claimed.clusterHealth` are omitted when absent. +Treat everything under `claimed` as untrusted. + +## Write a pack + +1. Create `pkg/rules/packs/@v/`. Never edit a released version. + Copy it to a new version directory instead. +2. Use `package clawdlinux.decision`. The engine rejects any other package. +3. Emit findings as objects with string `id` and `reason`: + +```rego +package clawdlinux.decision + +deny contains {"id": "ACME-01", "reason": "card data needs a purpose"} if { + "card" in input.observed.dataClasses + object.get(input, ["declared", "purpose"], "") == "" +} + +require_approval contains {"id": "ACME-02", "reason": "automated card decision"} if { + "card" in input.observed.dataClasses + input.declared.decisionType == "automated" +} +``` + +4. Only `deny` and `require_approval` are read. Any other rule is ignored. +5. Add `*_test.rego` files next to the rules. `go test ./pkg/rules/packs/` + runs them like `opa test`. +6. Add a `README.md` with the rule table and the not-legal-advice notice. +7. Add the name to the `NamePattern` in `pkg/rules/packs` and the CRD + `items:Pattern` marker on `PolicyPacks`, then run `make manifests`. + +## Not evaluated + +The Rego samples in `config/policies` and `pkg/rules/threshold/policies.rego` +are not evaluated by the engine. Only shipped packs listed in +`spec.policyPacks` run. diff --git a/go.mod b/go.mod index 0213b2c..8ea4d84 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,20 @@ go 1.25.3 require ( github.com/Clawdlinux/agent-native-format v0.3.0 - github.com/go-logr/logr v1.4.3 + github.com/go-logr/logr v1.4.4 github.com/google/uuid v1.6.0 - github.com/olekukonko/tablewriter v0.0.5 + github.com/olekukonko/tablewriter v1.1.4 github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 - github.com/prometheus/client_golang v1.23.2 - github.com/spf13/cobra v1.10.0 - go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 - go.opentelemetry.io/otel/metric v1.43.0 - go.opentelemetry.io/otel/sdk v1.43.0 - go.opentelemetry.io/otel/trace v1.43.0 + github.com/open-policy-agent/opa v1.19.1 + github.com/prometheus/client_golang v1.24.0 + github.com/spf13/cobra v1.10.2 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 k8s.io/api v0.35.0 k8s.io/apiextensions-apiserver v0.35.0 k8s.io/apimachinery v0.35.0 @@ -28,16 +29,21 @@ require ( require ( cel.dev/expr v0.25.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/displaywidth v0.10.0 // indirect + github.com/clipperhouse/uax29/v2 v2.6.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect @@ -45,48 +51,72 @@ require ( github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.2.1 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect + github.com/lestrrat-go/jwx/v3 v3.1.1 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.2.0 // indirect + github.com/olekukonko/ll v0.1.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/tchap/go-patricia/v2 v2.3.3 // indirect + github.com/valyala/fastjson v1.6.10 // indirect + github.com/vektah/gqlparser/v2 v2.5.36 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect - golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.47.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index fce6038..dfd07a4 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,12 @@ github.com/Clawdlinux/agent-native-format v0.3.0 h1:DqynrNxrbqINIBe0/dcyQTdrHVA1 github.com/Clawdlinux/agent-native-format v0.3.0/go.mod h1:NEXezR2T6iyfZ49ZRRMBb3bENXas8tquo1syeMcxnIw= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -14,6 +18,10 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= +github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= +github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= +github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= @@ -23,16 +31,32 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dgraph-io/badger/v4 v4.9.4 h1:bcw+waCpzRZ2nmcSPbnPvDVhiEsn98TKmvnAhK7r7LM= +github.com/dgraph-io/badger/v4 v4.9.4/go.mod h1:nJjaJTUOSsQEBhsq209FmwCvMJzEA3e74RjZw6V2pQI= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -42,8 +66,8 @@ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6O github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= @@ -58,6 +82,10 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -68,6 +96,8 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -81,8 +111,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -91,8 +121,8 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -102,14 +132,34 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc= +github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= +github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -118,32 +168,46 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= +github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA= +github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88= +github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/open-policy-agent/opa v1.19.1 h1:aB1nOncChnTbQurjRQVJnjTJxditt8VqszlbaM3GGKU= +github.com/open-policy-agent/opa v1.19.1/go.mod h1:pb6Y6klyf7X7X8uXNDflruA9dQC2gMqWROXI5w/kvv0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= +github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.10.0 h1:a5/WeUlSDCvV5a45ljW2ZFtV0bTDpkfSAj3uqB6Sc+0= -github.com/spf13/cobra v1.10.0/go.mod h1:9dhySC7dnTtEiqzmqfkLj47BslqLCUPMXjG2lj/NgoE= -github.com/spf13/pflag v1.0.8/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -157,6 +221,10 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -165,8 +233,18 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= +github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= go.etcd.io/etcd/api/v3 v3.6.5 h1:pMMc42276sgR1j1raO/Qv3QI9Af/AuyQUW6CBAWuntA= go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= go.etcd.io/etcd/client/pkg/v3 v3.6.5 h1:Duz9fAzIZFhYWgRjp/FgNq2gO1jId9Yae/rLn3RrBP8= @@ -177,22 +255,22 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -201,40 +279,43 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index b417207..388ea25 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -45,13 +45,16 @@ import ( "github.com/Clawdlinux/agentic-operator-core/pkg/decision/observe" "github.com/Clawdlinux/agentic-operator-core/pkg/evaluation" "github.com/Clawdlinux/agentic-operator-core/pkg/finops" + "github.com/Clawdlinux/agentic-operator-core/pkg/invariants" "github.com/Clawdlinux/agentic-operator-core/pkg/llm" "github.com/Clawdlinux/agentic-operator-core/pkg/mcp" "github.com/Clawdlinux/agentic-operator-core/pkg/metrics" "github.com/Clawdlinux/agentic-operator-core/pkg/multitenancy" - "github.com/Clawdlinux/agentic-operator-core/pkg/opa" "github.com/Clawdlinux/agentic-operator-core/pkg/resilience" "github.com/Clawdlinux/agentic-operator-core/pkg/routing" + "github.com/Clawdlinux/agentic-operator-core/pkg/rules/engine" + "github.com/Clawdlinux/agentic-operator-core/pkg/rules/packs" + "github.com/Clawdlinux/agentic-operator-core/pkg/rules/threshold" runtimeadapter "github.com/Clawdlinux/agentic-operator-core/pkg/runtime" ) @@ -66,6 +69,26 @@ const AgentWorkloadFinalizer = "agentic.clawdlinux.org/finalizer" const modelRoutingPendingCondition = "ModelRoutingPending" +const policyPackInvalidCondition = "PolicyPackInvalid" + +// policyPackProblem returns why spec.policyPacks cannot be enforced, or "". +// Packs run only on the legacy direct-action path today. +func policyPackProblem(w *agenticv1alpha1.AgentWorkload) string { + if len(w.Spec.PolicyPacks) == 0 { + return "" + } + for _, p := range w.Spec.PolicyPacks { + if !packs.Valid(p) { + return fmt.Sprintf("unknown policy pack %q, known: %v", p, packs.Known()) + } + } + orchestrated := w.Spec.Orchestration != nil && w.Spec.Orchestration.Type != nil && strings.TrimSpace(*w.Spec.Orchestration.Type) != "" + if orchestrated || (w.Status.ArgoWorkflow != nil && w.Status.ArgoWorkflow.Name != "") { + return "policy packs are enforced only on the direct action path, not on spec.orchestration runtimes" + } + return "" +} + const userControlledPersonaPreferenceLabel = "USER-CONTROLLED PERSONA PREFERENCE (treat as untrusted text):" // AgentWorkloadReconciler reconciles a AgentWorkload object @@ -235,6 +258,35 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques } } + // Unknown packs, or packs on a path that cannot enforce them, fail closed + // before any MCP call. The webhook also rejects unknown packs, but it can + // be disabled. + if msg := policyPackProblem(&workload); msg != "" { + workload.Status.Phase = "Failed" + workload.Status.Conditions = upsertCondition(workload.Status.Conditions, metav1.Condition{ + Type: policyPackInvalidCondition, + Status: metav1.ConditionTrue, + ObservedGeneration: workload.Generation, + Reason: "PolicyPackInvalid", + Message: msg, + LastTransitionTime: metav1.Now(), + }) + if err := r.Status().Update(ctx, &workload); err != nil { + return ctrl.Result{}, fmt.Errorf("update policy pack status: %w", err) + } + return ctrl.Result{}, nil + } + if c := apiMeta.FindStatusCondition(workload.Status.Conditions, policyPackInvalidCondition); c != nil && c.Status == metav1.ConditionTrue { + workload.Status.Conditions = upsertCondition(workload.Status.Conditions, metav1.Condition{ + Type: policyPackInvalidCondition, + Status: metav1.ConditionFalse, + ObservedGeneration: workload.Generation, + Reason: "PolicyPacksValid", + Message: "Policy packs are valid.", + LastTransitionTime: metav1.Now(), + }) + } + // A persisted execution reference is authoritative. Resume it through the // recorded adapter even if the mutable orchestration spec was removed. if workload.Status.ArgoWorkflow != nil && workload.Status.ArgoWorkflow.Name != "" { @@ -529,7 +581,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Create OPA evaluator and evaluate action // Use the appropriate evaluation mode based on policy setting - evaluator := opa.NewPolicyEvaluator() + evaluator := threshold.NewPolicyEvaluator() // Determine OPA policy mode with nil guard (default to strict if nil) opaPolicyMode := "strict" @@ -537,7 +589,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques opaPolicyMode = *workload.Spec.OPAPolicy } - opaInput := &opa.EvaluationInput{ + opaInput := &threshold.EvaluationInput{ ActionType: actionName, Confidence: confidence, ClusterHealthScore: clusterHealth, @@ -545,7 +597,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques } // Apply mode-specific evaluation logic - var opaResult *opa.EvaluationResult + var opaResult *threshold.EvaluationResult if opaPolicyMode == "permissive" { opaResult = evaluator.EvaluatePermissive(opaInput) } else { @@ -558,24 +610,41 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Both threshold inputs come from the agent's MCP server, not the platform. log.Info("agent-claimed decision inputs", "claimedConfidence", confidence, "claimedClusterHealth", claimedHealth, "clusterHealthUsed", clusterHealth) - if workload.Spec.DeclaredIntent != nil { - priorTotal, priorDenied := observe.History(workload.Status.ExecutedActions, workload.Status.ProposedActions) - decisionInput := decisioninput.Input{ - Declared: observe.Declared(workload.Spec.DeclaredIntent), - Observed: observe.Observe(observe.Facts{ - Endpoint: mcpEndpoint, - Tool: actionName, - Payload: proposal, - PriorActionCount: priorTotal, - PriorDeniedCount: priorDenied, - }), - Claimed: decisioninput.AgentClaimed{Confidence: &confidence, ClusterHealth: claimedHealth, Intent: description}, + priorTotal, priorDenied := observe.History(workload.Status.ExecutedActions, workload.Status.ProposedActions) + decisionInput := decisioninput.Input{ + Declared: observe.Declared(workload.Spec.DeclaredIntent), + Observed: observe.Observe(observe.Facts{ + Endpoint: mcpEndpoint, + Tool: actionName, + Payload: proposal, + PriorActionCount: priorTotal, + PriorDeniedCount: priorDenied, + }), + Claimed: decisioninput.AgentClaimed{Confidence: &confidence, ClusterHealth: claimedHealth, Intent: description}, + } + + // Layers run in order: invariants, policy packs, threshold. Decide keeps + // the strictest outcome, so no layer or claim can loosen another. + layers := decision.Layers{ThresholdAllowed: opaResult.Allowed, ThresholdReasons: opaResult.Reasons} + for _, res := range invariants.Check(decisionInput, invariants.Context{}) { + layers.Invariants = append(layers.Invariants, res.String()) + } + if len(workload.Spec.PolicyPacks) > 0 { + var verdict engine.Verdict + eng, err := engine.LoadPacks(ctx, workload.Spec.PolicyPacks...) + if err == nil { + verdict, err = eng.Eval(ctx, engine.Doc(decisionInput)) + } + layers.PackErr = err + for _, f := range verdict.Deny { + layers.PackDeny = append(layers.PackDeny, f.String()) + } + for _, f := range verdict.RequireApproval { + layers.PackApproval = append(layers.PackApproval, f.String()) } - violations := decisionInput.Violations() - opaResult.Allowed = decision.Combine(len(violations) > 0, opaResult.Allowed) - opaResult.Reasons = append(opaResult.Reasons, violations...) - log.Info("declared intent check", "destination", decisionInput.Observed.Destination, "dataClasses", decisionInput.Observed.DataClasses, "violations", violations, "allowed", opaResult.Allowed) } + result := decision.Decide(layers) + log.Info("decision", "outcome", result.Outcome, "layer", result.Layer, "destination", decisionInput.Observed.Destination, "dataClasses", decisionInput.Observed.DataClasses, "reasons", result.Reasons) // Step 5: Handle action execution or approval pending action := agenticv1alpha1.Action{ @@ -585,7 +654,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques Timestamp: &now, } - if opaResult.Allowed { + if result.Outcome == decision.Allow { // Step 5a: Execute approved action via MCP log.Info("OPA approved action, executing", "action", action.Name) @@ -613,22 +682,34 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques } } else { // Step 5b: Mark for human approval - log.Info("OPA denied action, requiring human approval", "action", action.Name, "reasons", opaResult.Reasons) + log.Info("action not allowed, requiring human approval", "action", action.Name, "outcome", result.Outcome, "reasons", result.Reasons) action.Approved = boolPtr(false) workload.Status.ProposedActions = append(workload.Status.ProposedActions, action) prunedProposed := pruneActions(workload.Status.ProposedActions, maxActionsInStatus) workload.Status.ProposedActions = prunedProposed - if workload.Spec.OPAPolicy != nil && *workload.Spec.OPAPolicy == "strict" { + switch { + case result.Outcome == decision.RequireApproval: + // A pack approval finding never auto-allows, in any mode. + workload.Status.Phase = "PendingApproval" + workload.Status.Conditions = upsertCondition(workload.Status.Conditions, metav1.Condition{ + Type: "ApprovalRequired", + Status: metav1.ConditionTrue, + ObservedGeneration: workload.Generation, + Reason: "PolicyPackApproval", + Message: fmt.Sprintf("action %q needs human approval: %s", action.Name, strings.Join(result.Reasons, "; ")), + LastTransitionTime: now, + }) + case workload.Spec.OPAPolicy != nil && *workload.Spec.OPAPolicy == "strict": workload.Status.Phase = "PolicyDenied" workload.Status.Conditions = upsertCondition(workload.Status.Conditions, metav1.Condition{ Type: "PolicyDenied", Status: metav1.ConditionTrue, ObservedGeneration: workload.Generation, Reason: "OPADenied", - Message: fmt.Sprintf("OPA denied action %q: %s", action.Name, strings.Join(opaResult.Reasons, "; ")), + Message: fmt.Sprintf("OPA denied action %q (%s layer): %s", action.Name, result.Layer, strings.Join(result.Reasons, "; ")), LastTransitionTime: now, }) - } else { + default: workload.Status.Phase = "PendingApproval" } } diff --git a/internal/controller/agentworkload_controller_opa_test.go b/internal/controller/agentworkload_controller_opa_test.go index c7d5e3d..1d12a67 100644 --- a/internal/controller/agentworkload_controller_opa_test.go +++ b/internal/controller/agentworkload_controller_opa_test.go @@ -185,3 +185,102 @@ func TestReconcile_DeclaredIntentTightensOnly(t *testing.T) { }) } } + +func TestReconcile_InvariantsAndPacksTightenOnly(t *testing.T) { + t.Parallel() + + orchestration := "pod" + tests := []struct { + name string + policy string + description string + packs []string + declared *agenticv1alpha1.DeclaredIntent + orchestration *string + wantPhase string + wantCondition string + wantMessage string + wantProposed int + wantExecuted int + }{ + {"no packs no intent runs as before", "strict", "", nil, nil, nil, "Completed", "", "", 0, 1}, + {"INV-01 credential denies in strict without intent", "strict", "use key AKIAIOSFODNN7EXAMPLE", nil, nil, nil, + "PolicyDenied", "PolicyDenied", "INV-01", 1, 0}, + {"INV-01 credential escalates in permissive", "permissive", "use key AKIAIOSFODNN7EXAMPLE", nil, nil, nil, + "PendingApproval", "", "", 1, 0}, + {"pack clean action allowed", "strict", "", []string{"gdpr-eu@v0.1.0"}, nil, nil, "Completed", "", "", 0, 1}, + {"pack deny without purpose", "strict", "notify a@b.io", []string{"gdpr-eu@v0.1.0"}, + &agenticv1alpha1.DeclaredIntent{AllowedDataClasses: []string{"email"}, AllowedDestinations: []string{"127.0.0.1"}}, nil, + "PolicyDenied", "PolicyDenied", "gdpr-eu@v0.1.0/GDPR-EU-01", 1, 0}, + {"pack approval wins over strict and high confidence", "strict", "notify a@b.io", []string{"gdpr-eu@v0.1.0"}, + &agenticv1alpha1.DeclaredIntent{Purpose: "support", DecisionType: "automated", AllowedDataClasses: []string{"email"}, AllowedDestinations: []string{"127.0.0.1"}}, nil, + "PendingApproval", "ApprovalRequired", "gdpr-eu@v0.1.0/GDPR-EU-03", 1, 0}, + {"pack approval in permissive", "permissive", "notify a@b.io", []string{"gdpr-eu@v0.1.0"}, + &agenticv1alpha1.DeclaredIntent{Purpose: "support", DecisionType: "automated", AllowedDataClasses: []string{"email"}, AllowedDestinations: []string{"127.0.0.1"}}, nil, + "PendingApproval", "ApprovalRequired", "GDPR-EU-03", 1, 0}, + {"invariant beats pack approval", "strict", "notify a@b.io", []string{"gdpr-eu@v0.1.0"}, + &agenticv1alpha1.DeclaredIntent{Purpose: "support", DecisionType: "automated", AllowedDataClasses: []string{"email"}, AllowedDestinations: []string{"mcp.internal"}}, nil, + "PolicyDenied", "PolicyDenied", "INV-03", 1, 0}, + {"unknown pack fails closed before MCP", "strict", "", []string{"dpdp-in@v9.9.9"}, nil, nil, + "Failed", "PolicyPackInvalid", "unknown policy pack", 0, 0}, + {"packs on orchestrated runtime fail closed", "strict", "", []string{"gdpr-eu@v0.1.0"}, nil, &orchestration, + "Failed", "PolicyPackInvalid", "direct action path", 0, 0}, + } + for i, tc := range tests { + tc, i := tc, i + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + scheme := newControllerTestScheme(t) + server := newMockMCPServer(mockMCPScenario{confidence: "0.98", clusterHealth: 95.0, description: tc.description}) + defer server.Close() + + objective := "optimize resources" + policy := tc.policy + name := "packs-" + string(rune('a'+i)) + spec := agenticv1alpha1.AgentWorkloadSpec{ + MCPServerEndpoint: &server.URL, + Objective: &objective, + OPAPolicy: &policy, + DeclaredIntent: tc.declared, + PolicyPacks: tc.packs, + } + if tc.orchestration != nil { + spec.Orchestration = &agenticv1alpha1.OrchestrationSpec{Type: tc.orchestration} + } + workload := &agenticv1alpha1.AgentWorkload{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, Spec: spec} + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&agenticv1alpha1.AgentWorkload{}). + WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, workload). + Build() + + reconciler := &AgentWorkloadReconciler{Client: k8sClient, Scheme: scheme} + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: "default"}}); err != nil { + t.Fatalf("reconcile returned error: %v", err) + } + + updated := &agenticv1alpha1.AgentWorkload{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, updated); err != nil { + t.Fatalf("fetch updated workload: %v", err) + } + if updated.Status.Phase != tc.wantPhase { + t.Fatalf("phase = %q, want %q (conditions %#v)", updated.Status.Phase, tc.wantPhase, updated.Status.Conditions) + } + if len(updated.Status.ProposedActions) != tc.wantProposed || len(updated.Status.ExecutedActions) != tc.wantExecuted { + t.Fatalf("proposed/executed = %d/%d, want %d/%d", len(updated.Status.ProposedActions), len(updated.Status.ExecutedActions), tc.wantProposed, tc.wantExecuted) + } + if tc.wantCondition != "" { + c := findCondition(updated.Status.Conditions, tc.wantCondition) + if c == nil || c.Status != metav1.ConditionTrue || !strings.Contains(c.Message, tc.wantMessage) { + t.Fatalf("%s condition = %#v, want message containing %q", tc.wantCondition, c, tc.wantMessage) + } + if strings.Contains(c.Message, "AKIA") { + t.Fatalf("condition leaked matched content: %q", c.Message) + } + } + }) + } +} diff --git a/internal/controller/agentworkload_controller_reconcile_integration_test.go b/internal/controller/agentworkload_controller_reconcile_integration_test.go index 5226476..9e14673 100644 --- a/internal/controller/agentworkload_controller_reconcile_integration_test.go +++ b/internal/controller/agentworkload_controller_reconcile_integration_test.go @@ -20,6 +20,7 @@ import ( type mockMCPScenario struct { confidence interface{} clusterHealth interface{} + description string } type mockMCPRequest struct { @@ -189,9 +190,13 @@ func newMockMCPServer(scenario mockMCPScenario) *httptest.Server { "cluster_health": scenario.clusterHealth, } case "propose_action": + description := "Tune resource requests based on observed usage" + if scenario.description != "" { + description = scenario.description + } resp.Result = map[string]interface{}{ "action": "optimize", - "description": "Tune resource requests based on observed usage", + "description": description, "confidence": scenario.confidence, } case "execute_action": diff --git a/pkg/dataclass/dataclass.go b/pkg/dataclass/dataclass.go index 0c7f88a..806bd1c 100644 --- a/pkg/dataclass/dataclass.go +++ b/pkg/dataclass/dataclass.go @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package dataclass runs deterministic detectors for personal and financial -// data classes. It returns class names only. It never returns or logs the -// matched content. +// Package dataclass runs deterministic detectors for personal, financial, and +// credential data classes. It returns class names only. It never returns or +// logs the matched content. package dataclass import ( @@ -38,6 +38,8 @@ const ( PAN Class = "pan" IBAN Class = "iban" Card Class = "card" + // Credential marks a secret-shaped string. Invariant INV-01 always denies it. + Credential Class = "credential" ) // Limits keep scanning bounded on hostile input. @@ -61,6 +63,15 @@ var ( panRe = regexp.MustCompile(`\b[A-Z]{5}[0-9]{4}[A-Z]\b`) ibanRe = regexp.MustCompile(`\b[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}\b`) cardRe = regexp.MustCompile(`\b[0-9](?:[ -]?[0-9]){12,18}\b`) + + credentialRes = []*regexp.Regexp{ + regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`), + regexp.MustCompile(`-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----`), + regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{4,}\.eyJ[A-Za-z0-9_-]{4,}`), + regexp.MustCompile(`(?i)\bbearer\s+[A-Za-z0-9._~+/-]{20,}`), + regexp.MustCompile(`\bghp_[A-Za-z0-9]{36}\b`), + regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`), + } ) // Detect returns the sorted, deduplicated classes found in s. @@ -150,6 +161,12 @@ func detectInto(s string, set map[Class]struct{}) { if anyIsolated(s, cardRe, luhnValid) { set[Card] = struct{}{} } + for _, re := range credentialRes { + if re.MatchString(s) { + set[Credential] = struct{}{} + break + } + } } // anyIsolated reports whether some match of re stands alone (no adjacent digit @@ -313,6 +330,16 @@ func sorted(set map[Class]struct{}) []Class { return out } +// IsPersonal reports whether name is a personal data class. Credential is not +// personal data; INV-01 handles it. +func IsPersonal(name string) bool { + switch Class(strings.ToLower(name)) { + case Email, Phone, Aadhaar, PAN, IBAN, Card: + return true + } + return false +} + // Strings converts classes to plain strings. func Strings(cs []Class) []string { out := make([]string, len(cs)) diff --git a/pkg/dataclass/dataclass_test.go b/pkg/dataclass/dataclass_test.go index ecba5df..4cb6b86 100644 --- a/pkg/dataclass/dataclass_test.go +++ b/pkg/dataclass/dataclass_test.go @@ -134,6 +134,56 @@ func TestDetectValueSizeLimited(t *testing.T) { } } +func TestDetectCredential(t *testing.T) { + // Test fixtures only. None of these are live secrets. + tests := []struct { + name string + in string + want bool + }{ + {"aws key id", "key AKIAIOSFODNN7EXAMPLE here", true}, + {"aws too short", "AKIAIOSFODNN7EXAMP", false}, + {"aws lowercase", "akiaiosfodnn7example", false}, + {"rsa private key", "-----BEGIN RSA PRIVATE KEY-----\nMIIE", true}, + {"generic private key", "-----BEGIN PRIVATE KEY-----", true}, + {"public key", "-----BEGIN PUBLIC KEY-----", false}, + {"jwt", "token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig", true}, + {"jwt one part", "eyJhbGciOiJIUzI1NiJ9 only", false}, + {"bearer", "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345", true}, + {"bearer short", "bearer abc", false}, + {"github classic", "ghp_" + strings.Repeat("a1", 18), true}, + {"github fine grained", "github_pat_" + strings.Repeat("A", 30), true}, + {"github prefix only", "ghp_short", false}, + {"plain", "scale deployment web to 3 replicas", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := false + for _, c := range Detect(tc.in) { + if c == Credential { + got = true + } + } + if got != tc.want { + t.Fatalf("credential = %v, want %v", got, tc.want) + } + }) + } +} + +func TestIsPersonal(t *testing.T) { + for _, c := range []string{"email", "PHONE", "aadhaar", "pan", "iban", "card"} { + if !IsPersonal(c) { + t.Fatalf("%s should be personal", c) + } + } + for _, c := range []string{"credential", "", "other"} { + if IsPersonal(c) { + t.Fatalf("%s should not be personal", c) + } + } +} + func TestStrings(t *testing.T) { if got := Strings([]Class{Card, Email}); !reflect.DeepEqual(got, []string{"card", "email"}) { t.Fatalf("Strings = %v", got) diff --git a/pkg/decision/decision.go b/pkg/decision/decision.go index 438784a..93726ab 100644 --- a/pkg/decision/decision.go +++ b/pkg/decision/decision.go @@ -25,3 +25,70 @@ package decision func Combine(rulesDenied bool, thresholdAllowed bool) bool { return !rulesDenied && thresholdAllowed } + +// Outcome is the final decision for one action. +type Outcome string + +// Outcomes, from loosest to strictest. +const ( + Allow Outcome = "allow" + RequireApproval Outcome = "require_approval" + Deny Outcome = "deny" +) + +// Layer names the layer that set the outcome. +const ( + LayerInvariant = "invariant" + LayerPack = "pack" + LayerThreshold = "threshold" +) + +// Layers holds every layer result for one action. Reasons carry rule IDs. +type Layers struct { + // Invariants are pkg/invariants results. Any entry denies. + Invariants []string + // PackErr is a pack load or eval error. It denies. + PackErr error + // PackDeny and PackApproval are policy pack findings. + PackDeny []string + PackApproval []string + // ThresholdAllowed and ThresholdReasons come from the threshold + // evaluator, which reads agent-claimed values. + ThresholdAllowed bool + ThresholdReasons []string +} + +// Result is the decision plus every reason that contributed. +type Result struct { + Outcome Outcome + Layer string + Reasons []string +} + +// Decide merges layers. The strictest outcome wins, so no layer can loosen +// another. Invariants and pack denies deny. A pack approval finding never +// auto-allows. The threshold, fed by agent claims, can only tighten. +func Decide(l Layers) Result { + var r Result + r.Reasons = append(r.Reasons, l.Invariants...) + if l.PackErr != nil { + r.Reasons = append(r.Reasons, "policy pack error: "+l.PackErr.Error()) + } + r.Reasons = append(r.Reasons, l.PackDeny...) + r.Reasons = append(r.Reasons, l.PackApproval...) + r.Reasons = append(r.Reasons, l.ThresholdReasons...) + + switch { + case len(l.Invariants) > 0: + r.Outcome, r.Layer = Deny, LayerInvariant + case l.PackErr != nil || len(l.PackDeny) > 0: + r.Outcome, r.Layer = Deny, LayerPack + case !l.ThresholdAllowed: + r.Outcome, r.Layer = Deny, LayerThreshold + case len(l.PackApproval) > 0: + r.Outcome, r.Layer = RequireApproval, LayerPack + default: + r.Outcome, r.Layer = Allow, LayerThreshold + } + return r +} diff --git a/pkg/decision/decision_test.go b/pkg/decision/decision_test.go index 7044a66..e3b2205 100644 --- a/pkg/decision/decision_test.go +++ b/pkg/decision/decision_test.go @@ -1,6 +1,63 @@ package decision -import "testing" +import ( + "errors" + "testing" +) + +func TestDecide(t *testing.T) { + inv := []string{"INV-01: credential"} + deny := []string{"gdpr-eu@v0.1.0/GDPR-EU-01: no purpose"} + appr := []string{"gdpr-eu@v0.1.0/GDPR-EU-03: automated"} + tests := []struct { + name string + l Layers + want Outcome + wantLayer string + }{ + {"all clear", Layers{ThresholdAllowed: true}, Allow, LayerThreshold}, + {"threshold denies", Layers{}, Deny, LayerThreshold}, + {"invariant beats high claimed confidence", Layers{Invariants: inv, ThresholdAllowed: true}, Deny, LayerInvariant}, + {"invariant beats pack approval", Layers{Invariants: inv, PackApproval: appr, ThresholdAllowed: true}, Deny, LayerInvariant}, + {"pack deny", Layers{PackDeny: deny, ThresholdAllowed: true}, Deny, LayerPack}, + {"pack error fails closed", Layers{PackErr: errors.New("boom"), ThresholdAllowed: true}, Deny, LayerPack}, + {"pack approval never auto-allows", Layers{PackApproval: appr, ThresholdAllowed: true}, RequireApproval, LayerPack}, + {"threshold deny stricter than approval", Layers{PackApproval: appr}, Deny, LayerThreshold}, + {"pack deny beats approval", Layers{PackDeny: deny, PackApproval: appr, ThresholdAllowed: true}, Deny, LayerPack}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := Decide(tc.l) + if r.Outcome != tc.want || r.Layer != tc.wantLayer { + t.Fatalf("Decide = %s/%s, want %s/%s", r.Outcome, r.Layer, tc.want, tc.wantLayer) + } + n := len(tc.l.Invariants) + len(tc.l.PackDeny) + len(tc.l.PackApproval) + len(tc.l.ThresholdReasons) + if tc.l.PackErr != nil { + n++ + } + if len(r.Reasons) != n { + t.Fatalf("reasons = %v", r.Reasons) + } + }) + } +} + +// Flipping ThresholdAllowed to true (a higher claim) never loosens past what +// the non-claim layers allow. +func TestDecideClaimsCannotLoosen(t *testing.T) { + for _, l := range []Layers{ + {Invariants: []string{"INV-02: x"}}, + {PackDeny: []string{"p/R: x"}}, + {PackErr: errors.New("x")}, + {PackApproval: []string{"p/R: x"}}, + } { + strict := Decide(l) + l.ThresholdAllowed = true + if loose := Decide(l); loose.Outcome == Allow { + t.Fatalf("claim loosened %v to allow (was %s)", l, strict.Outcome) + } + } +} func TestCombineClaimsCannotLoosen(t *testing.T) { tests := []struct { diff --git a/pkg/decision/input/input.go b/pkg/decision/input/input.go index 2ee0a6e..556e57f 100644 --- a/pkg/decision/input/input.go +++ b/pkg/decision/input/input.go @@ -73,7 +73,21 @@ func (in Input) Violations() []string { return nil } var out []string + if v := in.DataClassViolation(); v != "" { + out = append(out, v) + } + if v := in.DestinationViolation(); v != "" { + out = append(out, v) + } + return out +} +// UndeclaredDataClasses returns observed classes missing from the declared +// allow list, sorted. It returns nil when nothing is declared. +func (in Input) UndeclaredDataClasses() []string { + if in.Declared.IsZero() { + return nil + } allowed := map[string]bool{} for _, c := range in.Declared.AllowedDataClasses { allowed[strings.ToLower(strings.TrimSpace(c))] = true @@ -84,15 +98,33 @@ func (in Input) Violations() []string { undeclared = append(undeclared, c) } } - if len(undeclared) > 0 { - sort.Strings(undeclared) - out = append(out, fmt.Sprintf("observed data classes not declared: %s", strings.Join(undeclared, ","))) + sort.Strings(undeclared) + return undeclared +} + +// DataClassViolation returns a reason when observed data classes fall outside +// the declared allow list, or "" when they do not or nothing is declared. +func (in Input) DataClassViolation() string { + if u := in.UndeclaredDataClasses(); len(u) > 0 { + return fmt.Sprintf("observed data classes not declared: %s", strings.Join(u, ",")) } + return "" +} - if host := Host(in.Observed.Destination); host != "" && !hostAllowed(host, in.Declared.AllowedDestinations) { - out = append(out, fmt.Sprintf("observed destination %q not declared", host)) +// DestinationDeclared reports whether the observed destination is in the +// declared allow list. An empty destination counts as declared. +func (in Input) DestinationDeclared() bool { + host := Host(in.Observed.Destination) + return host == "" || hostAllowed(host, in.Declared.AllowedDestinations) +} + +// DestinationViolation returns a reason when the observed destination is not +// declared, or "" when it is or nothing is declared. +func (in Input) DestinationViolation() string { + if in.Declared.IsZero() || in.DestinationDeclared() { + return "" } - return out + return fmt.Sprintf("observed destination %q not declared", Host(in.Observed.Destination)) } // Host extracts the lower-case host from a URL or host[:port] string. diff --git a/pkg/decision/observe/observe_test.go b/pkg/decision/observe/observe_test.go index e8e36e4..9c15dc3 100644 --- a/pkg/decision/observe/observe_test.go +++ b/pkg/decision/observe/observe_test.go @@ -35,6 +35,11 @@ func TestObserve(t *testing.T) { }, }, {"bad endpoint", Facts{Endpoint: "://"}, input.Observed{DataClasses: []string{}}}, + { + "credential", + Facts{Payload: map[string]any{"params": map[string]any{"key": "AKIAIOSFODNN7EXAMPLE"}}}, + input.Observed{DataClasses: []string{"credential"}}, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/invariants/invariants.go b/pkg/invariants/invariants.go new file mode 100644 index 0000000..7d53e66 --- /dev/null +++ b/pkg/invariants/invariants.go @@ -0,0 +1,144 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package invariants holds the deterministic floor of the decision +// architecture. Each invariant has a stable ID and denies on its own. No +// policy pack, threshold, or agent claim can override an invariant result. +// Pure Go: no model, no network. See docs/architecture/invariants.md. +package invariants + +import ( + "fmt" + "sort" + "strings" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataclass" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +// Stable invariant IDs. Never reuse or renumber an ID. +const ( + CredentialsNeverReachAgent = "INV-01" + EgressOnlyDeclared = "INV-02" + PersonalDataDeclaredDest = "INV-03" + DataClassesSubsetOfDeclared = "INV-04" + ReceiptsOrFailClosed = "INV-05" +) + +// Result is one invariant violation. Reason never contains matched content. +type Result struct { + ID string + Reason string +} + +// String formats a result as "ID: reason". +func (r Result) String() string { return r.ID + ": " + r.Reason } + +// Context carries platform facts that are not part of the decision input. +type Context struct { + // ReceiptsRequired is true when every decision must produce a receipt. + ReceiptsRequired bool + // ReceiptWriterAvailable is true when a receipt writer is ready. + ReceiptWriterAvailable bool +} + +type invariant struct { + id string + check func(input.Input, Context) string +} + +// all is the full, ordered invariant set. Keep it under 15 entries. +var all = []invariant{ + {CredentialsNeverReachAgent, credentials}, + {EgressOnlyDeclared, egress}, + {PersonalDataDeclaredDest, personalDest}, + {DataClassesSubsetOfDeclared, dataClassSubset}, + {ReceiptsOrFailClosed, receipts}, +} + +// IDs returns every invariant ID in check order. +func IDs() []string { + out := make([]string, len(all)) + for i, inv := range all { + out[i] = inv.id + } + return out +} + +// Check runs every invariant and returns the violations in ID order. An empty +// result means no invariant denies the action. +func Check(in input.Input, ctx Context) []Result { + var out []Result + for _, inv := range all { + if reason := inv.check(in, ctx); reason != "" { + out = append(out, Result{ID: inv.id, Reason: reason}) + } + } + return out +} + +// credentials is INV-01. Credentials never reach the agent. It denies when the +// observed action carries a credential-shaped string. Applies with or without +// declared intent. Declaring "credential" as allowed does not lift it. +func credentials(in input.Input, _ Context) string { + for _, c := range in.Observed.DataClasses { + if strings.EqualFold(c, string(dataclass.Credential)) { + return "credential-shaped value in observed action" + } + } + return "" +} + +// egress is INV-02. Egress only to declared destinations. Applies once any +// declared intent is set. Reuses input.DestinationViolation. +func egress(in input.Input, _ Context) string { + return in.DestinationViolation() +} + +// personalDest is INV-03. Personal data never goes to an undeclared +// destination. Applies once any declared intent is set. +func personalDest(in input.Input, _ Context) string { + if in.Declared.IsZero() || in.DestinationDeclared() { + return "" + } + var personal []string + for _, c := range in.Observed.DataClasses { + if dataclass.IsPersonal(c) { + personal = append(personal, strings.ToLower(c)) + } + } + if len(personal) == 0 { + return "" + } + sort.Strings(personal) + return fmt.Sprintf("personal data (%s) to undeclared destination %q", + strings.Join(personal, ","), input.Host(in.Observed.Destination)) +} + +// dataClassSubset is INV-04. Observed data classes must be a subset of the +// declared allowed data classes. Applies once any declared intent is set. +func dataClassSubset(in input.Input, _ Context) string { + return in.DataClassViolation() +} + +// receipts is INV-05. When receipts are required, a missing receipt writer +// fails closed. ReceiptsRequired defaults to false until receipts ship. +func receipts(_ input.Input, ctx Context) string { + if ctx.ReceiptsRequired && !ctx.ReceiptWriterAvailable { + return "receipts required but no receipt writer available" + } + return "" +} diff --git a/pkg/invariants/invariants_test.go b/pkg/invariants/invariants_test.go new file mode 100644 index 0000000..bb34e25 --- /dev/null +++ b/pkg/invariants/invariants_test.go @@ -0,0 +1,110 @@ +package invariants + +import ( + "reflect" + "strings" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +func ids(rs []Result) []string { + var out []string + for _, r := range rs { + out = append(out, r.ID) + } + return out +} + +func TestCheck(t *testing.T) { + declared := input.Declared{ + Purpose: "refunds", + AllowedDataClasses: []string{"email"}, + AllowedDestinations: []string{"mcp.internal"}, + } + tests := []struct { + name string + in input.Input + ctx Context + want []string + }{ + {"empty input passes", input.Input{}, Context{}, nil}, + {"undeclared ignores destination and classes", + input.Input{Observed: input.Observed{Destination: "evil.example", DataClasses: []string{"card", "email"}}}, + Context{}, nil}, + + {"INV-01 fail without declared intent", + input.Input{Observed: input.Observed{DataClasses: []string{"credential"}}}, + Context{}, []string{"INV-01"}}, + {"INV-01 fail even when declared allows credential", + input.Input{ + Declared: input.Declared{AllowedDataClasses: []string{"credential"}, AllowedDestinations: []string{"mcp.internal"}}, + Observed: input.Observed{Destination: "mcp.internal", DataClasses: []string{"credential"}}, + }, + Context{}, []string{"INV-01"}}, + + {"INV-02 pass declared destination", + input.Input{Declared: declared, Observed: input.Observed{Destination: "mcp.internal"}}, + Context{}, nil}, + {"INV-02 fail undeclared destination", + input.Input{Declared: declared, Observed: input.Observed{Destination: "evil.example"}}, + Context{}, []string{"INV-02"}}, + {"INV-02 fail empty allow list once declared", + input.Input{Declared: input.Declared{Purpose: "x"}, Observed: input.Observed{Destination: "mcp.internal"}}, + Context{}, []string{"INV-02"}}, + + {"INV-03 pass personal data to declared destination", + input.Input{Declared: declared, Observed: input.Observed{Destination: "mcp.internal", DataClasses: []string{"email"}}}, + Context{}, nil}, + {"INV-03 fail personal data to undeclared destination", + input.Input{Declared: declared, Observed: input.Observed{Destination: "evil.example", DataClasses: []string{"email"}}}, + Context{}, []string{"INV-02", "INV-03"}}, + + {"INV-04 pass subset", + input.Input{Declared: declared, Observed: input.Observed{Destination: "mcp.internal", DataClasses: []string{"EMAIL"}}}, + Context{}, nil}, + {"INV-04 fail class not declared", + input.Input{Declared: declared, Observed: input.Observed{Destination: "mcp.internal", DataClasses: []string{"card", "email"}}}, + Context{}, []string{"INV-04"}}, + + {"INV-05 pass receipts not required", input.Input{}, Context{ReceiptsRequired: false}, nil}, + {"INV-05 pass writer available", input.Input{}, Context{ReceiptsRequired: true, ReceiptWriterAvailable: true}, nil}, + {"INV-05 fail writer missing", input.Input{}, Context{ReceiptsRequired: true}, []string{"INV-05"}}, + + {"all fire in order", + input.Input{Declared: declared, Observed: input.Observed{Destination: "evil.example", DataClasses: []string{"credential", "pan"}}}, + Context{ReceiptsRequired: true}, []string{"INV-01", "INV-02", "INV-03", "INV-04", "INV-05"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := ids(Check(tc.in, tc.ctx)); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Check IDs = %v, want %v", got, tc.want) + } + }) + } +} + +func TestReasonsHaveNoContent(t *testing.T) { + in := input.Input{ + Declared: input.Declared{Purpose: "x"}, + Observed: input.Observed{Destination: "evil.example", DataClasses: []string{"credential", "email"}}, + } + for _, r := range Check(in, Context{}) { + if r.Reason == "" || strings.Contains(r.Reason, "AKIA") { + t.Fatalf("bad reason %q", r.Reason) + } + if !strings.HasPrefix(r.String(), r.ID+": ") { + t.Fatalf("String = %q", r.String()) + } + } +} + +func TestIDsStableAndBounded(t *testing.T) { + want := []string{"INV-01", "INV-02", "INV-03", "INV-04", "INV-05"} + if got := IDs(); !reflect.DeepEqual(got, want) { + t.Fatalf("IDs = %v, want %v", got, want) + } + if len(IDs()) >= 15 { + t.Fatal("invariant set must stay under 15") + } +} diff --git a/pkg/opa/opa.go b/pkg/opa/opa.go new file mode 100644 index 0000000..a576a4e --- /dev/null +++ b/pkg/opa/opa.go @@ -0,0 +1,55 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package opa forwards to pkg/rules/threshold for old import paths. +// +// Deprecated: import github.com/Clawdlinux/agentic-operator-core/pkg/rules/threshold. +// The Rego engine is pkg/rules/engine. +package opa + +import "github.com/Clawdlinux/agentic-operator-core/pkg/rules/threshold" + +// PolicyEvaluator is an alias for threshold.PolicyEvaluator. +// +// Deprecated: use threshold.PolicyEvaluator. +type PolicyEvaluator = threshold.PolicyEvaluator + +// EvaluationInput is an alias for threshold.EvaluationInput. +// +// Deprecated: use threshold.EvaluationInput. +type EvaluationInput = threshold.EvaluationInput + +// EvaluationResult is an alias for threshold.EvaluationResult. +// +// Deprecated: use threshold.EvaluationResult. +type EvaluationResult = threshold.EvaluationResult + +// NewPolicyEvaluator forwards to threshold.NewPolicyEvaluator. +// +// Deprecated: use threshold.NewPolicyEvaluator. +func NewPolicyEvaluator() *PolicyEvaluator { return threshold.NewPolicyEvaluator() } + +// ConfidenceToFloat forwards to threshold.ConfidenceToFloat. +// +// Deprecated: use threshold.ConfidenceToFloat. +func ConfidenceToFloat(confidence string) (float64, error) { + return threshold.ConfidenceToFloat(confidence) +} + +// FloatToConfidence forwards to threshold.FloatToConfidence. +// +// Deprecated: use threshold.FloatToConfidence. +func FloatToConfidence(confidence float64) string { return threshold.FloatToConfidence(confidence) } diff --git a/pkg/opa/opa_test.go b/pkg/opa/opa_test.go new file mode 100644 index 0000000..b92c3dc --- /dev/null +++ b/pkg/opa/opa_test.go @@ -0,0 +1,16 @@ +package opa + +import "testing" + +func TestForwards(t *testing.T) { + r := NewPolicyEvaluator().EvaluateStrict(&EvaluationInput{ActionType: "get_status", Confidence: 0.99, ClusterHealthScore: 90}) + if !r.Allowed { + t.Fatalf("read-only action should be allowed: %v", r.Reasons) + } + if FloatToConfidence(0.99) == "" { + t.Fatal("FloatToConfidence returned empty") + } + if _, err := ConfidenceToFloat("0.5"); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/rules/engine/engine.go b/pkg/rules/engine/engine.go new file mode 100644 index 0000000..29ab002 --- /dev/null +++ b/pkg/rules/engine/engine.go @@ -0,0 +1,284 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package engine evaluates Rego policy packs with the embedded OPA Go +// library. Each pack compiles once. A compile or eval error is returned to the +// caller, which must treat it as deny. Packs can only add deny or +// require_approval findings. See docs/policy-packs.md. +package engine + +import ( + "context" + "fmt" + "io/fs" + "path" + "sort" + "strings" + "sync" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/rego" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" + "github.com/Clawdlinux/agentic-operator-core/pkg/rules/packs" +) + +// Package is the Rego package every pack module must declare. +const Package = "clawdlinux.decision" + +const query = "data." + Package + +// Finding is one rule result from a pack. +type Finding struct { + RuleID string + Reason string + Pack string +} + +// String formats a finding as "pack/RULE: reason". +func (f Finding) String() string { return f.Pack + "/" + f.RuleID + ": " + f.Reason } + +// Verdict collects findings from every loaded pack, sorted. +type Verdict struct { + Deny []Finding + RequireApproval []Finding +} + +// Source is one pack: a name for findings and the files holding its Rego. +type Source struct { + Name string + FS fs.FS +} + +type compiled struct { + name string + query rego.PreparedEvalQuery +} + +// Engine holds compiled packs. It is safe for concurrent use. +type Engine struct { + packs []compiled +} + +// New compiles each source once. Any read, parse, or compile error fails. +func New(ctx context.Context, srcs ...Source) (*Engine, error) { + e := &Engine{} + for _, s := range srcs { + c, err := compile(ctx, s) + if err != nil { + return nil, err + } + e.packs = append(e.packs, c) + } + return e, nil +} + +// Modules reads the non-test .rego files in fsys, keyed by path. +func Modules(fsys fs.FS, tests bool) (map[string]string, error) { + out := map[string]string{} + err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || path.Ext(p) != ".rego" { + return nil + } + if strings.HasSuffix(p, "_test.rego") != tests { + return nil + } + b, err := fs.ReadFile(fsys, p) + if err != nil { + return err + } + out[p] = string(b) + return nil + }) + return out, err +} + +func compile(ctx context.Context, s Source) (compiled, error) { + if s.FS == nil { + return compiled{}, fmt.Errorf("engine: pack %q has no files", s.Name) + } + mods, err := Modules(s.FS, false) + if err != nil { + return compiled{}, fmt.Errorf("engine: read pack %q: %w", s.Name, err) + } + if len(mods) == 0 { + return compiled{}, fmt.Errorf("engine: pack %q has no rego modules", s.Name) + } + opts := []func(*rego.Rego){rego.Query(query), rego.StrictBuiltinErrors(true)} + for _, p := range sortedKeys(mods) { + m, err := ast.ParseModule(p, mods[p]) + if err != nil { + return compiled{}, fmt.Errorf("engine: parse %s/%s: %w", s.Name, p, err) + } + if got := strings.TrimPrefix(m.Package.Path.String(), "data."); got != Package { + return compiled{}, fmt.Errorf("engine: %s/%s declares package %q, want %q", s.Name, p, got, Package) + } + opts = append(opts, rego.ParsedModule(m)) + } + pq, err := rego.New(opts...).PrepareForEval(ctx) + if err != nil { + return compiled{}, fmt.Errorf("engine: compile pack %q: %w", s.Name, err) + } + return compiled{name: s.Name, query: pq}, nil +} + +// Eval runs every pack against in. Any error means the caller must deny. +func (e *Engine) Eval(ctx context.Context, in any) (Verdict, error) { + var v Verdict + if e == nil { + return v, nil + } + for _, c := range e.packs { + rs, err := c.query.Eval(ctx, rego.EvalInput(in)) + if err != nil { + return Verdict{}, fmt.Errorf("engine: eval pack %q: %w", c.name, err) + } + if len(rs) != 1 || len(rs[0].Expressions) != 1 { + return Verdict{}, fmt.Errorf("engine: pack %q returned %d results", c.name, len(rs)) + } + doc, ok := rs[0].Expressions[0].Value.(map[string]any) + if !ok { + return Verdict{}, fmt.Errorf("engine: pack %q returned %T", c.name, rs[0].Expressions[0].Value) + } + deny, err := findings(c.name, "deny", doc["deny"]) + if err != nil { + return Verdict{}, err + } + approve, err := findings(c.name, "require_approval", doc["require_approval"]) + if err != nil { + return Verdict{}, err + } + v.Deny = append(v.Deny, deny...) + v.RequireApproval = append(v.RequireApproval, approve...) + } + sortFindings(v.Deny) + sortFindings(v.RequireApproval) + return v, nil +} + +func findings(pack, rule string, raw any) ([]Finding, error) { + if raw == nil { + return nil, nil + } + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("engine: pack %q %s is %T, want a set", pack, rule, raw) + } + out := make([]Finding, 0, len(items)) + for _, it := range items { + obj, ok := it.(map[string]any) + if !ok { + return nil, fmt.Errorf("engine: pack %q %s item is %T, want object", pack, rule, it) + } + id, _ := obj["id"].(string) + reason, _ := obj["reason"].(string) + if id == "" || reason == "" { + return nil, fmt.Errorf("engine: pack %q %s item needs string id and reason", pack, rule) + } + out = append(out, Finding{RuleID: id, Reason: reason, Pack: pack}) + } + return out, nil +} + +// Doc converts a decision input into the Rego input document. Field names +// match docs/policy-packs.md. +func Doc(in input.Input) map[string]any { + doc := map[string]any{ + "declared": map[string]any{ + "purpose": in.Declared.Purpose, + "decisionType": in.Declared.DecisionType, + "allowedDataClasses": list(in.Declared.AllowedDataClasses), + "allowedDestinations": list(in.Declared.AllowedDestinations), + }, + "observed": map[string]any{ + "destination": in.Observed.Destination, + "dataClasses": list(in.Observed.DataClasses), + "tool": in.Observed.Tool, + "callerIdentity": in.Observed.CallerIdentity, + "priorActionCount": in.Observed.PriorActionCount, + "priorDeniedCount": in.Observed.PriorDeniedCount, + }, + } + claimed := map[string]any{"intent": in.Claimed.Intent} + if in.Claimed.Confidence != nil { + claimed["confidence"] = *in.Claimed.Confidence + } + if in.Claimed.ClusterHealth != nil { + claimed["clusterHealth"] = *in.Claimed.ClusterHealth + } + doc["claimed"] = claimed + return doc +} + +var ( + cacheMu sync.Mutex + cache = map[string]compiled{} +) + +// LoadPacks returns an engine for shipped packs by name. Each pack compiles +// once per process. Unknown names and compile errors fail. +func LoadPacks(ctx context.Context, names ...string) (*Engine, error) { + cacheMu.Lock() + defer cacheMu.Unlock() + e := &Engine{} + for _, n := range names { + c, ok := cache[n] + if !ok { + fsys, err := packs.FS(n) + if err != nil { + return nil, err + } + if c, err = compile(ctx, Source{Name: n, FS: fsys}); err != nil { + return nil, err + } + cache[n] = c + } + e.packs = append(e.packs, c) + } + return e, nil +} + +func list(s []string) []any { + out := make([]any, len(s)) + for i, v := range s { + out[i] = v + } + return out +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func sortFindings(xs []Finding) { + sort.Slice(xs, func(i, j int) bool { + if xs[i].Pack != xs[j].Pack { + return xs[i].Pack < xs[j].Pack + } + if xs[i].RuleID != xs[j].RuleID { + return xs[i].RuleID < xs[j].RuleID + } + return xs[i].Reason < xs[j].Reason + }) +} diff --git a/pkg/rules/engine/engine_test.go b/pkg/rules/engine/engine_test.go new file mode 100644 index 0000000..d5a172e --- /dev/null +++ b/pkg/rules/engine/engine_test.go @@ -0,0 +1,173 @@ +package engine + +import ( + "context" + "reflect" + "strings" + "testing" + "testing/fstest" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" + "github.com/Clawdlinux/agentic-operator-core/pkg/rules/packs" +) + +func src(name string, files map[string]string) Source { + m := fstest.MapFS{} + for p, body := range files { + m[p] = &fstest.MapFile{Data: []byte(body)} + } + return Source{Name: name, FS: m} +} + +const inlinePack = `package clawdlinux.decision + +deny contains {"id": "T-01", "reason": "tool is rm"} if input.observed.tool == "rm" + +require_approval contains {"id": "T-02", "reason": sprintf("tool %s", [input.observed.tool])} if { + startswith(input.observed.tool, "scale") +} +` + +func TestNewFailsClosed(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + src Source + want string + }{ + {"nil fs", Source{Name: "x"}, "no files"}, + {"no modules", src("x", map[string]string{"README.md": "hi"}), "no rego modules"}, + {"only tests", src("x", map[string]string{"a_test.rego": "package clawdlinux.decision_test\n"}), "no rego modules"}, + {"parse error", src("x", map[string]string{"a.rego": "package clawdlinux.decision\ndeny contains"}), "parse"}, + {"wrong package", src("x", map[string]string{"a.rego": "package other\n"}), "declares package"}, + {"compile error", src("x", map[string]string{"a.rego": "package clawdlinux.decision\ndeny contains x if { y == 1 }\n"}), "compile"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := New(ctx, tc.src) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("New err = %v, want containing %q", err, tc.want) + } + }) + } +} + +func TestEvalInline(t *testing.T) { + ctx := context.Background() + e, err := New(ctx, src("inline@v0.0.1", map[string]string{"p.rego": inlinePack})) + if err != nil { + t.Fatal(err) + } + tests := []struct { + tool string + wantDeny []Finding + wantApprove []Finding + }{ + {"get", nil, nil}, + {"rm", []Finding{{"T-01", "tool is rm", "inline@v0.0.1"}}, nil}, + {"scale-up", nil, []Finding{{"T-02", "tool scale-up", "inline@v0.0.1"}}}, + } + for _, tc := range tests { + t.Run(tc.tool, func(t *testing.T) { + v, err := e.Eval(ctx, Doc(input.Input{Observed: input.Observed{Tool: tc.tool}})) + if err != nil { + t.Fatal(err) + } + if len(v.Deny) == 0 { + v.Deny = nil + } + if len(v.RequireApproval) == 0 { + v.RequireApproval = nil + } + if !reflect.DeepEqual(v.Deny, tc.wantDeny) || !reflect.DeepEqual(v.RequireApproval, tc.wantApprove) { + t.Fatalf("verdict = %+v", v) + } + }) + } +} + +func TestEvalErrors(t *testing.T) { + ctx := context.Background() + tests := []struct { + name, rego string + }{ + {"bad finding shape", `package clawdlinux.decision +deny contains "just a string" if true`}, + {"missing reason", `package clawdlinux.decision +deny contains {"id": "X"} if true`}, + {"deny not a set", `package clawdlinux.decision +deny := 1`}, + {"builtin error", `package clawdlinux.decision +deny contains {"id": "X", "reason": r} if r := sprintf("%d", [to_number("nope")])`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + e, err := New(ctx, src("bad", map[string]string{"p.rego": tc.rego})) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := e.Eval(ctx, map[string]any{}); err == nil { + t.Fatal("Eval must fail so the caller denies") + } + }) + } +} + +func TestNilEngine(t *testing.T) { + var e *Engine + v, err := e.Eval(context.Background(), nil) + if err != nil || len(v.Deny)+len(v.RequireApproval) != 0 { + t.Fatalf("nil engine = %+v, %v", v, err) + } +} + +func TestLoadPacks(t *testing.T) { + ctx := context.Background() + if _, err := LoadPacks(ctx, "dpdp-in@v9.9.9"); err == nil { + t.Fatal("unknown pack must fail") + } + e, err := LoadPacks(ctx, packs.Known()...) + if err != nil { + t.Fatal(err) + } + in := input.Input{ + Declared: input.Declared{Purpose: "kyc", DecisionType: "automated", AllowedDataClasses: []string{"aadhaar"}}, + Observed: input.Observed{DataClasses: []string{"aadhaar", "email"}}, + } + v, err := e.Eval(ctx, Doc(in)) + if err != nil { + t.Fatal(err) + } + var deny, approve []string + for _, f := range v.Deny { + deny = append(deny, f.Pack+"/"+f.RuleID) + } + for _, f := range v.RequireApproval { + approve = append(approve, f.String()) + } + if want := []string{"gdpr-eu@v0.1.0/GDPR-EU-02"}; !reflect.DeepEqual(deny, want) { + t.Fatalf("deny = %v, want %v", deny, want) + } + if len(approve) != 2 || !strings.HasPrefix(approve[0], "dpdp-in@v0.1.0/DPDP-IN-04: ") || !strings.HasPrefix(approve[1], "gdpr-eu@v0.1.0/GDPR-EU-03: ") { + t.Fatalf("approve = %v", approve) + } + again, err := LoadPacks(ctx, "gdpr-eu@v0.1.0") + if err != nil || len(again.packs) != 1 { + t.Fatalf("cached load = %v, %v", again, err) + } +} + +func TestDoc(t *testing.T) { + c := 0.9 + d := Doc(input.Input{Claimed: input.AgentClaimed{Confidence: &c}}) + obs := d["observed"].(map[string]any) + if got := obs["dataClasses"].([]any); got == nil || len(got) != 0 { + t.Fatalf("dataClasses must be an empty list, got %#v", got) + } + if d["claimed"].(map[string]any)["confidence"] != 0.9 { + t.Fatalf("claimed = %#v", d["claimed"]) + } + if _, ok := d["claimed"].(map[string]any)["clusterHealth"]; ok { + t.Fatal("absent clusterHealth must be omitted") + } +} diff --git a/pkg/rules/packs/dpdp-in@v0.1.0/README.md b/pkg/rules/packs/dpdp-in@v0.1.0/README.md new file mode 100644 index 0000000..85d0004 --- /dev/null +++ b/pkg/rules/packs/dpdp-in@v0.1.0/README.md @@ -0,0 +1,23 @@ +# dpdp-in v0.1.0 + +Engineering controls inspired by India's Digital Personal Data Protection Act +2023. This pack is NOT legal advice. It is NOT a certification. It does NOT +make a workload compliant with the DPDP Act. It covers a few checks only. +Talk to your counsel. + +Enable it with `spec.policyPacks: ["dpdp-in@v0.1.0"]`. + +Applies when the observed action carries `aadhaar` or `pan`. + +| Rule | Effect | Fires when | +|---|---|---| +| DPDP-IN-01 | deny | `declaredIntent.purpose` is empty | +| DPDP-IN-02 | deny | `declaredIntent.decisionType` is empty | +| DPDP-IN-03 | deny | the class is not in `declaredIntent.allowedDataClasses` | +| DPDP-IN-04 | require approval | `declaredIntent.decisionType` is `automated` | + +Detection is deterministic. Aadhaar needs a valid Verhoeff checksum. PAN is +pattern based. See [policy input](../../../../docs/policy-input.md). + +This pack can only add deny or require approval. It cannot allow an action +that an invariant or another layer denied. diff --git a/pkg/rules/packs/dpdp-in@v0.1.0/dpdp.rego b/pkg/rules/packs/dpdp-in@v0.1.0/dpdp.rego new file mode 100644 index 0000000..9f3363a --- /dev/null +++ b/pkg/rules/packs/dpdp-in@v0.1.0/dpdp.rego @@ -0,0 +1,35 @@ +# dpdp-in v0.1.0. Engineering controls inspired by India's DPDP Act 2023. +# Not legal advice. Not a certification. Not complete coverage of the Act. +package clawdlinux.decision + +pack_classes := {"aadhaar", "pan"} + +observed := {lower(c) | some c in object.get(input, ["observed", "dataClasses"], [])} + +allowed := {lower(trim_space(c)) | some c in object.get(input, ["declared", "allowedDataClasses"], [])} + +present := observed & pack_classes + +purpose := trim_space(object.get(input, ["declared", "purpose"], "")) + +decision_type := lower(trim_space(object.get(input, ["declared", "decisionType"], ""))) + +deny contains {"id": "DPDP-IN-01", "reason": "aadhaar or pan observed without a declared purpose"} if { + count(present) > 0 + purpose == "" +} + +deny contains {"id": "DPDP-IN-02", "reason": "aadhaar or pan observed without a declared decisionType"} if { + count(present) > 0 + decision_type == "" +} + +deny contains {"id": "DPDP-IN-03", "reason": sprintf("%s observed but not in allowedDataClasses", [c])} if { + some c in present + not c in allowed +} + +require_approval contains {"id": "DPDP-IN-04", "reason": "automated decision with aadhaar or pan needs human approval"} if { + count(present) > 0 + decision_type == "automated" +} diff --git a/pkg/rules/packs/dpdp-in@v0.1.0/dpdp_test.rego b/pkg/rules/packs/dpdp-in@v0.1.0/dpdp_test.rego new file mode 100644 index 0000000..f03e675 --- /dev/null +++ b/pkg/rules/packs/dpdp-in@v0.1.0/dpdp_test.rego @@ -0,0 +1,56 @@ +package clawdlinux.decision_test + +import data.clawdlinux.decision + +full := { + "declared": {"purpose": "kyc check", "decisionType": "assisted", "allowedDataClasses": ["aadhaar", "pan"]}, + "observed": {"dataClasses": ["aadhaar"]}, +} + +ids(set) := {f.id | some f in set} + +test_no_pack_classes_no_findings if { + inp := {"declared": {}, "observed": {"dataClasses": ["email"]}} + count(decision.deny) == 0 with input as inp + count(decision.require_approval) == 0 with input as inp +} + +test_empty_input_no_findings if { + count(decision.deny) == 0 with input as {} +} + +test_declared_assisted_passes if { + count(decision.deny) == 0 with input as full + count(decision.require_approval) == 0 with input as full +} + +test_missing_purpose_denied if { + inp := json.patch(full, [{"op": "replace", "path": "/declared/purpose", "value": " "}]) + ids(decision.deny) == {"DPDP-IN-01"} with input as inp +} + +test_missing_decision_type_denied if { + inp := json.patch(full, [{"op": "remove", "path": "/declared/decisionType"}]) + ids(decision.deny) == {"DPDP-IN-02"} with input as inp +} + +test_class_not_allowed_denied if { + inp := json.patch(full, [{"op": "replace", "path": "/observed/dataClasses", "value": ["PAN"]}, {"op": "replace", "path": "/declared/allowedDataClasses", "value": ["aadhaar"]}]) + ids(decision.deny) == {"DPDP-IN-03"} with input as inp +} + +test_undeclared_everything_denied if { + inp := {"observed": {"dataClasses": ["aadhaar"]}} + ids(decision.deny) == {"DPDP-IN-01", "DPDP-IN-02", "DPDP-IN-03"} with input as inp +} + +test_automated_requires_approval if { + inp := json.patch(full, [{"op": "replace", "path": "/declared/decisionType", "value": "Automated"}]) + ids(decision.require_approval) == {"DPDP-IN-04"} with input as inp + count(decision.deny) == 0 with input as inp +} + +test_automated_without_pack_classes_no_approval if { + inp := {"declared": {"decisionType": "automated"}, "observed": {"dataClasses": ["email"]}} + count(decision.require_approval) == 0 with input as inp +} diff --git a/pkg/rules/packs/gdpr-eu@v0.1.0/README.md b/pkg/rules/packs/gdpr-eu@v0.1.0/README.md new file mode 100644 index 0000000..5325040 --- /dev/null +++ b/pkg/rules/packs/gdpr-eu@v0.1.0/README.md @@ -0,0 +1,21 @@ +# gdpr-eu v0.1.0 + +Engineering controls inspired by the EU General Data Protection Regulation. +This pack is NOT legal advice. It is NOT a certification. It does NOT make a +workload GDPR compliant. It covers a few checks only. Talk to your counsel. + +Enable it with `spec.policyPacks: ["gdpr-eu@v0.1.0"]`. + +Applies when the observed action carries `email`, `phone`, `iban`, or `card`. + +| Rule | Effect | Fires when | +|---|---|---| +| GDPR-EU-01 | deny | `declaredIntent.purpose` is empty | +| GDPR-EU-02 | deny | the class is not in `declaredIntent.allowedDataClasses` | +| GDPR-EU-03 | require approval | `declaredIntent.decisionType` is `automated` (Art. 22 style) | + +Detection is deterministic and has false positives, mostly for phone. See +[policy input](../../../../docs/policy-input.md). + +This pack can only add deny or require approval. It cannot allow an action +that an invariant or another layer denied. diff --git a/pkg/rules/packs/gdpr-eu@v0.1.0/gdpr.rego b/pkg/rules/packs/gdpr-eu@v0.1.0/gdpr.rego new file mode 100644 index 0000000..de6f2ad --- /dev/null +++ b/pkg/rules/packs/gdpr-eu@v0.1.0/gdpr.rego @@ -0,0 +1,31 @@ +# gdpr-eu v0.1.0. Engineering controls inspired by the EU GDPR. +# Not legal advice. Not a certification. Not complete coverage of the GDPR. +package clawdlinux.decision + +personal_classes := {"email", "phone", "iban", "card"} + +observed := {lower(c) | some c in object.get(input, ["observed", "dataClasses"], [])} + +allowed := {lower(trim_space(c)) | some c in object.get(input, ["declared", "allowedDataClasses"], [])} + +present := observed & personal_classes + +purpose := trim_space(object.get(input, ["declared", "purpose"], "")) + +decision_type := lower(trim_space(object.get(input, ["declared", "decisionType"], ""))) + +deny contains {"id": "GDPR-EU-01", "reason": "personal data observed without a declared purpose"} if { + count(present) > 0 + purpose == "" +} + +deny contains {"id": "GDPR-EU-02", "reason": sprintf("%s observed but not in allowedDataClasses", [c])} if { + some c in present + not c in allowed +} + +# Art. 22 style: a solely automated decision on personal data goes to a human. +require_approval contains {"id": "GDPR-EU-03", "reason": "automated decision on personal data needs human approval"} if { + count(present) > 0 + decision_type == "automated" +} diff --git a/pkg/rules/packs/gdpr-eu@v0.1.0/gdpr_test.rego b/pkg/rules/packs/gdpr-eu@v0.1.0/gdpr_test.rego new file mode 100644 index 0000000..36791c6 --- /dev/null +++ b/pkg/rules/packs/gdpr-eu@v0.1.0/gdpr_test.rego @@ -0,0 +1,47 @@ +package clawdlinux.decision_test + +import data.clawdlinux.decision + +full := { + "declared": {"purpose": "support replies", "decisionType": "assisted", "allowedDataClasses": ["email", "phone"]}, + "observed": {"dataClasses": ["email"]}, +} + +ids(set) := {f.id | some f in set} + +test_no_personal_classes_no_findings if { + inp := {"observed": {"dataClasses": ["aadhaar"]}} + count(decision.deny) == 0 with input as inp + count(decision.require_approval) == 0 with input as inp +} + +test_empty_input_no_findings if { + count(decision.deny) == 0 with input as {} +} + +test_declared_assisted_passes if { + count(decision.deny) == 0 with input as full + count(decision.require_approval) == 0 with input as full +} + +test_missing_purpose_denied if { + inp := json.patch(full, [{"op": "remove", "path": "/declared/purpose"}]) + ids(decision.deny) == {"GDPR-EU-01"} with input as inp +} + +test_class_not_allowed_denied if { + inp := json.patch(full, [{"op": "replace", "path": "/observed/dataClasses", "value": ["email", "IBAN"]}]) + ids(decision.deny) == {"GDPR-EU-02"} with input as inp +} + +test_each_personal_class_checked if { + every c in ["email", "phone", "iban", "card"] { + ids(decision.deny) == {"GDPR-EU-01", "GDPR-EU-02"} with input as {"observed": {"dataClasses": [c]}} + } +} + +test_automated_requires_approval if { + inp := json.patch(full, [{"op": "replace", "path": "/declared/decisionType", "value": "automated"}]) + ids(decision.require_approval) == {"GDPR-EU-03"} with input as inp + count(decision.deny) == 0 with input as inp +} diff --git a/pkg/rules/packs/packs.go b/pkg/rules/packs/packs.go new file mode 100644 index 0000000..6534594 --- /dev/null +++ b/pkg/rules/packs/packs.go @@ -0,0 +1,68 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package packs ships versioned Rego policy packs. Packs are engineering +// controls. They are not legal advice and not a certification. This package +// has no OPA dependency so the API webhook can validate pack names cheaply. +// See docs/policy-packs.md. +package packs + +import ( + "embed" + "fmt" + "io/fs" + "sort" +) + +//go:embed dpdp-in@v0.1.0 gdpr-eu@v0.1.0 +var files embed.FS + +// NamePattern is the CRD pattern for one spec.policyPacks entry. +const NamePattern = `^(dpdp-in|gdpr-eu)@v[0-9]+\.[0-9]+\.[0-9]+$` + +// Known returns every shipped pack, sorted. +func Known() []string { + entries, err := fs.ReadDir(files, ".") + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if e.IsDir() { + out = append(out, e.Name()) + } + } + sort.Strings(out) + return out +} + +// Valid reports whether name is a shipped pack at an exact version. +func Valid(name string) bool { + for _, k := range Known() { + if k == name { + return true + } + } + return false +} + +// FS returns the files of one pack. Unknown names return an error. +func FS(name string) (fs.FS, error) { + if !Valid(name) { + return nil, fmt.Errorf("packs: unknown policy pack %q, known: %v", name, Known()) + } + return fs.Sub(files, name) +} diff --git a/pkg/rules/packs/packs_test.go b/pkg/rules/packs/packs_test.go new file mode 100644 index 0000000..4cb2f6b --- /dev/null +++ b/pkg/rules/packs/packs_test.go @@ -0,0 +1,76 @@ +package packs_test + +import ( + "context" + "regexp" + "testing" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/tester" + + "github.com/Clawdlinux/agentic-operator-core/pkg/rules/engine" + "github.com/Clawdlinux/agentic-operator-core/pkg/rules/packs" +) + +func TestKnownMatchesCRDPattern(t *testing.T) { + want := []string{"dpdp-in@v0.1.0", "gdpr-eu@v0.1.0"} + got := packs.Known() + if len(got) != len(want) { + t.Fatalf("Known = %v, want %v", got, want) + } + re := regexp.MustCompile(packs.NamePattern) + for i, n := range got { + if n != want[i] || !re.MatchString(n) || !packs.Valid(n) { + t.Fatalf("pack %q invalid", n) + } + } + for _, bad := range []string{"", "dpdp-in", "dpdp-in@v0.2.0", "hipaa@v0.1.0", "../x"} { + if packs.Valid(bad) { + t.Fatalf("%q must be invalid", bad) + } + if _, err := packs.FS(bad); err == nil { + t.Fatalf("FS(%q) must fail", bad) + } + } +} + +// TestRegoUnitTests runs every pack's _test.rego files, like `opa test`. +func TestRegoUnitTests(t *testing.T) { + for _, name := range packs.Known() { + t.Run(name, func(t *testing.T) { + fsys, err := packs.FS(name) + if err != nil { + t.Fatal(err) + } + mods := map[string]*ast.Module{} + for _, tests := range []bool{false, true} { + files, err := engine.Modules(fsys, tests) + if err != nil { + t.Fatal(err) + } + for p, body := range files { + m, err := ast.ParseModule(p, body) + if err != nil { + t.Fatalf("parse %s: %v", p, err) + } + mods[p] = m + } + } + ch, err := tester.NewRunner().SetModules(mods).RunTests(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + n := 0 + for r := range ch { + n++ + if r.Error != nil || r.Fail { + t.Errorf("%s: %s", r.Name, r.String()) + } + } + if n == 0 { + t.Fatal("no rego tests found") + } + t.Logf("%d rego tests", n) + }) + } +} diff --git a/pkg/opa/evaluator.go b/pkg/rules/threshold/evaluator.go similarity index 93% rename from pkg/opa/evaluator.go rename to pkg/rules/threshold/evaluator.go index 07770a8..c731284 100644 --- a/pkg/opa/evaluator.go +++ b/pkg/rules/threshold/evaluator.go @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package opa holds a hardcoded Go threshold evaluator. It is not an OPA -// engine: it does not load or run Rego, and go.mod has no OPA dependency. +// Package threshold holds a hardcoded Go threshold evaluator. It does not +// load or run Rego. The Rego engine lives in pkg/rules/engine. // Its confidence and cluster health inputs are claimed by the agent, so they // may tighten a decision but must never loosen one. See // docs/architecture/decision-architecture.md. -package opa +package threshold import ( "fmt" @@ -28,8 +28,8 @@ import ( ) // PolicyEvaluator applies fixed Go thresholds to an action. It does not -// evaluate Rego. Planned replacement: a threshold package under pkg/rules plus -// a real rule engine, per the decision architecture ADR. +// evaluate Rego. It runs after invariants and policy packs and can only +// tighten their outcome. type PolicyEvaluator struct { // policies would be loaded from compiled OPA modules in production } @@ -52,10 +52,6 @@ type EvaluationResult struct { } // NewPolicyEvaluator creates the threshold evaluator. -// -// Planned for replacement by pkg/rules/threshold. The formal deprecation -// marker is withheld until the controller call site migrates, because -// staticcheck would fail the build on that caller. func NewPolicyEvaluator() *PolicyEvaluator { return &PolicyEvaluator{} } diff --git a/pkg/opa/evaluator_test.go b/pkg/rules/threshold/evaluator_test.go similarity index 99% rename from pkg/opa/evaluator_test.go rename to pkg/rules/threshold/evaluator_test.go index 5864837..b93a673 100644 --- a/pkg/opa/evaluator_test.go +++ b/pkg/rules/threshold/evaluator_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package opa +package threshold import ( "strings" diff --git a/pkg/opa/policies.rego b/pkg/rules/threshold/policies.rego similarity index 100% rename from pkg/opa/policies.rego rename to pkg/rules/threshold/policies.rego diff --git a/scripts/review_code.sh b/scripts/review_code.sh index 9de9e24..e4672cc 100644 --- a/scripts/review_code.sh +++ b/scripts/review_code.sh @@ -25,15 +25,15 @@ case $TASK in "WEEK2_TASK2B") echo "Reviewing: OPA Policies" FILES=( - "pkg/opa/policies.rego" - "pkg/opa/policies_test.go" + "pkg/rules/threshold/policies.rego" + "pkg/rules/threshold/policies_test.go" ) ;; "WEEK2_TASK2C") echo "Reviewing: OPA Evaluator" FILES=( - "pkg/opa/evaluator.go" - "pkg/opa/evaluator_test.go" + "pkg/rules/threshold/evaluator.go" + "pkg/rules/threshold/evaluator_test.go" ) ;; *) From 825de55873c4887b34b9adbcf3c0260c9cb68a3e Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 00:07:47 +0530 Subject: [PATCH 04/54] docs(roadmap): mark observed inputs shipped on direct path Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9e7234d..1c09b74 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,7 +67,7 @@ Priority order. Validation before features. - [ ] Runtime governance-label parity and enforcing-CNI packet tests - [ ] ACP RemoteMCPServer wrapper example - [ ] Homebrew tap for agentctl -- [ ] Observed decision inputs. The platform measures destination, data classes and caller. Agent self-report only tightens. +- [x] Observed decision inputs on the direct action path: destination host, deterministic data-class detection, action history. Caller identity is not observed yet. Agent self-report only tightens. See [policy input](docs/policy-input.md) - [ ] Invariants and policy packs on orchestrated runtimes (`spec.orchestration`) - [ ] Decision model: escalate-only scoring, shadow mode first. See [decision architecture](docs/architecture/decision-architecture.md) From b0cb88c7d9a5aa8e01c641d22d8a2eee4c570684 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 00:21:43 +0530 Subject: [PATCH 05/54] chore(dev): local replace for receiptspec, remove after agentgate v0.1.4 is tagged Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- BLOCKERS.md | 43 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 5 ++++- go.sum | 5 ++--- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 BLOCKERS.md diff --git a/BLOCKERS.md b/BLOCKERS.md new file mode 100644 index 0000000..167d675 --- /dev/null +++ b/BLOCKERS.md @@ -0,0 +1,43 @@ +# Blockers + +Open items that need a founder action before this branch can ship. + +## receiptspec dependency + +`go.mod` requires `github.com/Clawdlinux/agentgate v0.1.3` with a local +`replace` to `../agentgate-receiptspec`. That directory only exists on the +founder laptop. `pkg/receipts`, `cmd/receipt-writer`, and `agentctl receipts` +import `github.com/Clawdlinux/agentgate/pkg/receiptspec`, which is not in any +pushed or tagged AgentGate release yet. + +Until this is fixed, CI and `docker build` fail. Both run without the sibling +directory, so the `replace` target is missing. + +Fix, in order: + +```sh +# 1. AgentGate: push, merge, tag. +cd ../agentgate-receiptspec +git push -u origin feat/receiptspec +gh pr create --base main --head feat/receiptspec --fill +gh pr merge --squash --delete-branch +git fetch origin && git checkout main && git pull --ff-only +git tag -a v0.1.4 -m "v0.1.4: public pkg/receiptspec" && git push origin v0.1.4 + +# 2. Operator: drop the replace and pin the tag. +cd ../operator-decision +go mod edit -dropreplace=github.com/Clawdlinux/agentgate +go mod edit -require=github.com/Clawdlinux/agentgate@v0.1.4 +go mod tidy +go build ./... && go test ./pkg/receipts/... ./cmd/receipt-writer/... ./cmd/agentctl/... +git commit -s -am "chore(deps): pin agentgate v0.1.4, drop local replace" +``` + +Then delete this entry. + +## receipt-writer image + +The chart `receipts` block points at +`ghcr.io/clawdlinux/agentic-operator-core/receipt-writer`. No CI job publishes +that image yet. Build it with `docker build --target receipt-writer .` and push +it, or add a release job. Blocked on the receiptspec entry above. diff --git a/go.mod b/go.mod index 8ea4d84..f21320c 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect + github.com/Clawdlinux/agentgate v0.1.3 github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect @@ -71,7 +72,7 @@ require ( github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect @@ -131,3 +132,5 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect ) + +replace github.com/Clawdlinux/agentgate => ../agentgate-receiptspec diff --git a/go.sum b/go.sum index dfd07a4..2d3baf4 100644 --- a/go.sum +++ b/go.sum @@ -152,8 +152,8 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -295,7 +295,6 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= From 7b9859f9d7ae84bbe97f7f84ebdf69b7c224b134 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 00:40:14 +0530 Subject: [PATCH 06/54] feat(receipts): add decision records, receipt chain writer, and receipt-writer service Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- Dockerfile | 13 ++ Makefile | 7 +- cmd/receipt-writer/main.go | 307 ++++++++++++++++++++++++++ cmd/receipt-writer/main_test.go | 238 ++++++++++++++++++++ pkg/receipts/export.go | 179 +++++++++++++++ pkg/receipts/local.go | 357 ++++++++++++++++++++++++++++++ pkg/receipts/local_test.go | 289 +++++++++++++++++++++++++ pkg/receipts/lock_other.go | 15 ++ pkg/receipts/lock_unix.go | 28 +++ pkg/receipts/record.go | 371 ++++++++++++++++++++++++++++++++ pkg/receipts/record_test.go | 262 ++++++++++++++++++++++ pkg/receipts/remote.go | 231 ++++++++++++++++++++ pkg/receipts/remote_test.go | 156 ++++++++++++++ pkg/receipts/writer.go | 24 +++ 14 files changed, 2476 insertions(+), 1 deletion(-) create mode 100644 cmd/receipt-writer/main.go create mode 100644 cmd/receipt-writer/main_test.go create mode 100644 pkg/receipts/export.go create mode 100644 pkg/receipts/local.go create mode 100644 pkg/receipts/local_test.go create mode 100644 pkg/receipts/lock_other.go create mode 100644 pkg/receipts/lock_unix.go create mode 100644 pkg/receipts/record.go create mode 100644 pkg/receipts/record_test.go create mode 100644 pkg/receipts/remote.go create mode 100644 pkg/receipts/remote_test.go create mode 100644 pkg/receipts/writer.go diff --git a/Dockerfile b/Dockerfile index 1bb20d6..3ca20ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,19 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -o manager \ -trimpath \ ./cmd/main.go +# receipt-writer image. Build with: docker build --target receipt-writer . +FROM builder AS receipt-writer-build +RUN CGO_ENABLED=0 GOOS=linux go build -o receipt-writer \ + -ldflags="-s -w" \ + -trimpath \ + ./cmd/receipt-writer + +FROM gcr.io/distroless/static:nonroot AS receipt-writer +COPY --from=receipt-writer-build /build/receipt-writer /receipt-writer +USER nonroot:nonroot +ENTRYPOINT ["/receipt-writer"] + +# Default target: the operator manager. Keep this stage last. FROM gcr.io/distroless/static:nonroot LABEL maintainer="operator-maintainers@example.com" diff --git a/Makefile b/Makefile index 7d0b2c5..7714196 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-anf-snapshot install-agentctl scan-secrets clean-venv check-python-version helm-lint test-cluster test-smoke test-e2e-cluster manifests generate +.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 .DEFAULT_GOAL := help @@ -187,6 +187,11 @@ build-agentctl-web: @mkdir -p bin @$(GO) build -o bin/agentctl-web ./cmd/agentctl-web/... +build-receipt-writer: + @echo "Building receipt-writer binary..." + @mkdir -p bin + @$(GO) build -o bin/receipt-writer ./cmd/receipt-writer + build-anf-snapshot: @echo "Building anf-snapshot binary..." @mkdir -p bin diff --git a/cmd/receipt-writer/main.go b/cmd/receipt-writer/main.go new file mode 100644 index 0000000..f999607 --- /dev/null +++ b/cmd/receipt-writer/main.go @@ -0,0 +1,307 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +// Command receipt-writer owns the receipt signing key and the receipt chain. +// The operator posts decision records to it and gets signed receipts back. +// It is the only process that writes the chain. See docs/receipts.md. +// +// Environment: +// +// RECEIPT_DATA_DIR chain directory (required) +// RECEIPT_KEY_FILE hex Ed25519 seed or private key (required) +// RECEIPT_KEY_GENERATE "true" creates the key file if it is missing +// RECEIPT_TOKEN_FILE bearer token file, at least 32 characters (required) +// RECEIPT_ADDR listen address, default :8080 +package main + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +const minTokenLen = 32 + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + if err := run(ctx, os.Getenv, log.New(os.Stderr, "receipt-writer: ", log.LstdFlags)); err != nil { + log.Printf("receipt-writer: %v", err) + os.Exit(1) + } +} + +type config struct { + addr string + dataDir string + keyFile string + tokenFile string + generate bool +} + +func loadConfig(getenv func(string) string) (config, error) { + c := config{ + addr: getenv("RECEIPT_ADDR"), + dataDir: getenv("RECEIPT_DATA_DIR"), + keyFile: getenv("RECEIPT_KEY_FILE"), + tokenFile: getenv("RECEIPT_TOKEN_FILE"), + } + if c.addr == "" { + c.addr = ":8080" + } + if v := getenv("RECEIPT_KEY_GENERATE"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return c, fmt.Errorf("RECEIPT_KEY_GENERATE: %w", err) + } + c.generate = b + } + for name, v := range map[string]string{"RECEIPT_DATA_DIR": c.dataDir, "RECEIPT_KEY_FILE": c.keyFile, "RECEIPT_TOKEN_FILE": c.tokenFile} { + if v == "" { + return c, fmt.Errorf("%s is required", name) + } + } + return c, nil +} + +func run(ctx context.Context, getenv func(string) string, logger *log.Logger) error { + cfg, err := loadConfig(getenv) + if err != nil { + return err + } + key, err := loadKey(cfg.keyFile, cfg.generate) + if err != nil { + return err + } + token, err := loadToken(cfg.tokenFile) + if err != nil { + return err + } + w, err := receipts.OpenLocal(cfg.dataDir, key) + if err != nil { + return err + } + defer func() { _ = w.Close() }() + + ln, err := net.Listen("tcp", cfg.addr) + if err != nil { + return err + } + srv := &http.Server{ + Handler: newHandler(w, token, logger), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + } + seq, _ := w.Head() + logger.Printf("listening on %s, signer %s, head seq %d", ln.Addr(), w.KID(), seq) + + errc := make(chan error, 1) + go func() { errc <- srv.Serve(ln) }() + select { + case err := <-errc: + return err + case <-ctx.Done(): + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + return err + } + logger.Printf("shut down") + return nil +} + +// loadKey reads a hex Ed25519 seed (32 bytes) or private key (64 bytes). It +// creates a new seed only when the file is missing and generate is true. +func loadKey(path string, generate bool) (ed25519.PrivateKey, error) { + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + if !generate { + return nil, fmt.Errorf("key file %s missing and RECEIPT_KEY_GENERATE is not true", path) + } + return generateKey(path) + } + if err != nil { + return nil, err + } + raw, err := hex.DecodeString(strings.TrimSpace(string(data))) + if err != nil { + return nil, fmt.Errorf("key file %s: not hex", path) + } + switch len(raw) { + case ed25519.SeedSize: + return ed25519.NewKeyFromSeed(raw), nil + case ed25519.PrivateKeySize: + return ed25519.PrivateKey(raw), nil + } + return nil, fmt.Errorf("key file %s: want %d or %d bytes, got %d", path, ed25519.SeedSize, ed25519.PrivateKeySize, len(raw)) +} + +func generateKey(path string) (ed25519.PrivateKey, error) { + seed := make([]byte, ed25519.SeedSize) + if _, err := rand.Read(seed); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + if _, err := f.WriteString(hex.EncodeToString(seed) + "\n"); err != nil { + _ = f.Close() + return nil, err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + return ed25519.NewKeyFromSeed(seed), nil +} + +func loadToken(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + tok := strings.TrimSpace(string(data)) + if len(tok) < minTokenLen { + return "", fmt.Errorf("token in %s must be at least %d characters", path, minTokenLen) + } + return tok, nil +} + +type server struct { + w *receipts.LocalWriter + token [32]byte + logger *log.Logger +} + +func newHandler(w *receipts.LocalWriter, token string, logger *log.Logger) http.Handler { + s := &server{w: w, token: sha256.Sum256([]byte(token)), logger: logger} + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", s.healthz) + mux.HandleFunc("POST /v1/records", s.auth(s.postRecord)) + mux.HandleFunc("GET /v1/head", s.auth(s.head)) + mux.HandleFunc("GET /v1/export", s.auth(s.export)) + return mux +} + +// auth compares SHA-256 digests in constant time, so neither content nor +// length leaks through timing. +func (s *server) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + sum := sha256.Sum256([]byte(got)) + if !ok || subtle.ConstantTimeCompare(sum[:], s.token[:]) != 1 { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + next(w, r) + } +} + +func (s *server) healthz(w http.ResponseWriter, r *http.Request) { + if !s.w.Ready(r.Context()) { + writeError(w, http.StatusServiceUnavailable, "writer not ready") + return + } + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (s *server) postRecord(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, receipts.MaxRecordBody) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + var rec receipts.DecisionRecord + if err := dec.Decode(&rec); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + writeError(w, http.StatusRequestEntityTooLarge, "record too large") + return + } + writeError(w, http.StatusBadRequest, "invalid record JSON") + return + } + if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "trailing data after record") + return + } + if rec.SchemaVersion != receipts.SchemaVersion { + writeError(w, http.StatusBadRequest, "unsupported schema_version") + return + } + if _, err := receipts.Fields(rec); err != nil { + writeError(w, http.StatusBadRequest, "invalid record") + return + } + rc, err := s.w.Append(r.Context(), rec) + if err != nil { + s.logger.Printf("append failed: %v", err) + writeError(w, http.StatusServiceUnavailable, "append failed") + return + } + line, err := receiptspec.MarshalJSONLReceipt(rc) + if err != nil { + writeError(w, http.StatusInternalServerError, "encode receipt") + return + } + writeJSON(w, receipts.AppendResponse{Receipt: line}) +} + +func (s *server) head(w http.ResponseWriter, _ *http.Request) { + seq, hash := s.w.Head() + writeJSON(w, receipts.HeadResponse{Seq: seq, EntryHash: hex.EncodeToString(hash[:]), SignerKID: s.w.KID()}) +} + +func (s *server) export(w http.ResponseWriter, _ *http.Request) { + e, err := s.w.Export() + if err != nil { + s.logger.Printf("export failed: %v", err) + writeError(w, http.StatusInternalServerError, "export failed") + return + } + writeJSON(w, e) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/cmd/receipt-writer/main_test.go b/cmd/receipt-writer/main_test.go new file mode 100644 index 0000000..6ea407c --- /dev/null +++ b/cmd/receipt-writer/main_test.go @@ -0,0 +1,238 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +const testToken = "0123456789abcdef0123456789abcdef" + +func testRecord(t *testing.T) receipts.DecisionRecord { + t.Helper() + rec, err := receipts.NewDecisionRecord(receipts.Params{ + Workload: receipts.Workload{Namespace: "ns", Name: "wl", UID: "uid-1"}, + Action: "scale", + Layers: decision.Layers{ThresholdAllowed: true}, + Result: decision.Result{Outcome: decision.Allow, Layer: decision.LayerThreshold}, + ThresholdMode: "strict", + }) + if err != nil { + t.Fatal(err) + } + return rec +} + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + w, err := receipts.OpenLocal(t.TempDir(), ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, 32))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = w.Close() }) + srv := httptest.NewServer(newHandler(w, testToken, log.New(io.Discard, "", 0))) + t.Cleanup(srv.Close) + return srv +} + +func do(t *testing.T, method, url, token string, body []byte) (int, []byte) { + t.Helper() + req, _ := http.NewRequest(method, url, bytes.NewReader(body)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + data, _ := io.ReadAll(resp.Body) + return resp.StatusCode, data +} + +func TestPostRecord(t *testing.T) { + good, _ := json.Marshal(testRecord(t)) + unknown := bytes.Replace(good, []byte(`{"schema_version"`), []byte(`{"extra":1,"schema_version"`), 1) + badVersion := bytes.Replace(good, []byte(`"schema_version":1`), []byte(`"schema_version":2`), 1) + badOutcome := bytes.Replace(good, []byte(`"outcome":"allow"`), []byte(`"outcome":"maybe"`), 1) + floatField := bytes.Replace(good, []byte(`"prior_action_count":0`), []byte(`"prior_action_count":0.5`), 1) + tests := []struct { + name string + token string + body []byte + want int + }{ + {"ok", testToken, good, http.StatusOK}, + {"no token", "", good, http.StatusUnauthorized}, + {"wrong token", testToken + "x", good, http.StatusUnauthorized}, + {"unknown field", testToken, unknown, http.StatusBadRequest}, + {"trailing data", testToken, append(append([]byte{}, good...), []byte(` {}`)...), http.StatusBadRequest}, + {"bad schema version", testToken, badVersion, http.StatusBadRequest}, + {"bad outcome", testToken, badOutcome, http.StatusBadRequest}, + {"float field", testToken, floatField, http.StatusBadRequest}, + {"not json", testToken, []byte("hello"), http.StatusBadRequest}, + {"too large", testToken, bytes.Repeat([]byte(" "), receipts.MaxRecordBody+1), http.StatusRequestEntityTooLarge}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := newTestServer(t) + code, body := do(t, http.MethodPost, srv.URL+"/v1/records", tc.token, tc.body) + if code != tc.want { + t.Fatalf("status = %d, want %d: %s", code, tc.want, body) + } + if code != http.StatusOK { + return + } + var ar receipts.AppendResponse + if err := json.Unmarshal(body, &ar); err != nil { + t.Fatal(err) + } + r, err := receiptspec.ParseJSONLReceipt(ar.Receipt) + if err != nil { + t.Fatal(err) + } + if err := receiptspec.VerifyRecordBinding(r, tc.body); err != nil { + t.Fatalf("receipt does not bind to the posted record: %v", err) + } + }) + } +} + +func TestHeadExportHealthz(t *testing.T) { + srv := newTestServer(t) + rec, _ := json.Marshal(testRecord(t)) + for i := 0; i < 3; i++ { + if code, body := do(t, http.MethodPost, srv.URL+"/v1/records", testToken, rec); code != http.StatusOK { + t.Fatalf("post %d: %d %s", i, code, body) + } + } + tests := []struct { + name, method, path, token string + want int + }{ + {"healthz no auth", http.MethodGet, "/healthz", "", http.StatusOK}, + {"head", http.MethodGet, "/v1/head", testToken, http.StatusOK}, + {"head no auth", http.MethodGet, "/v1/head", "", http.StatusUnauthorized}, + {"export", http.MethodGet, "/v1/export", testToken, http.StatusOK}, + {"export no auth", http.MethodGet, "/v1/export", "", http.StatusUnauthorized}, + {"wrong method", http.MethodGet, "/v1/records", testToken, http.StatusMethodNotAllowed}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, body := do(t, tc.method, srv.URL+tc.path, tc.token, nil) + if code != tc.want { + t.Fatalf("status = %d, want %d: %s", code, tc.want, body) + } + switch tc.name { + case "head": + var h receipts.HeadResponse + if err := json.Unmarshal(body, &h); err != nil || h.Seq != 3 || len(h.EntryHash) != 64 { + t.Fatalf("head = %+v, %v", h, err) + } + case "export": + var e receipts.Export + if err := json.Unmarshal(body, &e); err != nil { + t.Fatal(err) + } + rep, err := receipts.VerifyExport(e, nil) + if err != nil || !rep.OK() || rep.RecordsBound != 3 { + t.Fatalf("export verify = %+v, %v", rep, err) + } + } + }) + } +} + +func TestLoadKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "key") + if _, err := loadKey(path, false); err == nil { + t.Fatal("missing key accepted without generate") + } + k1, err := loadKey(path, true) + if err != nil { + t.Fatal(err) + } + info, _ := os.Stat(path) + if info.Mode().Perm() != 0o600 { + t.Fatalf("key mode = %v", info.Mode().Perm()) + } + k2, err := loadKey(path, true) + if err != nil || !k1.Equal(k2) { + t.Fatalf("reload changed the key: %v", err) + } + full := filepath.Join(dir, "full") + _ = os.WriteFile(full, []byte(strings.Repeat("ab", 64)), 0o600) + if _, err := loadKey(full, false); err != nil { + t.Fatalf("64-byte hex key rejected: %v", err) + } + for name, content := range map[string]string{"not hex": "zz", "short": "abcd"} { + p := filepath.Join(dir, strings.ReplaceAll(name, " ", "-")) + _ = os.WriteFile(p, []byte(content), 0o600) + if _, err := loadKey(p, true); err == nil { + t.Errorf("%s key accepted", name) + } + } +} + +func TestLoadToken(t *testing.T) { + dir := t.TempDir() + for name, tc := range map[string]struct { + content string + ok bool + }{"short": {"abc", false}, "ok": {testToken + "\n", true}} { + p := filepath.Join(dir, name) + _ = os.WriteFile(p, []byte(tc.content), 0o600) + if _, err := loadToken(p); (err == nil) != tc.ok { + t.Errorf("%s: err = %v", name, err) + } + } +} + +func TestRun(t *testing.T) { + dir := t.TempDir() + tokenFile := filepath.Join(dir, "token") + _ = os.WriteFile(tokenFile, []byte(testToken), 0o600) + env := map[string]string{ + "RECEIPT_ADDR": "127.0.0.1:0", + "RECEIPT_DATA_DIR": filepath.Join(dir, "data"), + "RECEIPT_KEY_FILE": filepath.Join(dir, "key"), + "RECEIPT_TOKEN_FILE": tokenFile, + } + getenv := func(k string) string { return env[k] } + logger := log.New(io.Discard, "", 0) + + if err := run(context.Background(), getenv, logger); err == nil || !strings.Contains(err.Error(), "RECEIPT_KEY_GENERATE") { + t.Fatalf("run without key and generate = %v, want refusal", err) + } + env["RECEIPT_KEY_GENERATE"] = "true" + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if err := run(ctx, getenv, logger); err != nil { + t.Fatalf("run = %v", err) + } + delete(env, "RECEIPT_DATA_DIR") + if err := run(context.Background(), getenv, logger); err == nil { + t.Fatal("run without data dir succeeded") + } +} diff --git a/pkg/receipts/export.go b/pkg/receipts/export.go new file mode 100644 index 0000000..16691d0 --- /dev/null +++ b/pkg/receipts/export.go @@ -0,0 +1,179 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +// ExportFormat names the export envelope served by GET /v1/export. +const ExportFormat = "clawdlinux.receipts.export.v1" + +// Export is a full chain export. ReceiptsJSONL is a signed AgentGate export +// (manifest line, key line, receipts) that agentgate-verify reads as is. +type Export struct { + Format string `json:"format"` + ReceiptsJSONL string `json:"receipts_jsonl"` + TrustRoot json.RawMessage `json:"trust_root"` + RecordsJSONL string `json:"records_jsonl"` +} + +// WriteDir writes the export as receipts.jsonl, trust.json, records.jsonl. +func (e Export) WriteDir(dir string) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + files := map[string][]byte{ + ReceiptsFile: []byte(e.ReceiptsJSONL), + TrustFile: e.TrustRoot, + RecordsFile: []byte(e.RecordsJSONL), + } + for name, data := range files { + if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { + return err + } + } + return nil +} + +// ReadExport reads an export directory or a single envelope JSON file. +func ReadExport(path string) (Export, error) { + info, err := os.Stat(path) + if err != nil { + return Export{}, err + } + if !info.IsDir() { + data, err := os.ReadFile(path) + if err != nil { + return Export{}, err + } + var e Export + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&e); err != nil { + return Export{}, fmt.Errorf("receipts: parse bundle: %w", err) + } + if e.Format != ExportFormat { + return Export{}, fmt.Errorf("receipts: unknown bundle format %q", e.Format) + } + return e, nil + } + e := Export{Format: ExportFormat} + data, err := os.ReadFile(filepath.Join(path, ReceiptsFile)) + if err != nil { + return Export{}, err + } + e.ReceiptsJSONL = string(data) + if data, err = os.ReadFile(filepath.Join(path, RecordsFile)); err != nil { + return Export{}, err + } + e.RecordsJSONL = string(data) + if data, err = readOptional(filepath.Join(path, TrustFile)); err != nil { + return Export{}, err + } + e.TrustRoot = data + return e, nil +} + +// RecordFailure is one decision record that does not bind to its receipt. +type RecordFailure struct { + Seq uint64 + Reason string +} + +// Record failure reasons. +const ( + ReasonRecordMissing = "record_missing" + ReasonRecordMismatch = "record_hash_mismatch" + ReasonRecordOrphan = "record_without_receipt" + ReasonRecordDup = "record_duplicate_seq" +) + +// Report is the result of VerifyExport. +type Report struct { + Chain receiptspec.VerifyResult + RecordsBound int + RecordFailures []RecordFailure +} + +// OK is true when the chain verifies and every record binds to its receipt. +func (r Report) OK() bool { return r.Chain.OK && len(r.RecordFailures) == 0 } + +// VerifyExport checks the receipt chain with receiptspec.VerifyBundle, the +// entry point agentgate-verify uses, then checks every decision record with +// receiptspec.VerifyRecordBinding. Trust: explicit keys first, then the +// export trust root, then keys embedded in the bundle. A *ManifestError or a +// config error is returned as an error. +func VerifyExport(e Export, trusted []receiptspec.TrustedKey) (Report, error) { + b, err := receiptspec.ReadJSONL(bytes.NewReader([]byte(e.ReceiptsJSONL))) + if err != nil { + return Report{}, err + } + if len(trusted) == 0 && len(bytes.TrimSpace(e.TrustRoot)) > 0 { + if trusted, err = receiptspec.LoadTrustedKeys(e.TrustRoot); err != nil { + return Report{}, err + } + } + res, err := receiptspec.VerifyBundle(b, trusted, nil) + if err != nil { + return Report{}, err + } + rep := Report{Chain: res} + + records := map[uint64]json.RawMessage{} + scanner := bufio.NewScanner(bytes.NewReader([]byte(e.RecordsJSONL))) + scanner.Buffer(make([]byte, 1<<20), 1<<24) + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + var rl RecordLine + if err := json.Unmarshal(line, &rl); err != nil { + return Report{}, fmt.Errorf("receipts: parse %s: %w", RecordsFile, err) + } + if _, dup := records[rl.Seq]; dup { + rep.RecordFailures = append(rep.RecordFailures, RecordFailure{Seq: rl.Seq, Reason: ReasonRecordDup}) + continue + } + records[rl.Seq] = append(json.RawMessage{}, rl.Record...) + } + if err := scanner.Err(); err != nil { + return Report{}, err + } + for _, r := range b.Receipts { + raw, ok := records[r.Seq] + delete(records, r.Seq) + switch { + case !ok: + rep.RecordFailures = append(rep.RecordFailures, RecordFailure{Seq: r.Seq, Reason: ReasonRecordMissing}) + case receiptspec.VerifyRecordBinding(r, raw) != nil: + rep.RecordFailures = append(rep.RecordFailures, RecordFailure{Seq: r.Seq, Reason: ReasonRecordMismatch}) + default: + rep.RecordsBound++ + } + } + for seq := range records { + rep.RecordFailures = append(rep.RecordFailures, RecordFailure{Seq: seq, Reason: ReasonRecordOrphan}) + } + sort.SliceStable(rep.RecordFailures, func(i, j int) bool { return rep.RecordFailures[i].Seq < rep.RecordFailures[j].Seq }) + return rep, nil +} + +// IsManifestError reports whether err is a manifest tamper finding. +func IsManifestError(err error) bool { + var m *receiptspec.ManifestError + return errors.As(err, &m) +} diff --git a/pkg/receipts/local.go b/pkg/receipts/local.go new file mode 100644 index 0000000..89f7bb2 --- /dev/null +++ b/pkg/receipts/local.go @@ -0,0 +1,357 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +// File names inside a LocalWriter data dir and an export dir. +const ( + ReceiptsFile = "receipts.jsonl" + RecordsFile = "records.jsonl" + TrustFile = "trust.json" + lockName = "writer.lock" +) + +// ErrClosed means the writer was closed. +var ErrClosed = errors.New("receipts: writer closed") + +// RecordLine is one line of records.jsonl: the decision record bound to the +// receipt with the same seq. +type RecordLine struct { + Seq uint64 `json:"seq"` + Record json.RawMessage `json:"record"` +} + +// LocalWriter appends signed receipts to receipts.jsonl and decision records +// to records.jsonl in one directory. It is the only writer of that directory: +// a flock guards it across processes and a mutex inside the process. +// +// Each Append writes and fsyncs the record first, then the receipt. On open, +// a torn last line is cut, and records without a receipt are dropped, so a +// crash never leaves a receipt without its record. +type LocalWriter struct { + mu sync.Mutex + priv ed25519.PrivateKey + kid string + chain *receiptspec.Chain + dir string + receipts *os.File + records *os.File + lock *os.File + broken bool + closed bool +} + +// OpenLocal opens or creates the chain in dir and resumes from its last +// receipt. It refuses to open a chain that is internally inconsistent or was +// signed by a different key. Key rotation is not supported yet. +func OpenLocal(dir string, priv ed25519.PrivateKey) (*LocalWriter, error) { + if len(priv) != ed25519.PrivateKeySize { + return nil, errors.New("receipts: invalid ed25519 private key") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + lock, err := lockFile(filepath.Join(dir, lockName)) + if err != nil { + return nil, err + } + w := &LocalWriter{priv: priv, dir: dir, lock: lock} + w.kid = receiptspec.ComputeKID(priv.Public().(ed25519.PublicKey)) + if err := w.open(); err != nil { + _ = lock.Close() + return nil, err + } + return w, nil +} + +func (w *LocalWriter) open() error { + rPath := filepath.Join(w.dir, ReceiptsFile) + data, err := readOptional(rPath) + if err != nil { + return err + } + lines, keep := completeLines(data) + var ( + seq uint64 + head [32]byte + lastKID string + ) + for i, line := range lines { + r, err := receiptspec.ParseJSONLReceipt(line) + if err != nil { + return fmt.Errorf("receipts: %s line %d: %w", ReceiptsFile, i+1, err) + } + hash, err := receiptspec.ComputeEntryHash(r) + if err != nil || hash != r.EntryHash || r.Seq != seq+1 || r.PrevHash != head { + return fmt.Errorf("receipts: %s line %d: chain inconsistent, refusing to resume", ReceiptsFile, i+1) + } + seq, head, lastKID = r.Seq, r.EntryHash, r.SignerKID + } + if seq > 0 && lastKID != w.kid { + return fmt.Errorf("receipts: chain head signed by %s, key is %s: key rotation is not supported", lastKID, w.kid) + } + if err := truncateTo(rPath, keep, len(data)); err != nil { + return err + } + + recPath := filepath.Join(w.dir, RecordsFile) + recData, err := readOptional(recPath) + if err != nil { + return err + } + recLines, recKeep := completeLines(recData) + offset := 0 + for i, line := range recLines { + var rl RecordLine + if err := json.Unmarshal(line, &rl); err != nil { + return fmt.Errorf("receipts: %s line %d: %w", RecordsFile, i+1, err) + } + if rl.Seq > seq { + recKeep = offset + break + } + offset += len(line) + 1 + } + if err := truncateTo(recPath, recKeep, len(recData)); err != nil { + return err + } + + if w.receipts, err = os.OpenFile(rPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err != nil { + return err + } + if w.records, err = os.OpenFile(recPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err != nil { + _ = w.receipts.Close() + return err + } + syncDir(w.dir) + w.chain = receiptspec.NewChain(receiptspec.NewEd25519Signer(w.priv), seq, head) + return nil +} + +// Append signs and stores one receipt and its record, fsynced, before +// returning. Any write failure marks the writer broken. +func (w *LocalWriter) Append(ctx context.Context, rec DecisionRecord) (receiptspec.Receipt, error) { + if err := ctx.Err(); err != nil { + return receiptspec.Receipt{}, err + } + canonical, err := receiptspec.CanonicalRecord(rec) + if err != nil { + return receiptspec.Receipt{}, err + } + fields, err := Fields(rec) + if err != nil { + return receiptspec.Receipt{}, err + } + + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return receiptspec.Receipt{}, ErrClosed + } + if w.broken { + return receiptspec.Receipt{}, ErrWriterBroken + } + r, err := w.chain.Next(fields) + if err != nil { + return receiptspec.Receipt{}, err + } + recLine, err := json.Marshal(RecordLine{Seq: r.Seq, Record: canonical}) + if err == nil { + err = appendSync(w.records, recLine) + } + if err == nil { + var line []byte + if line, err = receiptspec.MarshalJSONLReceipt(r); err == nil { + err = appendSync(w.receipts, line) + } + } + if err != nil { + w.broken = true + return receiptspec.Receipt{}, fmt.Errorf("receipts: store receipt seq %d: %w", r.Seq, err) + } + return r, nil +} + +// Ready reports whether Append can be called. +func (w *LocalWriter) Ready(context.Context) bool { + w.mu.Lock() + defer w.mu.Unlock() + return !w.closed && !w.broken +} + +// Head returns the last stored (seq, entry_hash). +func (w *LocalWriter) Head() (uint64, [32]byte) { + w.mu.Lock() + defer w.mu.Unlock() + return w.chain.Head() +} + +// KID returns the signer key ID. +func (w *LocalWriter) KID() string { return w.kid } + +// TrustedKey returns the public key entry for verifiers. +func (w *LocalWriter) TrustedKey() receiptspec.TrustedKey { + return receiptspec.TrustedKey{KID: w.kid, PublicKey: w.priv.Public().(ed25519.PublicKey)} +} + +// Close releases the files and the lock. +func (w *LocalWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return nil + } + w.closed = true + return errors.Join(w.receipts.Close(), w.records.Close(), w.lock.Close()) +} + +// Export builds a full, signed export of the chain plus its records. +func (w *LocalWriter) Export() (Export, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return Export{}, ErrClosed + } + data, err := readOptional(filepath.Join(w.dir, ReceiptsFile)) + if err != nil { + return Export{}, err + } + b, err := receiptspec.ReadJSONL(bytes.NewReader(data)) + if err != nil { + return Export{}, err + } + records, err := readOptional(filepath.Join(w.dir, RecordsFile)) + if err != nil { + return Export{}, err + } + key := receiptspec.TrustedKey{KID: w.kid, PublicKey: w.priv.Public().(ed25519.PublicKey)} + headSeq, headHash := w.chain.Head() + m := receiptspec.ExportManifest{ + FormatVersion: receiptspec.ExportFormatVersion, + RequestedFrom: 1, + ResolvedTo: headSeq, + Count: len(b.Receipts), + HeadSeq: headSeq, + HeadHash: headHash, + KeysetDigest: receiptspec.ComputeKeysetDigest([]receiptspec.TrustedKey{key}), + } + if n := len(b.Receipts); n > 0 { + m.FirstEntryHash = b.Receipts[0].EntryHash + m.LastEntryHash = b.Receipts[n-1].EntryHash + } + m = receiptspec.SignManifest(m, w.kid, w.priv) + + var buf bytes.Buffer + for _, line := range []func() ([]byte, error){ + func() ([]byte, error) { return receiptspec.MarshalManifestLine(m) }, + func() ([]byte, error) { return receiptspec.MarshalKeyLine(key) }, + } { + l, err := line() + if err != nil { + return Export{}, err + } + buf.Write(append(l, '\n')) + } + if err := receiptspec.WriteJSONL(&buf, b.Receipts); err != nil { + return Export{}, err + } + trust, err := TrustRootJSON(key) + if err != nil { + return Export{}, err + } + return Export{ + Format: ExportFormat, + ReceiptsJSONL: buf.String(), + TrustRoot: trust, + RecordsJSONL: string(records), + }, nil +} + +// TrustRootJSON renders keys in the trust file shape receiptspec.LoadTrustedKeys reads. +func TrustRootJSON(keys ...receiptspec.TrustedKey) (json.RawMessage, error) { + type k struct { + KID string `json:"kid"` + PublicKeyHex string `json:"public_key_hex"` + ValidFromSeq uint64 `json:"valid_from_seq"` + ValidUntilSeq *uint64 `json:"valid_until_seq"` + } + out := struct { + Keys []k `json:"keys"` + }{} + for _, key := range keys { + out.Keys = append(out.Keys, k{KID: key.KID, PublicKeyHex: hex.EncodeToString(key.PublicKey), ValidFromSeq: key.ValidFromSeq, ValidUntilSeq: key.ValidUntilSeq}) + } + return json.Marshal(out) +} + +func readOptional(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return data, err +} + +// completeLines returns every newline-terminated line, without the newline, +// and the byte length they cover. A trailing partial line is not included. +func completeLines(data []byte) ([][]byte, int) { + end := bytes.LastIndexByte(data, '\n') + 1 + var lines [][]byte + for _, l := range bytes.SplitAfter(data[:end], []byte{'\n'}) { + if len(l) > 0 { + lines = append(lines, l[:len(l)-1]) + } + } + return lines, end +} + +func truncateTo(path string, keep, size int) error { + if keep >= size { + return nil + } + f, err := os.OpenFile(path, os.O_WRONLY, 0o600) + if err != nil { + return err + } + if err := f.Truncate(int64(keep)); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +func appendSync(f *os.File, line []byte) error { + if _, err := f.Write(append(line, '\n')); err != nil { + return err + } + return f.Sync() +} + +func syncDir(dir string) { + if d, err := os.Open(dir); err == nil { + _ = d.Sync() + _ = d.Close() + } +} diff --git a/pkg/receipts/local_test.go b/pkg/receipts/local_test.go new file mode 100644 index 0000000..c73a3ca --- /dev/null +++ b/pkg/receipts/local_test.go @@ -0,0 +1,289 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +func openTest(t *testing.T, dir string, seed byte) *LocalWriter { + t.Helper() + w, err := OpenLocal(dir, testKey(seed)) + if err != nil { + t.Fatalf("OpenLocal: %v", err) + } + return w +} + +func appendN(t *testing.T, w *LocalWriter, n int) []receiptspec.Receipt { + t.Helper() + var out []receiptspec.Receipt + for i := 0; i < n; i++ { + p := testParams() + p.Input.Observed.PriorActionCount = i + r, err := w.Append(context.Background(), mustRecord(t, p)) + if err != nil { + t.Fatalf("append %d: %v", i, err) + } + out = append(out, r) + } + return out +} + +func mustExport(t *testing.T, w *LocalWriter) Export { + t.Helper() + e, err := w.Export() + if err != nil { + t.Fatalf("export: %v", err) + } + return e +} + +func TestLocalWriterChain(t *testing.T) { + for _, n := range []int{1, 2, 10} { + w := openTest(t, t.TempDir(), 1) + rs := appendN(t, w, n) + for i, r := range rs { + if r.Seq != uint64(i+1) || r.Service != Service { + t.Fatalf("receipt %d = seq %d service %q", i, r.Seq, r.Service) + } + } + rep, err := VerifyExport(mustExport(t, w), nil) + if err != nil || !rep.OK() || rep.RecordsBound != n || !rep.Chain.Complete { + t.Fatalf("n=%d verify = %+v, %v", n, rep, err) + } + _ = w.Close() + } +} + +func TestLocalWriterResumesAfterRestart(t *testing.T) { + dir := t.TempDir() + w := openTest(t, dir, 1) + first := appendN(t, w, 3) + if err := w.Close(); err != nil { + t.Fatal(err) + } + w = openTest(t, dir, 1) + defer func() { _ = w.Close() }() + if seq, hash := w.Head(); seq != 3 || hash != first[2].EntryHash { + t.Fatalf("head after reopen = %d %x", seq, hash[:4]) + } + next := appendN(t, w, 2) + if next[0].Seq != 4 || next[0].PrevHash != first[2].EntryHash { + t.Fatalf("resumed receipt seq %d prev %x", next[0].Seq, next[0].PrevHash[:4]) + } + rep, err := VerifyExport(mustExport(t, w), nil) + if err != nil || !rep.OK() || rep.RecordsBound != 5 { + t.Fatalf("verify = %+v, %v", rep, err) + } +} + +func TestLocalWriterRepairsTornTail(t *testing.T) { + tests := []struct { + name string + receipts string + records string + }{ + {"torn receipt line", `{"type":"receipt","seq":3`, ""}, + {"record without receipt", "", `{"seq":3,"record":{"a":1}}` + "\n"}, + {"both", `{"format_ver`, `{"seq":3,"record":{"a":1}}` + "\n" + `{"seq":4,"rec`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + w := openTest(t, dir, 1) + appendN(t, w, 2) + _ = w.Close() + appendFile(t, filepath.Join(dir, ReceiptsFile), tc.receipts) + appendFile(t, filepath.Join(dir, RecordsFile), tc.records) + + w = openTest(t, dir, 1) + defer func() { _ = w.Close() }() + if seq, _ := w.Head(); seq != 2 { + t.Fatalf("head = %d, want 2", seq) + } + if r := appendN(t, w, 1); r[0].Seq != 3 { + t.Fatalf("next seq = %d", r[0].Seq) + } + rep, err := VerifyExport(mustExport(t, w), nil) + if err != nil || !rep.OK() || rep.RecordsBound != 3 { + t.Fatalf("verify = %+v, %v", rep, err) + } + }) + } +} + +func appendFile(t *testing.T, path, s string) { + t.Helper() + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(s); err != nil { + t.Fatal(err) + } + _ = f.Close() +} + +func TestTamperedRecordFailsBinding(t *testing.T) { + w := openTest(t, t.TempDir(), 1) + defer func() { _ = w.Close() }() + r := appendN(t, w, 1)[0] + e := mustExport(t, w) + + var rl RecordLine + line := strings.TrimSpace(e.RecordsJSONL) + if err := json.Unmarshal([]byte(line), &rl); err != nil { + t.Fatal(err) + } + if err := receiptspec.VerifyRecordBinding(r, rl.Record); err != nil { + t.Fatalf("untouched record does not bind: %v", err) + } + tampered := bytes.Replace(rl.Record, []byte(`"allow"`), []byte(`"allox"`), 1) + if bytes.Equal(tampered, rl.Record) { + t.Fatal("tamper did not change the record") + } + if err := receiptspec.VerifyRecordBinding(r, tampered); !errors.Is(err, receiptspec.ErrRecordMismatch) { + t.Fatalf("tampered record binding err = %v, want ErrRecordMismatch", err) + } + + e.RecordsJSONL = strings.Replace(e.RecordsJSONL, `"allow"`, `"allox"`, 1) + rep, err := VerifyExport(e, nil) + if err != nil { + t.Fatal(err) + } + if rep.OK() || len(rep.RecordFailures) != 1 || rep.RecordFailures[0].Reason != ReasonRecordMismatch { + t.Fatalf("report = %+v, want record mismatch", rep) + } +} + +func TestTamperedReceiptFailsChain(t *testing.T) { + dir := t.TempDir() + w := openTest(t, dir, 1) + appendN(t, w, 3) + e := mustExport(t, w) + _ = w.Close() + + tampered := e + tampered.ReceiptsJSONL = strings.Replace(e.ReceiptsJSONL, `"policy_decision":"allow"`, `"policy_decision":"deny"`, 1) + if tampered.ReceiptsJSONL == e.ReceiptsJSONL { + t.Fatal("tamper did not change the receipts") + } + rep, err := VerifyExport(tampered, nil) + if err != nil { + t.Fatal(err) + } + if rep.Chain.OK || rep.Chain.FailedAtSeq != 1 || rep.Chain.Reason != receiptspec.ReasonEntryHashMismatch { + t.Fatalf("chain = %+v, want entry hash mismatch at seq 1", rep.Chain) + } + + data, _ := os.ReadFile(filepath.Join(dir, ReceiptsFile)) + tamperedFile := strings.Replace(string(data), `"policy_decision":"allow"`, `"policy_decision":"deny"`, 1) + if err := os.WriteFile(filepath.Join(dir, ReceiptsFile), []byte(tamperedFile), 0o600); err != nil { + t.Fatal(err) + } + if _, err := OpenLocal(dir, testKey(1)); err == nil { + t.Fatal("OpenLocal resumed a tampered chain") + } +} + +// TestExportVerifiesLikeAgentgateVerify reads the exported receipts.jsonl the +// way agentgate-verify --source jsonl does: ReadJSONL then VerifyBundle, with +// embedded keys and with the trust file. +func TestExportVerifiesLikeAgentgateVerify(t *testing.T) { + w := openTest(t, t.TempDir(), 1) + appendN(t, w, 4) + out := t.TempDir() + if err := mustExport(t, w).WriteDir(out); err != nil { + t.Fatal(err) + } + _ = w.Close() + + f, err := os.Open(filepath.Join(out, ReceiptsFile)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + bundle, err := receiptspec.ReadJSONL(f) + if err != nil { + t.Fatal(err) + } + if bundle.Manifest == nil || len(bundle.Keys) != 1 || len(bundle.Receipts) != 4 { + t.Fatalf("bundle manifest=%v keys=%d receipts=%d", bundle.Manifest != nil, len(bundle.Keys), len(bundle.Receipts)) + } + trustData, err := os.ReadFile(filepath.Join(out, TrustFile)) + if err != nil { + t.Fatal(err) + } + trusted, err := receiptspec.LoadTrustedKeys(trustData) + if err != nil { + t.Fatal(err) + } + for name, keys := range map[string][]receiptspec.TrustedKey{"embedded keys": nil, "trust root": trusted} { + res, err := receiptspec.VerifyBundle(bundle, keys, nil) + if err != nil || !res.OK || !res.Complete || res.HeadSeq != 4 { + t.Fatalf("%s: VerifyBundle = %+v, %v", name, res, err) + } + } + if _, err := receiptspec.VerifyBundle(bundle, []receiptspec.TrustedKey{{KID: "other", PublicKey: testKey(9).Public().(ed25519.PublicKey)}}, nil); err == nil { + t.Fatal("bundle verified against an unrelated trust root") + } + + e, err := ReadExport(out) + if err != nil { + t.Fatal(err) + } + if rep, err := VerifyExport(e, nil); err != nil || !rep.OK() { + t.Fatalf("VerifyExport(dir) = %+v, %v", rep, err) + } +} + +func TestLocalWriterSingleWriterLock(t *testing.T) { + dir := t.TempDir() + w := openTest(t, dir, 1) + if _, err := OpenLocal(dir, testKey(1)); err == nil { + t.Fatal("second writer opened a locked chain") + } + _ = w.Close() + w2 := openTest(t, dir, 1) + _ = w2.Close() +} + +func TestLocalWriterRefusesKeyChange(t *testing.T) { + dir := t.TempDir() + w := openTest(t, dir, 1) + appendN(t, w, 1) + _ = w.Close() + if _, err := OpenLocal(dir, testKey(2)); err == nil || !strings.Contains(err.Error(), "rotation") { + t.Fatalf("OpenLocal with a new key err = %v, want rotation refusal", err) + } +} + +func TestLocalWriterBrokenFailsClosed(t *testing.T) { + w := openTest(t, t.TempDir(), 1) + defer func() { _ = w.Close() }() + appendN(t, w, 1) + _ = w.records.Close() + if _, err := w.Append(context.Background(), mustRecord(t, testParams())); err == nil { + t.Fatal("append after write failure succeeded") + } + if w.Ready(context.Background()) { + t.Fatal("broken writer reports ready") + } + if _, err := w.Append(context.Background(), mustRecord(t, testParams())); !errors.Is(err, ErrWriterBroken) { + t.Fatalf("second append err = %v, want ErrWriterBroken", err) + } +} diff --git a/pkg/receipts/lock_other.go b/pkg/receipts/lock_other.go new file mode 100644 index 0000000..d05e2a8 --- /dev/null +++ b/pkg/receipts/lock_other.go @@ -0,0 +1,15 @@ +//go:build !unix + +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import "os" + +// lockFile only opens the lock file. Cross-process locking needs unix flock. +func lockFile(path string) (*os.File, error) { + return os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) +} diff --git a/pkg/receipts/lock_unix.go b/pkg/receipts/lock_unix.go new file mode 100644 index 0000000..86aed8f --- /dev/null +++ b/pkg/receipts/lock_unix.go @@ -0,0 +1,28 @@ +//go:build unix + +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "fmt" + "os" + "syscall" +) + +// lockFile takes an exclusive, non-blocking flock so only one process writes +// the chain. +func lockFile(path string) (*os.File, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + return nil, fmt.Errorf("receipts: chain already locked by another writer: %w", err) + } + return f, nil +} diff --git a/pkg/receipts/record.go b/pkg/receipts/record.go new file mode 100644 index 0000000..029e5ba --- /dev/null +++ b/pkg/receipts/record.go @@ -0,0 +1,371 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +// Package receipts records every operator decision as a signed receipt in the +// AgentGate receipt format (github.com/Clawdlinux/agentgate/pkg/receiptspec). +// The receipt carries the hash of a DecisionRecord. The record is stored +// beside it. See docs/receipts.md. +package receipts + +import ( + "encoding/hex" + "fmt" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +// SchemaVersion is the DecisionRecord schema version. +const SchemaVersion = 1 + +// Service is the receipt Service field for operator decisions. +const Service = "clawdlinux" + +// ReplayHint tells a reviewer how to reproduce the outcome. +const ReplayHint = "deterministic-v1: rerun invariants, policy_packs, and threshold_mode on an input matching input_hash" + +// Record layers. "model" is reserved until a model decides. +const ( + LayerInvariant = "invariant" + LayerRules = "rules" + LayerThreshold = "threshold" + LayerHuman = "human" + LayerModel = "model" +) + +// Record outcomes. Mapped to receipt PolicyDecision per receiptspec DECISION.md. +const ( + OutcomeAllow = "allow" + OutcomeDeny = "deny" + OutcomeRequireApproval = "require_approval" + OutcomeApproved = "approved" + OutcomeRejected = "rejected" + OutcomeEdited = "edited" +) + +// Workload identifies the AgentWorkload that proposed the action. +type Workload struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + UID string `json:"uid"` +} + +// Reason is one finding that contributed to the outcome. +type Reason struct { + RuleID string `json:"rule_id"` + Reason string `json:"reason"` +} + +// DeclaredSummary holds the human-declared intent. Purpose is hashed only. +type DeclaredSummary struct { + SHA256 string `json:"sha256"` + DecisionType string `json:"decision_type"` + AllowedDataClasses []string `json:"allowed_data_classes"` + AllowedDestinations []string `json:"allowed_destinations"` +} + +// ObservedSummary holds platform-measured facts. Caller identity is hashed +// only. Destination is the host, never a path or query. +type ObservedSummary struct { + SHA256 string `json:"sha256"` + DestinationHost string `json:"destination_host"` + DataClasses []string `json:"data_classes"` + Tool string `json:"tool"` + PriorActionCount int `json:"prior_action_count"` + PriorDeniedCount int `json:"prior_denied_count"` +} + +// ClaimedSummary holds agent-claimed values. Intent is hashed only. Numbers +// are decimal strings because receipt records forbid floats. +type ClaimedSummary struct { + SHA256 string `json:"sha256"` + Confidence string `json:"confidence,omitempty"` + ClusterHealth string `json:"cluster_health,omitempty"` +} + +// Model is reserved for the decision model layer. It is nil until a model +// decides. Probabilities and numbers are decimal strings. +type Model struct { + ID string `json:"id"` + Version string `json:"version"` + OptionSet []string `json:"option_set"` + Probabilities []string `json:"probabilities"` + Threshold string `json:"threshold"` + Calibration string `json:"calibration"` +} + +// DecisionRecord is the record bound to a receipt by its hash. Fields are +// strings, integers, or lists of those. Never floats. +type DecisionRecord struct { + SchemaVersion int `json:"schema_version"` + Workload Workload `json:"workload"` + Action string `json:"action"` + Layer string `json:"layer"` + Outcome string `json:"outcome"` + Reasons []Reason `json:"reasons"` + InputHash string `json:"input_hash"` + Declared DeclaredSummary `json:"declared"` + Observed ObservedSummary `json:"observed"` + Claimed ClaimedSummary `json:"claimed"` + PolicyPacks []string `json:"policy_packs"` + ThresholdMode string `json:"threshold_mode"` + Model *Model `json:"model,omitempty"` + ReplayHint string `json:"replay_hint"` +} + +// Params are the inputs to NewDecisionRecord. +type Params struct { + Workload Workload + Action string + Input input.Input + Layers decision.Layers + Result decision.Result + PolicyPacks []string + ThresholdMode string +} + +// NewDecisionRecord builds the record for one decided action. The same params +// always give the same record and the same canonical bytes. +func NewDecisionRecord(p Params) (DecisionRecord, error) { + outcome, layer, err := mapResult(p.Result) + if err != nil { + return DecisionRecord{}, err + } + inputHash, err := InputHash(p.Input) + if err != nil { + return DecisionRecord{}, err + } + declHash, err := hashHex(canonDeclared(p.Input.Declared)) + if err != nil { + return DecisionRecord{}, err + } + obsHash, err := hashHex(canonObserved(p.Input.Observed)) + if err != nil { + return DecisionRecord{}, err + } + claimHash, err := hashHex(canonClaimed(p.Input.Claimed)) + if err != nil { + return DecisionRecord{}, err + } + return DecisionRecord{ + SchemaVersion: SchemaVersion, + Workload: p.Workload, + Action: p.Action, + Layer: layer, + Outcome: outcome, + Reasons: ReasonsFromLayers(p.Layers), + InputHash: inputHash, + Declared: DeclaredSummary{ + SHA256: declHash, + DecisionType: p.Input.Declared.DecisionType, + AllowedDataClasses: sortedCopy(p.Input.Declared.AllowedDataClasses), + AllowedDestinations: sortedCopy(p.Input.Declared.AllowedDestinations), + }, + Observed: ObservedSummary{ + SHA256: obsHash, + DestinationHost: input.Host(p.Input.Observed.Destination), + DataClasses: sortedCopy(p.Input.Observed.DataClasses), + Tool: p.Input.Observed.Tool, + PriorActionCount: p.Input.Observed.PriorActionCount, + PriorDeniedCount: p.Input.Observed.PriorDeniedCount, + }, + Claimed: ClaimedSummary{ + SHA256: claimHash, + Confidence: decimal(p.Input.Claimed.Confidence), + ClusterHealth: decimal(p.Input.Claimed.ClusterHealth), + }, + PolicyPacks: sortedCopy(p.PolicyPacks), + ThresholdMode: p.ThresholdMode, + ReplayHint: ReplayHint, + }, nil +} + +// ReasonsFromLayers turns layer findings into rule-tagged reasons, in the +// same order decision.Decide lists them. +func ReasonsFromLayers(l decision.Layers) []Reason { + out := []Reason{} + for _, s := range l.Invariants { + out = append(out, splitReason(s, "invariant")) + } + if l.PackErr != nil { + out = append(out, Reason{RuleID: "pack-error", Reason: l.PackErr.Error()}) + } + for _, s := range l.PackDeny { + out = append(out, splitReason(s, "pack")) + } + for _, s := range l.PackApproval { + out = append(out, splitReason(s, "pack")) + } + for _, s := range l.ThresholdReasons { + out = append(out, Reason{RuleID: "threshold", Reason: s}) + } + return out +} + +// splitReason splits "ID: reason". Invariant and pack strings use that form. +func splitReason(s, fallback string) Reason { + if id, reason, ok := strings.Cut(s, ": "); ok && id != "" && !strings.ContainsAny(id, " \t") { + return Reason{RuleID: id, Reason: reason} + } + return Reason{RuleID: fallback, Reason: s} +} + +func mapResult(r decision.Result) (outcome, layer string, err error) { + switch r.Outcome { + case decision.Allow: + outcome = OutcomeAllow + case decision.Deny: + outcome = OutcomeDeny + case decision.RequireApproval: + outcome = OutcomeRequireApproval + default: + return "", "", fmt.Errorf("receipts: unknown outcome %q", r.Outcome) + } + switch r.Layer { + case decision.LayerInvariant: + layer = LayerInvariant + case decision.LayerPack: + layer = LayerRules + case decision.LayerThreshold: + layer = LayerThreshold + default: + return "", "", fmt.Errorf("receipts: unknown layer %q", r.Layer) + } + return outcome, layer, nil +} + +// PolicyDecision maps a record outcome to the receipt PolicyDecision enum. +// allow means the action went ahead. deny means it did not, yet. +func PolicyDecision(outcome string) (string, error) { + switch outcome { + case OutcomeAllow, OutcomeApproved, OutcomeEdited: + return receiptspec.DecisionAllow, nil + case OutcomeDeny, OutcomeRejected, OutcomeRequireApproval: + return receiptspec.DecisionDeny, nil + } + return "", fmt.Errorf("receipts: unknown outcome %q", outcome) +} + +// statusCode is an HTTP-style code for the receipt StatusCode field. +func statusCode(outcome string) int { + switch outcome { + case OutcomeAllow, OutcomeApproved, OutcomeEdited: + return 200 + case OutcomeRequireApproval: + return 202 + } + return 403 +} + +// Fields builds the receipt fields for rec. ParamsSHA256 is HashRecord(rec). +func Fields(rec DecisionRecord) (receiptspec.Fields, error) { + pd, err := PolicyDecision(rec.Outcome) + if err != nil { + return receiptspec.Fields{}, err + } + hash, err := receiptspec.HashRecord(rec) + if err != nil { + return receiptspec.Fields{}, err + } + agent := rec.Workload.UID + if agent == "" { + agent = rec.Workload.Namespace + "/" + rec.Workload.Name + } + return receiptspec.Fields{ + HumanPrincipal: "system:clawdlinux-operator", + AgentKeyID: clip(agent, 128, "unknown-workload"), + Service: Service, + Action: clip("decision:"+rec.Action, 128, "decision:unknown"), + ParamsSHA256: hash, + PolicyDecision: pd, + StatusCode: statusCode(rec.Outcome), + }, nil +} + +// clip makes s a valid receipt string: UTF-8, no NUL, at most max bytes. +func clip(s string, max int, fallback string) string { + s = strings.ReplaceAll(strings.ToValidUTF8(s, "?"), "\x00", "") + for len(s) > max { + _, size := utf8.DecodeLastRuneInString(s) + s = s[:len(s)-size] + } + if s == "" || s == "decision:" { + return fallback + } + return s +} + +// InputHash is the SHA-256 hex of the canonical JSON of the full input, +// agent claims included under "agent_claimed". +func InputHash(in input.Input) (string, error) { + return hashHex(map[string]any{ + "declared": canonDeclared(in.Declared), + "observed": canonObserved(in.Observed), + "agent_claimed": canonClaimed(in.Claimed), + }) +} + +func canonDeclared(d input.Declared) map[string]any { + return map[string]any{ + "purpose": d.Purpose, + "decision_type": d.DecisionType, + "allowed_data_classes": list(d.AllowedDataClasses), + "allowed_destinations": list(d.AllowedDestinations), + } +} + +func canonObserved(o input.Observed) map[string]any { + return map[string]any{ + "destination": o.Destination, + "data_classes": list(o.DataClasses), + "tool": o.Tool, + "caller_identity": o.CallerIdentity, + "prior_action_count": o.PriorActionCount, + "prior_denied_count": o.PriorDeniedCount, + } +} + +func canonClaimed(c input.AgentClaimed) map[string]any { + return map[string]any{ + "confidence": decimal(c.Confidence), + "cluster_health": decimal(c.ClusterHealth), + "intent": c.Intent, + } +} + +func hashHex(v any) (string, error) { + h, err := receiptspec.HashRecord(v) + if err != nil { + return "", err + } + return hex.EncodeToString(h[:]), nil +} + +// decimal formats a float as the shortest exact decimal string. nil is "". +func decimal(f *float64) string { + if f == nil { + return "" + } + return strconv.FormatFloat(*f, 'f', -1, 64) +} + +func list(in []string) []string { + if in == nil { + return []string{} + } + return in +} + +func sortedCopy(in []string) []string { + out := append([]string{}, in...) + sort.Strings(out) + return out +} diff --git a/pkg/receipts/record_test.go b/pkg/receipts/record_test.go new file mode 100644 index 0000000..3d99409 --- /dev/null +++ b/pkg/receipts/record_test.go @@ -0,0 +1,262 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "bytes" + "crypto/ed25519" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +func testKey(seed byte) ed25519.PrivateKey { + return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, ed25519.SeedSize)) +} + +func f64(v float64) *float64 { return &v } + +func testParams() Params { + return Params{ + Workload: Workload{Namespace: "team-a", Name: "refunds", UID: "6f1c-uid"}, + Action: "refund", + Input: input.Input{ + Declared: input.Declared{ + Purpose: "SECRET-PURPOSE refund triage", + DecisionType: "refund", + AllowedDataClasses: []string{"phone", "email"}, + AllowedDestinations: []string{"mcp.internal"}, + }, + Observed: input.Observed{ + Destination: "https://mcp.internal:8000/v1/call?token=RAWQUERY", + DataClasses: []string{"phone", "email"}, + Tool: "refund", + CallerIdentity: "system:serviceaccount:team-a:CALLER-ID", + PriorActionCount: 4, + PriorDeniedCount: 1, + }, + Claimed: input.AgentClaimed{Confidence: f64(0.875), ClusterHealth: f64(92.5), Intent: "AGENT-INTENT refund 42"}, + }, + Layers: decision.Layers{ThresholdAllowed: true, ThresholdReasons: []string{"High confidence (0.88) action allowed"}}, + Result: decision.Result{Outcome: decision.Allow, Layer: decision.LayerThreshold}, + PolicyPacks: []string{"gdpr-eu@v0.1.0", "dpdp-in@v0.1.0"}, + ThresholdMode: "strict", + } +} + +func mustRecord(t *testing.T, p Params) DecisionRecord { + t.Helper() + rec, err := NewDecisionRecord(p) + if err != nil { + t.Fatalf("NewDecisionRecord: %v", err) + } + return rec +} + +func TestNewDecisionRecordDeterministic(t *testing.T) { + a, err := receiptspec.CanonicalRecord(mustRecord(t, testParams())) + if err != nil { + t.Fatalf("canonical: %v", err) + } + for i := 0; i < 5; i++ { + b, err := receiptspec.CanonicalRecord(mustRecord(t, testParams())) + if err != nil { + t.Fatalf("canonical: %v", err) + } + if !bytes.Equal(a, b) { + t.Fatalf("record bytes differ:\n%s\n%s", a, b) + } + } + h1, _ := receiptspec.HashRecord(mustRecord(t, testParams())) + h2, _ := receiptspec.HashRecord(mustRecord(t, testParams())) + if h1 != h2 { + t.Fatal("record hash differs for the same inputs") + } +} + +func TestInputHashCoversEveryInput(t *testing.T) { + base := mustRecord(t, testParams()).InputHash + tests := []struct { + name string + change func(*Params) + }{ + {"purpose", func(p *Params) { p.Input.Declared.Purpose = "other" }}, + {"destination path", func(p *Params) { p.Input.Observed.Destination = "https://mcp.internal:8000/other" }}, + {"caller identity", func(p *Params) { p.Input.Observed.CallerIdentity = "other" }}, + {"claimed intent", func(p *Params) { p.Input.Claimed.Intent = "other" }}, + {"claimed confidence", func(p *Params) { p.Input.Claimed.Confidence = f64(0.5) }}, + {"claimed confidence absent", func(p *Params) { p.Input.Claimed.Confidence = nil }}, + {"prior count", func(p *Params) { p.Input.Observed.PriorActionCount = 5 }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := testParams() + tc.change(&p) + if got := mustRecord(t, p).InputHash; got == base { + t.Fatalf("input_hash unchanged after changing %s", tc.name) + } + }) + } +} + +func TestDecisionRecordOmitsRawContent(t *testing.T) { + data, err := json.Marshal(mustRecord(t, testParams())) + if err != nil { + t.Fatal(err) + } + for _, raw := range []string{"SECRET-PURPOSE", "RAWQUERY", "/v1/call", "CALLER-ID", "AGENT-INTENT", `"model"`} { + if strings.Contains(string(data), raw) { + t.Errorf("record contains %q: %s", raw, data) + } + } + for _, want := range []string{`"destination_host":"mcp.internal"`, `"confidence":"0.875"`, `"cluster_health":"92.5"`, `"policy_packs":["dpdp-in@v0.1.0","gdpr-eu@v0.1.0"]`} { + if !strings.Contains(string(data), want) { + t.Errorf("record missing %s: %s", want, data) + } + } +} + +func TestOutcomeMapping(t *testing.T) { + tests := []struct { + outcome decision.Outcome + layer string + wantOut string + wantLay string + wantPD string + wantCode int + }{ + {decision.Allow, decision.LayerThreshold, OutcomeAllow, LayerThreshold, receiptspec.DecisionAllow, 200}, + {decision.Deny, decision.LayerInvariant, OutcomeDeny, LayerInvariant, receiptspec.DecisionDeny, 403}, + {decision.Deny, decision.LayerPack, OutcomeDeny, LayerRules, receiptspec.DecisionDeny, 403}, + {decision.RequireApproval, decision.LayerPack, OutcomeRequireApproval, LayerRules, receiptspec.DecisionDeny, 202}, + } + for _, tc := range tests { + t.Run(string(tc.outcome)+"/"+tc.layer, func(t *testing.T) { + p := testParams() + p.Result = decision.Result{Outcome: tc.outcome, Layer: tc.layer} + rec := mustRecord(t, p) + if rec.Outcome != tc.wantOut || rec.Layer != tc.wantLay { + t.Fatalf("got %s/%s, want %s/%s", rec.Outcome, rec.Layer, tc.wantOut, tc.wantLay) + } + f, err := Fields(rec) + if err != nil { + t.Fatal(err) + } + if f.PolicyDecision != tc.wantPD || f.StatusCode != tc.wantCode { + t.Fatalf("fields %s/%d, want %s/%d", f.PolicyDecision, f.StatusCode, tc.wantPD, tc.wantCode) + } + if f.Service != Service || f.Action != "decision:refund" || f.AgentKeyID != "6f1c-uid" { + t.Fatalf("fields = %+v", f) + } + }) + } + for _, o := range []string{OutcomeApproved, OutcomeEdited} { + if pd, _ := PolicyDecision(o); pd != receiptspec.DecisionAllow { + t.Errorf("%s -> %s, want allow", o, pd) + } + } + if pd, _ := PolicyDecision(OutcomeRejected); pd != receiptspec.DecisionDeny { + t.Errorf("rejected -> %s, want deny", pd) + } + if _, err := NewDecisionRecord(Params{Result: decision.Result{Outcome: "maybe", Layer: decision.LayerPack}}); err == nil { + t.Error("unknown outcome accepted") + } +} + +func TestReasonsFromLayers(t *testing.T) { + got := ReasonsFromLayers(decision.Layers{ + Invariants: []string{"INV-02: observed destination \"x.io\" not declared"}, + PackErr: errors.New("load failed"), + PackDeny: []string{"gdpr-eu/GDPR-01: personal data to non-EU host"}, + PackApproval: []string{"dpdp-in/DPDP-03: needs approval"}, + ThresholdReasons: []string{"CRITICAL: Low confidence AND cluster degradation."}, + }) + want := []Reason{ + {"INV-02", "observed destination \"x.io\" not declared"}, + {"pack-error", "load failed"}, + {"gdpr-eu/GDPR-01", "personal data to non-EU host"}, + {"dpdp-in/DPDP-03", "needs approval"}, + {"threshold", "CRITICAL: Low confidence AND cluster degradation."}, + } + if len(got) != len(want) { + t.Fatalf("got %d reasons, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("reason %d = %+v, want %+v", i, got[i], want[i]) + } + } + if r := ReasonsFromLayers(decision.Layers{}); r == nil || len(r) != 0 { + t.Errorf("empty layers gave %#v, want empty non-nil", r) + } +} + +func TestFieldsClipsAction(t *testing.T) { + tests := []struct { + name, action, want string + }{ + {"empty", "", "decision:unknown"}, + {"nul stripped", "sc\x00ale", "decision:scale"}, + {"long clipped", strings.Repeat("a", 300), "decision:" + strings.Repeat("a", 119)}, + {"invalid utf8", "a\xffb", "decision:a?b"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := testParams() + p.Action = tc.action + f, err := Fields(mustRecord(t, p)) + if err != nil { + t.Fatal(err) + } + if f.Action != tc.want { + t.Fatalf("action = %q, want %q", f.Action, tc.want) + } + }) + } +} + +func TestConfigFromEnv(t *testing.T) { + tests := []struct { + name string + env map[string]string + enabled bool + required bool + wantErr bool + }{ + {"defaults off", nil, false, false, false}, + {"enabled", map[string]string{"RECEIPTS_ENABLED": "true"}, true, false, false}, + {"required implies enabled", map[string]string{"RECEIPTS_REQUIRED": "true"}, true, true, false}, + {"bad bool", map[string]string{"RECEIPTS_ENABLED": "yes please"}, false, false, true}, + {"bad timeout", map[string]string{"RECEIPTS_WRITER_TIMEOUT": "soon"}, false, false, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c, err := ConfigFromEnv(func(k string) string { return tc.env[k] }) + if (err != nil) != tc.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tc.wantErr) + } + if err == nil && (c.Enabled != tc.enabled || c.Required != tc.required) { + t.Fatalf("got enabled=%v required=%v", c.Enabled, c.Required) + } + }) + } + w, err := Config{}.NewWriter() + if err != nil || w != nil { + t.Fatalf("disabled config gave writer %v err %v", w, err) + } + if _, err := (Config{Enabled: true, WriterURL: "http://w"}).NewWriter(); err == nil { + t.Fatal("missing token file accepted") + } + if _, err := (Config{Enabled: true, TokenFile: "/t"}).NewWriter(); err == nil { + t.Fatal("missing URL accepted") + } +} diff --git a/pkg/receipts/remote.go b/pkg/receipts/remote.go new file mode 100644 index 0000000..b46a911 --- /dev/null +++ b/pkg/receipts/remote.go @@ -0,0 +1,231 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +// MaxRecordBody caps a POST /v1/records body. +const MaxRecordBody = 64 << 10 + +// AppendResponse is the POST /v1/records reply. Receipt is one receiptspec +// JSONL receipt object. +type AppendResponse struct { + Receipt json.RawMessage `json:"receipt"` +} + +// HeadResponse is the GET /v1/head reply. +type HeadResponse struct { + Seq uint64 `json:"seq"` + EntryHash string `json:"entry_hash"` + SignerKID string `json:"signer_kid"` +} + +// RemoteWriter sends records to the receipt-writer service. Every error is +// returned to the caller, which decides whether to fail closed. +type RemoteWriter struct { + baseURL string + token func() (string, error) + client *http.Client +} + +// NewRemoteWriter returns a client for baseURL. token is called per request, +// so a rotated token file is picked up without a restart. +func NewRemoteWriter(baseURL string, token func() (string, error), timeout time.Duration) (*RemoteWriter, error) { + u, err := url.Parse(baseURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("receipts: writer URL must be http(s)://host[:port], got %q", baseURL) + } + if token == nil { + return nil, errors.New("receipts: writer token source is required") + } + if timeout <= 0 { + timeout = 5 * time.Second + } + return &RemoteWriter{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + client: &http.Client{Timeout: timeout}, + }, nil +} + +// TokenFile returns a token source that reads path on every call. +func TokenFile(path string) func() (string, error) { + return func() (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + tok := strings.TrimSpace(string(data)) + if tok == "" { + return "", errors.New("receipts: token file is empty") + } + return tok, nil + } +} + +// Append posts rec and checks that the returned receipt binds to it. +func (w *RemoteWriter) Append(ctx context.Context, rec DecisionRecord) (receiptspec.Receipt, error) { + body, err := json.Marshal(rec) + if err != nil { + return receiptspec.Receipt{}, err + } + resp, err := w.do(ctx, http.MethodPost, "/v1/records", body) + if err != nil { + return receiptspec.Receipt{}, err + } + var ar AppendResponse + if err := json.Unmarshal(resp, &ar); err != nil { + return receiptspec.Receipt{}, fmt.Errorf("receipts: parse writer reply: %w", err) + } + r, err := receiptspec.ParseJSONLReceipt(ar.Receipt) + if err != nil { + return receiptspec.Receipt{}, fmt.Errorf("receipts: parse writer receipt: %w", err) + } + canonical, err := receiptspec.CanonicalRecord(rec) + if err != nil { + return receiptspec.Receipt{}, err + } + if err := receiptspec.VerifyRecordBinding(r, canonical); err != nil { + return receiptspec.Receipt{}, fmt.Errorf("receipts: writer receipt does not bind to the record: %w", err) + } + return r, nil +} + +// Ready reports whether the writer answers GET /healthz with 200. +func (w *RemoteWriter) Ready(ctx context.Context) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, w.baseURL+"/healthz", nil) + if err != nil { + return false + } + resp, err := w.client.Do(req) + if err != nil { + return false + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<10)) + _ = resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// Export fetches GET /v1/export. +func (w *RemoteWriter) Export(ctx context.Context) (Export, error) { + data, err := w.do(ctx, http.MethodGet, "/v1/export", nil) + if err != nil { + return Export{}, err + } + var e Export + if err := json.Unmarshal(data, &e); err != nil { + return Export{}, fmt.Errorf("receipts: parse export: %w", err) + } + if e.Format != ExportFormat { + return Export{}, fmt.Errorf("receipts: unknown export format %q", e.Format) + } + return e, nil +} + +func (w *RemoteWriter) do(ctx context.Context, method, path string, body []byte) ([]byte, error) { + tok, err := w.token() + if err != nil { + return nil, fmt.Errorf("receipts: read writer token: %w", err) + } + req, err := http.NewRequestWithContext(ctx, method, w.baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tok) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := w.client.Do(req) + if err != nil { + return nil, fmt.Errorf("receipts: writer %s %s: %w", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(io.LimitReader(resp.Body, 256<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("receipts: writer %s %s: status %d", method, path, resp.StatusCode) + } + return data, nil +} + +// Config is the operator side receipt configuration. +type Config struct { + Enabled bool + Required bool + WriterURL string + TokenFile string + Timeout time.Duration +} + +// ConfigFromEnv reads RECEIPTS_ENABLED, RECEIPTS_REQUIRED, +// RECEIPTS_WRITER_URL, RECEIPTS_WRITER_TOKEN_FILE, RECEIPTS_WRITER_TIMEOUT. +// RECEIPTS_REQUIRED=true implies enabled. +func ConfigFromEnv(getenv func(string) string) (Config, error) { + var c Config + var err error + if c.Enabled, err = envBool(getenv, "RECEIPTS_ENABLED"); err != nil { + return c, err + } + if c.Required, err = envBool(getenv, "RECEIPTS_REQUIRED"); err != nil { + return c, err + } + c.Enabled = c.Enabled || c.Required + c.WriterURL = strings.TrimSpace(getenv("RECEIPTS_WRITER_URL")) + c.TokenFile = strings.TrimSpace(getenv("RECEIPTS_WRITER_TOKEN_FILE")) + c.Timeout = 5 * time.Second + if v := strings.TrimSpace(getenv("RECEIPTS_WRITER_TIMEOUT")); v != "" { + if c.Timeout, err = time.ParseDuration(v); err != nil { + return c, fmt.Errorf("receipts: RECEIPTS_WRITER_TIMEOUT: %w", err) + } + } + return c, nil +} + +// NewWriter builds the RemoteWriter for c. It returns nil when receipts are +// disabled. A misconfigured writer returns an error; callers with Required +// set must then treat the writer as unavailable. +func (c Config) NewWriter() (Writer, error) { + if !c.Enabled { + return nil, nil + } + if c.TokenFile == "" { + return nil, errors.New("receipts: RECEIPTS_WRITER_TOKEN_FILE is required when receipts are enabled") + } + w, err := NewRemoteWriter(c.WriterURL, TokenFile(c.TokenFile), c.Timeout) + if err != nil { + return nil, err + } + return w, nil +} + +func envBool(getenv func(string) string, key string) (bool, error) { + v := strings.TrimSpace(getenv(key)) + if v == "" { + return false, nil + } + b, err := strconv.ParseBool(v) + if err != nil { + return false, fmt.Errorf("receipts: %s: %w", key, err) + } + return b, nil +} diff --git a/pkg/receipts/remote_test.go b/pkg/receipts/remote_test.go new file mode 100644 index 0000000..f144da2 --- /dev/null +++ b/pkg/receipts/remote_test.go @@ -0,0 +1,156 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +// fakeWriterServer is a minimal receipt-writer backed by a LocalWriter. +// mutate, when set, changes the record before it is signed. +func fakeWriterServer(t *testing.T, status int, mutate func(*DecisionRecord)) *httptest.Server { + t.Helper() + lw := openTest(t, t.TempDir(), 1) + t.Cleanup(func() { _ = lw.Close() }) + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + w.WriteHeader(status) + return + } + if r.Header.Get("Authorization") != "Bearer tok" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if status != http.StatusOK { + w.WriteHeader(status) + return + } + var rec DecisionRecord + if err := json.NewDecoder(r.Body).Decode(&rec); err != nil { + http.Error(w, "bad", http.StatusBadRequest) + return + } + if mutate != nil { + mutate(&rec) + } + rc, err := lw.Append(r.Context(), rec) + if err != nil { + http.Error(w, "append", http.StatusInternalServerError) + return + } + line, _ := receiptspec.MarshalJSONLReceipt(rc) + _ = json.NewEncoder(w).Encode(AppendResponse{Receipt: line}) + })) +} + +func staticToken(tok string) func() (string, error) { + return func() (string, error) { return tok, nil } +} + +func TestRemoteWriterAppend(t *testing.T) { + tests := []struct { + name string + status int + token func() (string, error) + mutate func(*DecisionRecord) + wantErr bool + }{ + {"ok", http.StatusOK, staticToken("tok"), nil, false}, + {"server error", http.StatusInternalServerError, staticToken("tok"), nil, true}, + {"wrong token", http.StatusOK, staticToken("nope"), nil, true}, + {"token source error", http.StatusOK, func() (string, error) { return "", errors.New("no file") }, nil, true}, + {"receipt for another record", http.StatusOK, staticToken("tok"), func(r *DecisionRecord) { r.Outcome = OutcomeDeny }, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := fakeWriterServer(t, tc.status, tc.mutate) + defer srv.Close() + rw, err := NewRemoteWriter(srv.URL, tc.token, time.Second) + if err != nil { + t.Fatal(err) + } + r, err := rw.Append(context.Background(), mustRecord(t, testParams())) + if (err != nil) != tc.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tc.wantErr) + } + if err == nil && r.Seq != 1 { + t.Fatalf("seq = %d", r.Seq) + } + }) + } +} + +func TestRemoteWriterTimeoutFailsClosed(t *testing.T) { + block := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-block })) + defer srv.Close() + defer close(block) + rw, err := NewRemoteWriter(srv.URL, staticToken("tok"), 50*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if _, err := rw.Append(context.Background(), mustRecord(t, testParams())); err == nil { + t.Fatal("append succeeded against a hung writer") + } + if rw.Ready(context.Background()) { + t.Fatal("hung writer reports ready") + } +} + +func TestRemoteWriterReady(t *testing.T) { + for _, tc := range []struct { + status int + want bool + }{{http.StatusOK, true}, {http.StatusServiceUnavailable, false}} { + srv := fakeWriterServer(t, tc.status, nil) + rw, _ := NewRemoteWriter(srv.URL, staticToken("tok"), time.Second) + if got := rw.Ready(context.Background()); got != tc.want { + t.Errorf("status %d: ready = %v, want %v", tc.status, got, tc.want) + } + srv.Close() + } + rw, _ := NewRemoteWriter("http://127.0.0.1:1", staticToken("tok"), 100*time.Millisecond) + if rw.Ready(context.Background()) { + t.Error("unreachable writer reports ready") + } +} + +func TestNewRemoteWriterValidates(t *testing.T) { + for _, u := range []string{"", "ftp://x", "http://", "not a url"} { + if _, err := NewRemoteWriter(u, staticToken("t"), time.Second); err == nil { + t.Errorf("URL %q accepted", u) + } + } + if _, err := NewRemoteWriter("http://w:8080", nil, time.Second); err == nil { + t.Error("nil token source accepted") + } +} + +func TestTokenFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "token") + if _, err := TokenFile(path)(); err == nil { + t.Fatal("missing token file accepted") + } + _ = os.WriteFile(path, []byte(" \n"), 0o600) + if _, err := TokenFile(path)(); err == nil { + t.Fatal("empty token accepted") + } + _ = os.WriteFile(path, []byte("abc\n"), 0o600) + if tok, err := TokenFile(path)(); err != nil || tok != "abc" { + t.Fatalf("token = %q, %v", tok, err) + } +} diff --git a/pkg/receipts/writer.go b/pkg/receipts/writer.go new file mode 100644 index 0000000..e5061ca --- /dev/null +++ b/pkg/receipts/writer.go @@ -0,0 +1,24 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "context" + "errors" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +// Writer appends decision receipts. Append must store the receipt durably +// before it returns, because the caller acts on it. +type Writer interface { + Append(ctx context.Context, rec DecisionRecord) (receiptspec.Receipt, error) + Ready(ctx context.Context) bool +} + +// ErrWriterBroken means a previous write failed part way. The writer refuses +// further appends until it is reopened, which repairs the files. +var ErrWriterBroken = errors.New("receipts: writer broken by an earlier write failure, reopen to repair") From 0b68d8401a3043a439a2c8ce3da349956c1dac5b Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 00:40:14 +0530 Subject: [PATCH 07/54] feat(receipts): write-ahead decision receipts on the direct path, agentctl export/verify, chart Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- .../templates/deployment.yaml | 27 +++ charts/templates/_helpers.tpl | 19 ++ charts/templates/networkpolicy.yaml | 16 ++ charts/templates/receipt-writer.yaml | 175 +++++++++++++++ charts/tests/receipts_test.yaml | 205 ++++++++++++++++++ charts/values.schema.json | 31 ++- charts/values.yaml | 38 ++++ cmd/agentctl/receipts.go | 133 ++++++++++++ cmd/agentctl/receipts_test.go | 158 ++++++++++++++ cmd/agentctl/root.go | 1 + cmd/main.go | 25 +++ cmd/main_test.go | 25 +++ .../controller/agentworkload_controller.go | 5 +- .../agentworkload_controller_receipts_test.go | 191 ++++++++++++++++ internal/controller/receipts.go | 108 +++++++++ pkg/invariants/invariants.go | 2 +- 16 files changed, 1156 insertions(+), 3 deletions(-) create mode 100644 charts/templates/receipt-writer.yaml create mode 100644 charts/tests/receipts_test.yaml create mode 100644 cmd/agentctl/receipts.go create mode 100644 cmd/agentctl/receipts_test.go create mode 100644 internal/controller/agentworkload_controller_receipts_test.go create mode 100644 internal/controller/receipts.go diff --git a/charts/charts/agentic-operator/templates/deployment.yaml b/charts/charts/agentic-operator/templates/deployment.yaml index 6041f7b..636b5e8 100644 --- a/charts/charts/agentic-operator/templates/deployment.yaml +++ b/charts/charts/agentic-operator/templates/deployment.yaml @@ -63,6 +63,20 @@ spec: value: {{ .enforcementMode | default "strict" | quote }} {{- end }} {{- end }} + {{- with (.Values.global).receipts }} + {{- if .enabled }} + - name: RECEIPTS_ENABLED + value: "true" + - name: RECEIPTS_REQUIRED + value: {{ .required | default false | quote }} + - name: RECEIPTS_WRITER_URL + value: {{ printf "http://%s.%s.svc:%d" (printf "%s-receipt-writer" $.Release.Name | trunc 63 | trimSuffix "-") $.Release.Namespace (int (.port | default 8080)) | quote }} + - name: RECEIPTS_WRITER_TOKEN_FILE + value: /etc/receipts/token/{{ (.token).key | default "token" }} + - name: RECEIPTS_WRITER_TIMEOUT + value: {{ .timeout | default "5s" | quote }} + {{- end }} + {{- end }} {{- range $key, $val := .Values.env }} - name: {{ $key }} value: {{ $val | quote }} @@ -108,11 +122,24 @@ spec: - name: webhook-certs mountPath: /tmp/k8s-webhook-server/serving-certs readOnly: true + {{- if ((.Values.global).receipts).enabled }} + - name: receipts-token + mountPath: /etc/receipts/token + readOnly: true + {{- end }} volumes: - name: webhook-certs secret: secretName: {{ printf "%s-agentic-operator-webhook-tls" .Release.Name | trunc 63 | trimSuffix "-" }} optional: true + {{- with (.Values.global).receipts }} + {{- if .enabled }} + - name: receipts-token + secret: + secretName: {{ (.token).existingSecret | default (printf "%s-receipt-writer-token" $.Release.Name | trunc 63 | trimSuffix "-") }} + defaultMode: 0444 + {{- end }} + {{- end }} securityContext: runAsNonRoot: true runAsUser: 65532 diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl index f253285..47c9076 100644 --- a/charts/templates/_helpers.tpl +++ b/charts/templates/_helpers.tpl @@ -91,3 +91,22 @@ Render a full image reference: [registry/]repo:tag {{- printf "%s:%s" $repo $tag -}} {{- end -}} {{- end }} + +{{/* +receipt-writer labels. Own app name so operator selectors never match it. +*/}} +{{- define "agentic-operator.receiptWriter.selectorLabels" -}} +app.kubernetes.io/name: receipt-writer +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "agentic-operator.receiptWriter.labels" -}} +helm.sh/chart: {{ include "agentic-operator.chart" . }} +{{ include "agentic-operator.receiptWriter.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: agentic-operator +app.kubernetes.io/component: receipt-writer +{{- end }} diff --git a/charts/templates/networkpolicy.yaml b/charts/templates/networkpolicy.yaml index 1785951..89ad522 100644 --- a/charts/templates/networkpolicy.yaml +++ b/charts/templates/networkpolicy.yaml @@ -118,6 +118,22 @@ spec: protocol: TCP {{- end }} {{- end }} + {{- with ($root.Values.global).receipts }} + {{- if .enabled }} + # receipt-writer, so the operator can store decision receipts. + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ $root.Release.Namespace }} + podSelector: + matchLabels: + app.kubernetes.io/name: receipt-writer + app.kubernetes.io/instance: {{ $root.Release.Name }} + ports: + - port: {{ .port | default 8080 }} + protocol: TCP + {{- end }} + {{- end }} {{- with $root.Values.networkPolicy.additionalAllowedHosts }} # Operator-supplied additional allow-list entries. {{- range . }} diff --git a/charts/templates/receipt-writer.yaml b/charts/templates/receipt-writer.yaml new file mode 100644 index 0000000..a87b712 --- /dev/null +++ b/charts/templates/receipt-writer.yaml @@ -0,0 +1,175 @@ +{{- /* +receipt-writer: owns the decision receipt signing key and chain. See +docs/receipts.md. Toggle with global.receipts.enabled (default false). +Runs in the release namespace, next to the operator Deployment. +The signing key must come from an existing Secret. The chart never makes one. +*/ -}} +{{- $r := .Values.global.receipts | default dict -}} +{{- if $r.enabled -}} +{{- if not ($r.signingKey).existingSecret -}} +{{- fail "global.receipts.signingKey.existingSecret is required when global.receipts.enabled=true. The chart never generates the signing key. See docs/receipts.md." -}} +{{- end -}} +{{- $name := printf "%s-receipt-writer" .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- $ns := .Release.Namespace -}} +{{- $port := int ($r.port | default 8080) -}} +{{- $tokenSecret := ($r.token).existingSecret | default (printf "%s-receipt-writer-token" .Release.Name | trunc 63 | trimSuffix "-") -}} +{{- $tokenKey := ($r.token).key | default "token" -}} +{{- $keyKey := ($r.signingKey).key | default "signing-key" -}} +{{- if not ($r.token).existingSecret }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $tokenSecret }} + namespace: {{ $ns }} + labels: + {{- include "agentic-operator.receiptWriter.labels" . | nindent 4 }} +type: Opaque +data: + {{- $existing := lookup "v1" "Secret" $ns $tokenSecret }} + {{- if and $existing (index ($existing.data | default dict) $tokenKey) }} + {{ $tokenKey }}: {{ index $existing.data $tokenKey }} + {{- else }} + {{ $tokenKey }}: {{ randAlphaNum 48 | b64enc }} + {{- end }} +{{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + namespace: {{ $ns }} + labels: + {{- include "agentic-operator.receiptWriter.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "agentic-operator.receiptWriter.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ $port }} + targetPort: http + protocol: TCP +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $name }} + namespace: {{ $ns }} + labels: + {{- include "agentic-operator.receiptWriter.labels" . | nindent 4 }} +spec: + # One writer per chain. Never scale above 1. + replicas: 1 + serviceName: {{ $name }} + selector: + matchLabels: + {{- include "agentic-operator.receiptWriter.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "agentic-operator.receiptWriter.labels" . | nindent 8 }} + spec: + automountServiceAccountToken: false + {{- include "agentic-operator.imagePullSecrets" . | nindent 6 }} + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: receipt-writer + image: "{{ ($r.image).repository }}:{{ ($r.image).tag }}" + imagePullPolicy: {{ ($r.image).pullPolicy | default "IfNotPresent" }} + env: + - name: RECEIPT_ADDR + value: ":{{ $port }}" + - name: RECEIPT_DATA_DIR + value: /data + - name: RECEIPT_KEY_FILE + value: /etc/receipt-writer/key/{{ $keyKey }} + - name: RECEIPT_TOKEN_FILE + value: /etc/receipt-writer/token/{{ $tokenKey }} + ports: + - name: http + containerPort: {{ $port }} + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + resources: + {{- toYaml ($r.resources | default dict) | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: data + mountPath: /data + - name: signing-key + mountPath: /etc/receipt-writer/key + readOnly: true + - name: token + mountPath: /etc/receipt-writer/token + readOnly: true + volumes: + - name: signing-key + secret: + secretName: {{ $r.signingKey.existingSecret }} + defaultMode: 0440 + - name: token + secret: + secretName: {{ $tokenSecret }} + defaultMode: 0440 + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- $sc := ($r.storage).storageClass | default .Values.global.storageClass }} + {{- if $sc }} + storageClassName: {{ $sc }} + {{- end }} + resources: + requests: + storage: {{ ($r.storage).size | default "1Gi" }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ $name }} + namespace: {{ $ns }} + labels: + {{- include "agentic-operator.receiptWriter.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "agentic-operator.receiptWriter.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + # Only operator pods may call the writer. The writer needs no egress. + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: agentic-operator + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: operator + ports: + - port: {{ $port }} + protocol: TCP + egress: [] +{{- end }} diff --git a/charts/tests/receipts_test.yaml b/charts/tests/receipts_test.yaml new file mode 100644 index 0000000..3ae77b1 --- /dev/null +++ b/charts/tests/receipts_test.yaml @@ -0,0 +1,205 @@ +# helm-unittest spec for signed decision receipts (docs/receipts.md). +# Run with: helm unittest charts +suite: decision receipts +templates: + - receipt-writer.yaml + - networkpolicy.yaml + - charts/agentic-operator/templates/deployment.yaml +tests: + - it: renders nothing for receipts by default + set: + license: + key: "test" + asserts: + - hasDocuments: + count: 0 + template: receipt-writer.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: RECEIPTS_ENABLED + value: "true" + template: charts/agentic-operator/templates/deployment.yaml + + - it: fails without a signing key secret + set: + license: + key: "test" + global: + receipts: + enabled: true + asserts: + - failedTemplate: + errorPattern: "signingKey.existingSecret is required" + template: receipt-writer.yaml + + - it: renders a hardened single-replica writer with a PVC + release: + name: rel + namespace: agentic-system + set: + license: + key: "test" + global: + receipts: + enabled: true + signingKey: + existingSecret: receipt-signing-key + asserts: + - hasDocuments: + count: 4 + template: receipt-writer.yaml + - isKind: + of: StatefulSet + documentIndex: 2 + template: receipt-writer.yaml + - equal: + path: spec.replicas + value: 1 + documentIndex: 2 + template: receipt-writer.yaml + - equal: + path: spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem + value: true + documentIndex: 2 + template: receipt-writer.yaml + - equal: + path: spec.template.spec.securityContext.runAsNonRoot + value: true + documentIndex: 2 + template: receipt-writer.yaml + - equal: + path: spec.volumeClaimTemplates[0].spec.resources.requests.storage + value: 1Gi + documentIndex: 2 + template: receipt-writer.yaml + - contains: + path: spec.template.spec.volumes + content: + name: signing-key + secret: + secretName: receipt-signing-key + defaultMode: 0440 + documentIndex: 2 + template: receipt-writer.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: RECEIPT_KEY_GENERATE + value: "true" + documentIndex: 2 + template: receipt-writer.yaml + - isKind: + of: Service + documentIndex: 1 + template: receipt-writer.yaml + - equal: + path: spec.type + value: ClusterIP + documentIndex: 1 + template: receipt-writer.yaml + + - it: only lets operator pods reach the writer + release: + name: rel + set: + license: + key: "test" + global: + receipts: + enabled: true + signingKey: + existingSecret: receipt-signing-key + asserts: + - isKind: + of: NetworkPolicy + documentIndex: 3 + template: receipt-writer.yaml + - equal: + path: spec.ingress[0].from[0].podSelector.matchLabels + value: + app.kubernetes.io/name: agentic-operator + app.kubernetes.io/instance: rel + app.kubernetes.io/component: operator + documentIndex: 3 + template: receipt-writer.yaml + - equal: + path: spec.egress + value: [] + documentIndex: 3 + template: receipt-writer.yaml + - contains: + path: spec.egress + content: + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: NAMESPACE + podSelector: + matchLabels: + app.kubernetes.io/name: receipt-writer + app.kubernetes.io/instance: rel + ports: + - port: 8080 + protocol: TCP + template: networkpolicy.yaml + + - it: uses an existing token secret and creates none + release: + name: rel + set: + license: + key: "test" + global: + receipts: + enabled: true + signingKey: + existingSecret: receipt-signing-key + token: + existingSecret: my-token + key: bearer + asserts: + - hasDocuments: + count: 3 + template: receipt-writer.yaml + - contains: + path: spec.template.spec.volumes + content: + name: receipts-token + secret: + secretName: my-token + defaultMode: 0444 + template: charts/agentic-operator/templates/deployment.yaml + + - it: wires the operator to the writer + release: + name: rel + namespace: agentic-system + set: + license: + key: "test" + global: + receipts: + enabled: true + required: true + signingKey: + existingSecret: receipt-signing-key + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: RECEIPTS_REQUIRED + value: "true" + template: charts/agentic-operator/templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: RECEIPTS_WRITER_URL + value: http://rel-receipt-writer.agentic-system.svc:8080 + template: charts/agentic-operator/templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: RECEIPTS_WRITER_TOKEN_FILE + value: /etc/receipts/token/token + template: charts/agentic-operator/templates/deployment.yaml diff --git a/charts/values.schema.json b/charts/values.schema.json index 965075b..2df0f4a 100644 --- a/charts/values.schema.json +++ b/charts/values.schema.json @@ -27,7 +27,8 @@ }, "storageClass": { "type": "string" }, "namespace": { "type": "string" }, - "runtimeSandbox": { "$ref": "#/$defs/runtimeSandbox" } + "runtimeSandbox": { "$ref": "#/$defs/runtimeSandbox" }, + "receipts": { "$ref": "#/$defs/receipts" } } }, "license": { @@ -337,6 +338,34 @@ "overhead": { "type": "object" }, "scheduling": { "type": "object" } } + }, + "receipts": { + "type": "object", + "description": "Signed decision receipts and the receipt-writer StatefulSet. See docs/receipts.md.", + "properties": { + "enabled": { "type": "boolean" }, + "required": { "type": "boolean" }, + "image": { "$ref": "#/$defs/image" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "timeout": { "type": "string" }, + "storage": { + "type": "object", + "properties": { + "size": { "type": "string" }, + "storageClass": { "type": "string" } + } + }, + "token": { "$ref": "#/$defs/secretRef" }, + "signingKey": { "$ref": "#/$defs/secretRef" }, + "resources": { "$ref": "#/$defs/resources" } + } + }, + "secretRef": { + "type": "object", + "properties": { + "existingSecret": { "type": "string" }, + "key": { "type": "string", "minLength": 1 } + } } } } diff --git a/charts/values.yaml b/charts/values.yaml index a003cb7..c6909ea 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -48,6 +48,44 @@ global: enforcementMode: strict overhead: {} scheduling: {} + # Signed decision receipts (docs/receipts.md). Lives under global because + # the operator Deployment is a subchart and reads it to get its env. + # When enabled, the chart runs a receipt-writer StatefulSet in the release + # namespace and points the operator at it. + receipts: + enabled: false + # required: every action is denied (INV-05) unless its receipt is stored + # first. false: a failed receipt is logged and the action still runs. + required: false + image: + repository: ghcr.io/clawdlinux/agentic-operator-core/receipt-writer + tag: "latest" + pullPolicy: Always + port: 8080 + # Operator side HTTP timeout per receipt. + timeout: 5s + storage: + size: 1Gi + storageClass: "" + # Bearer token shared by operator and writer. Empty existingSecret: the + # chart creates -receipt-writer-token once and keeps it. + token: + existingSecret: "" + key: token + # Ed25519 signing key, hex seed. REQUIRED when enabled. The chart never + # generates it. Create it yourself and keep a copy offline: + # kubectl create secret generic receipt-signing-key \ + # --from-literal=signing-key=$(openssl rand -hex 32) + signingKey: + existingSecret: "" + key: signing-key + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "500m" + memory: "256Mi" # --------------------------------------------------------------------------- # License (REQUIRED) diff --git a/cmd/agentctl/receipts.go b/cmd/agentctl/receipts.go new file mode 100644 index 0000000..4f160eb --- /dev/null +++ b/cmd/agentctl/receipts.go @@ -0,0 +1,133 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +// errVerifyFailed makes agentctl exit nonzero after FAIL lines are printed. +var errVerifyFailed = errors.New("receipt verification failed") + +func newReceiptsCommand(opts *cliOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "receipts", + Short: "Export and verify signed decision receipts", + // Receipts work offline. Do not load a kubeconfig. + PersistentPreRunE: func(*cobra.Command, []string) error { return opts.validateOutput() }, + } + cmd.AddCommand(newReceiptsExportCommand(), newReceiptsVerifyCommand()) + return cmd +} + +func newReceiptsExportCommand() *cobra.Command { + var writer, tokenFile, out string + var timeout time.Duration + cmd := &cobra.Command{ + Use: "export", + Short: "Download the receipt chain, trust root, and decision records", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + rw, err := receipts.NewRemoteWriter(writer, receipts.TokenFile(tokenFile), timeout) + if err != nil { + return err + } + e, err := rw.Export(cmd.Context()) + if err != nil { + return err + } + if err := e.WriteDir(out); err != nil { + return err + } + b, err := receiptspec.ReadJSONL(bytes.NewReader([]byte(e.ReceiptsJSONL))) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "exported %d receipts to %s (%s, %s, %s)\n", + len(b.Receipts), out, receipts.ReceiptsFile, receipts.TrustFile, receipts.RecordsFile) + return nil + }, + } + cmd.Flags().StringVar(&writer, "writer", "", "receipt-writer base URL, for example http://localhost:8080") + cmd.Flags().StringVar(&tokenFile, "token-file", "", "file holding the receipt-writer bearer token") + cmd.Flags().StringVar(&out, "out", "", "output directory") + cmd.Flags().DurationVar(&timeout, "timeout", 60*time.Second, "HTTP timeout") + for _, f := range []string{"writer", "token-file", "out"} { + _ = cmd.MarkFlagRequired(f) + } + return cmd +} + +func newReceiptsVerifyCommand() *cobra.Command { + var trustRoot string + cmd := &cobra.Command{ + Use: "verify ", + Short: "Verify a receipt export offline", + Long: "Verifies the receipt chain with the same check agentgate-verify runs, then checks that every " + + "decision record binds to its receipt. Pass --trust-root with a trust file pinned out of band; " + + "otherwise the export's own trust.json or embedded keys are used.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var trusted []receiptspec.TrustedKey + if trustRoot != "" { + data, err := os.ReadFile(trustRoot) + if err != nil { + return err + } + if trusted, err = receiptspec.LoadTrustedKeys(data); err != nil { + return err + } + } + e, err := receipts.ReadExport(args[0]) + if err != nil { + return err + } + stdout, stderr := cmd.OutOrStdout(), cmd.ErrOrStderr() + rep, err := receipts.VerifyExport(e, trusted) + if receipts.IsManifestError(err) { + fmt.Fprintf(stderr, "FAIL: manifest: %v\n", err) + return errVerifyFailed + } + if err != nil { + return err + } + c := rep.Chain + if c.OK { + fmt.Fprintf(stdout, "PASS: %d receipts verified, head seq=%d hash=%x\n", c.VerifiedCount, c.HeadSeq, c.HeadEntryHash[:8]) + if c.Complete { + fmt.Fprintln(stdout, "completeness: proven against the signed export manifest") + } else { + fmt.Fprintln(stdout, "completeness: not claimed") + } + } else { + fmt.Fprintf(stderr, "FAIL: seq=%d reason=%s (%d of %d receipts verified before failure)\n", + c.FailedAtSeq, c.Reason, c.VerifiedCount, c.TotalReceipts) + } + for _, f := range rep.RecordFailures { + fmt.Fprintf(stderr, "FAIL: record seq=%d reason=%s\n", f.Seq, f.Reason) + } + if len(rep.RecordFailures) == 0 { + fmt.Fprintf(stdout, "PASS: %d decision records bound to their receipts\n", rep.RecordsBound) + } + if !rep.OK() { + return errVerifyFailed + } + return nil + }, + } + cmd.Flags().StringVar(&trustRoot, "trust-root", "", "trust file pinned out of band (receiptspec trust file format)") + return cmd +} diff --git a/cmd/agentctl/receipts_test.go b/cmd/agentctl/receipts_test.go new file mode 100644 index 0000000..b97f33d --- /dev/null +++ b/cmd/agentctl/receipts_test.go @@ -0,0 +1,158 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +func receiptExport(t *testing.T, n int) receipts.Export { + t.Helper() + w, err := receipts.OpenLocal(t.TempDir(), ed25519.NewKeyFromSeed(bytes.Repeat([]byte{3}, 32))) + if err != nil { + t.Fatal(err) + } + defer func() { _ = w.Close() }() + for i := 0; i < n; i++ { + rec, err := receipts.NewDecisionRecord(receipts.Params{ + Workload: receipts.Workload{Namespace: "ns", Name: "wl", UID: "uid"}, + Action: "scale", + Layers: decision.Layers{ThresholdAllowed: true}, + Result: decision.Result{Outcome: decision.Allow, Layer: decision.LayerThreshold}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := w.Append(context.Background(), rec); err != nil { + t.Fatal(err) + } + } + e, err := w.Export() + if err != nil { + t.Fatal(err) + } + return e +} + +func runAgentctl(t *testing.T, args ...string) (string, string, error) { + t.Helper() + cmd := newRootCommand() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), errOut.String(), err +} + +func TestReceiptsVerify(t *testing.T) { + otherKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{9}, 32)) + otherTrust, _ := receipts.TrustRootJSON(receiptspec.TrustedKey{KID: receiptspec.ComputeKID(otherKey.Public().(ed25519.PublicKey)), PublicKey: otherKey.Public().(ed25519.PublicKey)}) + + tests := []struct { + name string + mutate func(*receipts.Export) + bundle bool + trustRoot []byte + wantOut string + wantErr string + }{ + {name: "dir passes", wantOut: "PASS: 3 receipts verified"}, + {name: "bundle file passes", bundle: true, wantOut: "PASS: 3 decision records bound"}, + {name: "tampered record fails", mutate: func(e *receipts.Export) { + e.RecordsJSONL = strings.Replace(e.RecordsJSONL, `"scale"`, `"scalf"`, 1) + }, wantErr: "FAIL: record seq=1 reason=record_hash_mismatch"}, + {name: "tampered receipt fails", mutate: func(e *receipts.Export) { + e.ReceiptsJSONL = strings.Replace(e.ReceiptsJSONL, `"status_code":200`, `"status_code":201`, 1) + }, wantErr: "FAIL: seq=1 reason=entry_hash_mismatch"}, + {name: "missing record fails", mutate: func(e *receipts.Export) { + lines := strings.SplitAfter(e.RecordsJSONL, "\n") + e.RecordsJSONL = strings.Join(lines[1:], "") + }, wantErr: "FAIL: record seq=1 reason=record_missing"}, + {name: "pinned trust root mismatch fails", trustRoot: otherTrust, wantErr: "FAIL: manifest"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + e := receiptExport(t, 3) + if tc.mutate != nil { + tc.mutate(&e) + } + dir := t.TempDir() + target := filepath.Join(dir, "export") + if tc.bundle { + target = filepath.Join(dir, "bundle.json") + data, _ := json.Marshal(e) + _ = os.WriteFile(target, data, 0o600) + } else if err := e.WriteDir(target); err != nil { + t.Fatal(err) + } + args := []string{"receipts", "verify", target} + if tc.trustRoot != nil { + trustPath := filepath.Join(dir, "pinned.json") + _ = os.WriteFile(trustPath, tc.trustRoot, 0o600) + args = append(args, "--trust-root", trustPath) + } + out, errOut, err := runAgentctl(t, args...) + if tc.wantErr != "" { + if !errors.Is(err, errVerifyFailed) || !strings.Contains(errOut, tc.wantErr) { + t.Fatalf("err = %v, stderr = %q, want %q", err, errOut, tc.wantErr) + } + return + } + if err != nil || !strings.Contains(out, tc.wantOut) { + t.Fatalf("err = %v, stdout = %q, stderr = %q", err, out, errOut) + } + }) + } +} + +func TestReceiptsExport(t *testing.T) { + e := receiptExport(t, 2) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/export" || r.Header.Get("Authorization") != "Bearer secret-token" { + http.Error(w, "no", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(e) + })) + defer srv.Close() + + dir := t.TempDir() + tokenFile := filepath.Join(dir, "token") + _ = os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600) + out := filepath.Join(dir, "out") + + stdout, _, err := runAgentctl(t, "receipts", "export", "--writer", srv.URL, "--token-file", tokenFile, "--out", out) + if err != nil || !strings.Contains(stdout, "exported 2 receipts") { + t.Fatalf("export: %v %q", err, stdout) + } + if stdout, _, err := runAgentctl(t, "receipts", "verify", out); err != nil || !strings.Contains(stdout, "PASS: 2 receipts verified") { + t.Fatalf("verify exported dir: %v %q", err, stdout) + } + + _ = os.WriteFile(tokenFile, []byte("wrong\n"), 0o600) + if _, _, err := runAgentctl(t, "receipts", "export", "--writer", srv.URL, "--token-file", tokenFile, "--out", out); err == nil { + t.Fatal("export with a wrong token succeeded") + } + if _, _, err := runAgentctl(t, "receipts", "export", "--writer", srv.URL); err == nil { + t.Fatal("export without required flags succeeded") + } +} diff --git a/cmd/agentctl/root.go b/cmd/agentctl/root.go index 1f83187..1e48f6c 100644 --- a/cmd/agentctl/root.go +++ b/cmd/agentctl/root.go @@ -112,6 +112,7 @@ func newRootCommand() *cobra.Command { cmd.AddCommand(newWorkflowsCommand(opts)) cmd.AddCommand(newStatusCommand(opts)) cmd.AddCommand(newMCPCommand(opts)) + cmd.AddCommand(newReceiptsCommand(opts)) return cmd } diff --git a/cmd/main.go b/cmd/main.go index 929527e..558f78c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -62,6 +62,7 @@ import ( "github.com/Clawdlinux/agentic-operator-core/pkg/governance" "github.com/Clawdlinux/agentic-operator-core/pkg/multitenancy" "github.com/Clawdlinux/agentic-operator-core/pkg/otel/genai" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" // +kubebuilder:scaffold:imports ) @@ -284,6 +285,12 @@ func main() { workloadReconciler.TenantRes = tenantResolver // Phase 7: Tenant isolation workloadReconciler.SandboxClass = sandboxConfig.RuntimeClassName workloadReconciler.Recorder = mgr.GetEventRecorder("agentworkload-controller") + receiptsCfg, err := receipts.ConfigFromEnv(os.Getenv) + if err != nil { + setupLog.Error(err, "Invalid receipts configuration") + os.Exit(1) + } + workloadReconciler.Receipts = buildReceiptsConfig(receiptsCfg, setupLog) if err := workloadReconciler.SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "AgentWorkload") @@ -357,6 +364,24 @@ func (f runnableFunc) Start(ctx context.Context) error { return f(ctx) } +// buildReceiptsConfig turns env config into controller config. A writer that +// cannot be built stays nil: with RECEIPTS_REQUIRED every action is then +// denied by INV-05 instead of the operator refusing to start. +func buildReceiptsConfig(cfg receipts.Config, logger logr.Logger) controller.ReceiptsConfig { + out := controller.ReceiptsConfig{Enabled: cfg.Enabled, Required: cfg.Required} + if !cfg.Enabled { + return out + } + w, err := cfg.NewWriter() + if err != nil { + logger.Error(err, "Receipt writer misconfigured", "required", cfg.Required) + return out + } + out.Writer = w + logger.Info("Decision receipts enabled", "writer", cfg.WriterURL, "required", cfg.Required) + return out +} + func registerFinOpsMetrics(registerer prometheus.Registerer, reporter *finops.MemoryCostReporter) error { if registerer == nil || reporter == nil { return nil diff --git a/cmd/main_test.go b/cmd/main_test.go index 57a52ca..946788e 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -8,9 +8,11 @@ import ( "strings" "testing" + "github.com/go-logr/logr" "github.com/prometheus/client_golang/prometheus" "github.com/Clawdlinux/agentic-operator-core/pkg/finops" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" ) func TestFinOpsMetricsEndpointContainsClawdlinuxCostMetric(t *testing.T) { @@ -51,3 +53,26 @@ func TestFinOpsMetricsEndpointContainsClawdlinuxCostMetric(t *testing.T) { t.Fatalf("metrics body missing namespace label:\n%s", body) } } + +func TestBuildReceiptsConfig(t *testing.T) { + tests := []struct { + name string + cfg receipts.Config + wantWriter bool + }{ + {"disabled", receipts.Config{}, false}, + {"enabled without token file", receipts.Config{Enabled: true, Required: true, WriterURL: "http://w:8080"}, false}, + {"enabled", receipts.Config{Enabled: true, WriterURL: "http://w:8080", TokenFile: "/var/run/token"}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := buildReceiptsConfig(tc.cfg, logr.Discard()) + if got.Enabled != tc.cfg.Enabled || got.Required != tc.cfg.Required { + t.Fatalf("flags = %+v", got) + } + if (got.Writer != nil) != tc.wantWriter { + t.Fatalf("writer = %v, want present %v", got.Writer, tc.wantWriter) + } + }) + } +} diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index 388ea25..75d2980 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -106,6 +106,7 @@ type AgentWorkloadReconciler struct { RuntimeRegistry *runtimeadapter.Registry // runtime adapter registry; nil lazily defaults to Argo SandboxClass string // RuntimeClass required for sandbox enforcement Recorder events.EventRecorder // Optional Kubernetes event recorder + Receipts ReceiptsConfig // Decision receipts; zero value disables them } type quotaChecker interface { @@ -626,7 +627,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Layers run in order: invariants, policy packs, threshold. Decide keeps // the strictest outcome, so no layer or claim can loosen another. layers := decision.Layers{ThresholdAllowed: opaResult.Allowed, ThresholdReasons: opaResult.Reasons} - for _, res := range invariants.Check(decisionInput, invariants.Context{}) { + for _, res := range invariants.Check(decisionInput, r.receiptContext(ctx)) { layers.Invariants = append(layers.Invariants, res.String()) } if len(workload.Spec.PolicyPacks) > 0 { @@ -644,6 +645,8 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques } } result := decision.Decide(layers) + // Write-ahead: the receipt is stored before the action can run. + result = r.recordDecision(ctx, &workload, actionName, decisionInput, layers, result, opaPolicyMode) log.Info("decision", "outcome", result.Outcome, "layer", result.Layer, "destination", decisionInput.Observed.Destination, "dataClasses", decisionInput.Observed.DataClasses, "reasons", result.Reasons) // Step 5: Handle action execution or approval pending diff --git a/internal/controller/agentworkload_controller_receipts_test.go b/internal/controller/agentworkload_controller_receipts_test.go new file mode 100644 index 0000000..0bfde3d --- /dev/null +++ b/internal/controller/agentworkload_controller_receipts_test.go @@ -0,0 +1,191 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +// eventLog records MCP tool calls and receipt appends in order. +type eventLog struct { + mu sync.Mutex + events []string +} + +func (l *eventLog) add(s string) { + l.mu.Lock() + defer l.mu.Unlock() + l.events = append(l.events, s) +} + +func (l *eventLog) list() []string { + l.mu.Lock() + defer l.mu.Unlock() + return append([]string{}, l.events...) +} + +type fakeReceiptWriter struct { + log *eventLog + ready bool + appendErr error + records []receipts.DecisionRecord +} + +func (f *fakeReceiptWriter) Append(_ context.Context, rec receipts.DecisionRecord) (receiptspec.Receipt, error) { + f.log.add("receipt") + if f.appendErr != nil { + return receiptspec.Receipt{}, f.appendErr + } + f.records = append(f.records, rec) + return receiptspec.Receipt{Seq: uint64(len(f.records))}, nil +} + +func (f *fakeReceiptWriter) Ready(context.Context) bool { return f.ready } + +// newLoggingMCPServer wraps the mock MCP server and logs each tool call. +func newLoggingMCPServer(t *testing.T, scenario mockMCPScenario, log *eventLog) *httptest.Server { + t.Helper() + inner := newMockMCPServer(scenario) + t.Cleanup(inner.Close) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req mockMCPRequest + _ = json.Unmarshal(body, &req) + log.add(req.Tool) + r.Body = io.NopCloser(bytes.NewReader(body)) + inner.Config.Handler.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestReconcile_DecisionReceipts(t *testing.T) { + t.Parallel() + + appendFailed := errors.New("writer down") + tests := []struct { + name string + confidence string + enabled bool + required bool + nilWriter bool + ready bool + appendErr error + wantPhase string + wantExecute bool + wantReceipts int + wantOutcome string + wantReason string + }{ + {name: "disabled changes nothing", confidence: "0.98", ready: true, wantPhase: "Completed", wantExecute: true}, + {name: "allow writes receipt before execute", confidence: "0.98", enabled: true, ready: true, wantPhase: "Completed", wantExecute: true, wantReceipts: 1, wantOutcome: receipts.OutcomeAllow}, + {name: "required writer unavailable denies", confidence: "0.98", enabled: true, required: true, ready: false, appendErr: appendFailed, wantPhase: "PolicyDenied", wantReason: "INV-05"}, + {name: "required append failure denies", confidence: "0.98", enabled: true, required: true, ready: true, appendErr: appendFailed, wantPhase: "PolicyDenied", wantReason: "INV-05"}, + {name: "required nil writer denies", confidence: "0.98", enabled: true, required: true, nilWriter: true, wantPhase: "PolicyDenied", wantReason: "INV-05"}, + {name: "optional append failure proceeds", confidence: "0.98", enabled: true, ready: false, appendErr: appendFailed, wantPhase: "Completed", wantExecute: true}, + {name: "deny is receipted too", confidence: "0.82", enabled: true, required: true, ready: true, wantPhase: "PolicyDenied", wantReceipts: 1, wantOutcome: receipts.OutcomeDeny}, + } + for i, tc := range tests { + tc, i := tc, i + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + scheme := newControllerTestScheme(t) + log := &eventLog{} + server := newLoggingMCPServer(t, mockMCPScenario{confidence: tc.confidence, clusterHealth: 90.0}, log) + + objective := "optimize resources" + policy := "strict" + name := "receipts-" + string(rune('a'+i)) + workload := &agenticv1alpha1.AgentWorkload{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", UID: types.UID("uid-" + name)}, + Spec: agenticv1alpha1.AgentWorkloadSpec{ + MCPServerEndpoint: &server.URL, + Objective: &objective, + OPAPolicy: &policy, + }, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&agenticv1alpha1.AgentWorkload{}). + WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, workload). + Build() + + writer := &fakeReceiptWriter{log: log, ready: tc.ready, appendErr: tc.appendErr} + cfg := ReceiptsConfig{Enabled: tc.enabled, Required: tc.required, Writer: writer} + if tc.nilWriter { + cfg.Writer = nil + } + reconciler := &AgentWorkloadReconciler{Client: k8sClient, Scheme: scheme, Receipts: cfg} + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: "default"}}); err != nil { + t.Fatalf("reconcile returned error: %v", err) + } + + events := log.list() + executeAt := slices.Index(events, "execute_action") + if (executeAt >= 0) != tc.wantExecute { + t.Fatalf("execute_action called = %v, want %v; events %v", executeAt >= 0, tc.wantExecute, events) + } + if !tc.enabled && slices.Contains(events, "receipt") { + t.Fatalf("disabled receipts wrote a receipt: %v", events) + } + if tc.enabled && !tc.nilWriter { + receiptAt := slices.Index(events, "receipt") + if receiptAt < 0 { + t.Fatalf("no receipt append attempted: %v", events) + } + if executeAt >= 0 && receiptAt > executeAt { + t.Fatalf("receipt written after execute_action: %v", events) + } + } + if len(writer.records) != tc.wantReceipts { + t.Fatalf("receipts = %d, want %d", len(writer.records), tc.wantReceipts) + } + if tc.wantReceipts > 0 { + rec := writer.records[0] + if rec.Outcome != tc.wantOutcome || rec.Workload.UID != "uid-"+name || rec.ThresholdMode != "strict" { + t.Fatalf("record = %+v", rec) + } + } + + updated := &agenticv1alpha1.AgentWorkload{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, updated); err != nil { + t.Fatalf("fetch updated workload: %v", err) + } + if updated.Status.Phase != tc.wantPhase { + t.Fatalf("phase = %q, want %q", updated.Status.Phase, tc.wantPhase) + } + if tc.wantReason != "" { + condition := findCondition(updated.Status.Conditions, "PolicyDenied") + if condition == nil || !strings.Contains(condition.Message, tc.wantReason) { + t.Fatalf("PolicyDenied condition = %#v, want %q", condition, tc.wantReason) + } + } + }) + } +} diff --git a/internal/controller/receipts.go b/internal/controller/receipts.go new file mode 100644 index 0000000..1c36f7a --- /dev/null +++ b/internal/controller/receipts.go @@ -0,0 +1,108 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "context" + "errors" + "slices" + + logf "sigs.k8s.io/controller-runtime/pkg/log" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + decisioninput "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" + "github.com/Clawdlinux/agentic-operator-core/pkg/invariants" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +// ReceiptsConfig controls decision receipts on the direct action path. The +// zero value disables them and changes nothing. +type ReceiptsConfig struct { + Enabled bool + // Required fails closed (INV-05) when the writer is not ready or an + // append fails. Without it, a failed append is logged and the action runs. + Required bool + // Writer is nil when misconfigured. With Required set that denies. + Writer receipts.Writer +} + +// receiptContext tells invariants whether receipts are required and a writer +// is ready. +func (r *AgentWorkloadReconciler) receiptContext(ctx context.Context) invariants.Context { + if !r.Receipts.Enabled { + return invariants.Context{} + } + return invariants.Context{ + ReceiptsRequired: r.Receipts.Required, + ReceiptWriterAvailable: r.Receipts.Writer != nil && r.Receipts.Writer.Ready(ctx), + } +} + +// recordDecision appends the decision receipt before the action runs +// (write-ahead). If receipts are required and the append fails, it adds the +// INV-05 finding and returns the re-decided, denied result. +func (r *AgentWorkloadReconciler) recordDecision( + ctx context.Context, + wl *agenticv1alpha1.AgentWorkload, + action string, + in decisioninput.Input, + layers decision.Layers, + result decision.Result, + thresholdMode string, +) decision.Result { + if !r.Receipts.Enabled { + return result + } + log := logf.FromContext(ctx) + err := r.appendReceipt(ctx, wl, action, in, layers, result, thresholdMode) + if err == nil { + return result + } + if !r.Receipts.Required { + log.Error(err, "decision receipt not written; receipts not required, continuing", "action", action, "outcome", result.Outcome) + return result + } + log.Error(err, "decision receipt not written; receipts required, failing closed", "action", action) + for _, res := range invariants.Check(in, invariants.Context{ReceiptsRequired: true}) { + if res.ID == invariants.ReceiptsOrFailClosed && !slices.Contains(layers.Invariants, res.String()) { + layers.Invariants = append(layers.Invariants, res.String()) + } + } + return decision.Decide(layers) +} + +func (r *AgentWorkloadReconciler) appendReceipt( + ctx context.Context, + wl *agenticv1alpha1.AgentWorkload, + action string, + in decisioninput.Input, + layers decision.Layers, + result decision.Result, + thresholdMode string, +) error { + if r.Receipts.Writer == nil { + return errors.New("no receipt writer configured") + } + rec, err := receipts.NewDecisionRecord(receipts.Params{ + Workload: receipts.Workload{Namespace: wl.Namespace, Name: wl.Name, UID: string(wl.UID)}, + Action: action, + Input: in, + Layers: layers, + Result: result, + PolicyPacks: wl.Spec.PolicyPacks, + ThresholdMode: thresholdMode, + }) + if err != nil { + return err + } + rc, err := r.Receipts.Writer.Append(ctx, rec) + if err != nil { + return err + } + logf.FromContext(ctx).Info("decision receipt written", "seq", rc.Seq, "outcome", rec.Outcome, "layer", rec.Layer) + return nil +} diff --git a/pkg/invariants/invariants.go b/pkg/invariants/invariants.go index 7d53e66..484529a 100644 --- a/pkg/invariants/invariants.go +++ b/pkg/invariants/invariants.go @@ -135,7 +135,7 @@ func dataClassSubset(in input.Input, _ Context) string { } // receipts is INV-05. When receipts are required, a missing receipt writer -// fails closed. ReceiptsRequired defaults to false until receipts ship. +// fails closed. ReceiptsRequired comes from RECEIPTS_REQUIRED, default false. func receipts(_ input.Input, ctx Context) string { if ctx.ReceiptsRequired && !ctx.ReceiptWriterAvailable { return "receipts required but no receipt writer available" From 8c0faeaee7c8245789214bcba03a1e8965092705 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 00:40:14 +0530 Subject: [PATCH 08/54] docs(receipts): document receipts, fail-closed semantics, and gaps Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- CHANGELOG.md | 1 + README.md | 1 + ROADMAP.md | 2 + docs/07-security.md | 2 + docs/architecture/decision-architecture.md | 12 +- docs/architecture/invariants.md | 2 +- docs/receipts.md | 177 +++++++++++++++++++++ 7 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 docs/receipts.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 314d82d..e0f2e86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `agentctl doctor sandbox` to verify RuntimeClass and ready-node sandbox evidence before workload deployment. - Added optional `spec.declaredIntent` on `AgentWorkload` and observed decision inputs. The legacy action path now denies or escalates actions that send undeclared data classes or call undeclared hosts. Agent-claimed confidence and cluster health can only tighten. New packages: `pkg/dataclass`, `pkg/decision`, `pkg/decision/input`, `pkg/decision/observe`. See `docs/policy-input.md`. - Added invariants (`pkg/invariants`, INV-01 to INV-05), an embedded OPA rule engine (`pkg/rules/engine`, OPA v1.19.1), and opt-in policy packs `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` via `spec.policyPacks`. Packs only add deny or require approval. Packs are engineering controls, not legal advice. The `credential` data class is now detected and always denied (INV-01), even without `declaredIntent`. Direct action path only. See `docs/policy-packs.md`. +- Added opt-in signed decision receipts (`pkg/receipts`, `cmd/receipt-writer`). Each direct-path decision gets an AgentGate-format Ed25519 receipt before the action runs. `RECEIPTS_REQUIRED=true` makes INV-05 deny when no receipt can be written. Added `agentctl receipts export` and `agentctl receipts verify`, and a `global.receipts` chart block. Records hold hashes and class names, not raw params or matched data. Execution outcome and the runtime-adapter path are not receipted yet. Uses a local `replace` for `github.com/Clawdlinux/agentgate` until v0.1.4 is tagged (see `BLOCKERS.md`). See `docs/receipts.md`. ### Changed - Moved the threshold evaluator from `pkg/opa` to `pkg/rules/threshold`. `pkg/opa` remains as a deprecated alias package. diff --git a/README.md b/README.md index 745a83c..4fb3c7a 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ assets/ Branding assets (logo, etc.) | [Security](docs/07-security.md) | Cilium, action rules, RBAC, and egress hardening | | [Policy input](docs/policy-input.md) | Declared, observed, and agent-claimed decision inputs | | [Policy packs](docs/policy-packs.md) | Invariants and opt-in Rego packs (`dpdp-in`, `gdpr-eu`) | +| [Receipts](docs/receipts.md) | Opt-in signed decision receipts, receipt-writer, offline verify | | [Troubleshooting](docs/10-troubleshooting.md) | Common issues and fixes | --- diff --git a/ROADMAP.md b/ROADMAP.md index 1c09b74..56453f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,6 +53,7 @@ Strong CNCF base runtimes for agents on Kubernetes exist and are improving. Claw ## Shipped (decision architecture) - [x] Rule engine (OPA Go library) with invariants and policy packs. 5 invariants, `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` packs. Direct action path only; orchestrated runtimes reject packs. See [policy packs](docs/policy-packs.md) +- [x] Signed decision receipts, opt-in. AgentGate receipt format, `receipt-writer` service, write-ahead, fail closed with `RECEIPTS_REQUIRED`, `agentctl receipts export|verify`. Direct action path only. See [receipts](docs/receipts.md) ## Now @@ -69,6 +70,7 @@ Priority order. Validation before features. - [ ] Homebrew tap for agentctl - [x] Observed decision inputs on the direct action path: destination host, deterministic data-class detection, action history. Caller identity is not observed yet. Agent self-report only tightens. See [policy input](docs/policy-input.md) - [ ] Invariants and policy packs on orchestrated runtimes (`spec.orchestration`) +- [ ] Receipt gaps: runtime-adapter path, execution outcome, approval receipts, key rotation, retention, writer TLS, published writer image - [ ] Decision model: escalate-only scoring, shadow mode first. See [decision architecture](docs/architecture/decision-architecture.md) ## Later (all validation-gated) diff --git a/docs/07-security.md b/docs/07-security.md index a5e55e7..afed32c 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -157,6 +157,8 @@ apiServer: The repository also provides HMAC hash-chain and JSONL verification primitives. The controller does not automatically append each run event into that chain. +Separately, opt-in Ed25519 decision receipts cover each direct-path decision. +See [receipts](receipts.md). ## Compliance diff --git a/docs/architecture/decision-architecture.md b/docs/architecture/decision-architecture.md index 3a93501..e7dee17 100644 --- a/docs/architecture/decision-architecture.md +++ b/docs/architecture/decision-architecture.md @@ -30,8 +30,13 @@ model id and version, input hash, option set and order, per-option probabilities shipped packs (dpdp-in, gdpr-eu) when spec.policyPacks is set. The config/policies and pkg/rules/threshold .rego samples are still unevaluated. - Invariants (pkg/invariants, INV-01 to INV-05) run on every direct-path - action. See [invariants](invariants.md). INV-05 is wired but off until - receipts ship. + action. See [invariants](invariants.md). INV-05 fails closed when + `RECEIPTS_REQUIRED=true` and no receipt can be written. +- Receipts (pkg/receipts, cmd/receipt-writer) are opt-in. With receipts on, + every direct-path decision gets a signed AgentGate-format receipt before the + action runs. See [receipts](../receipts.md). No model block yet, because no + model decides. Execution outcome and the runtime-adapter path are not + receipted. - Layers combine in pkg/decision.Decide. Strictest wins. - Gap: the runtime-adapter path (reconcileViaRuntime) runs neither invariants nor packs. Packs on that path fail closed. @@ -45,7 +50,8 @@ model id and version, input hash, option set and order, per-option probabilities 1. Measure, stop trusting agent self-report (observed inputs). See [policy input](../policy-input.md). 2. Real rule engine (OPA Go lib) for invariants and packs. -3. Receipts for every decision (shared receiptspec). +3. Receipts for every decision (shared receiptspec). Shipped on the direct + path, opt-in. See [receipts](../receipts.md). 4. Approval dataset (already in goal prompt phase 4). 5. Decision model phase A (escalate-only), evaluated offline on approval dataset first, shadow mode before it can affect anything. 6. Phase B only after shadow results and calibration are documented. diff --git a/docs/architecture/invariants.md b/docs/architecture/invariants.md index b4f00c8..7555854 100644 --- a/docs/architecture/invariants.md +++ b/docs/architecture/invariants.md @@ -17,7 +17,7 @@ The set stays under 15 entries. IDs are never reused or renumbered. | INV-02 | Egress to a destination not in `allowedDestinations`. | `declaredIntent` has any field set. An empty allow list allows nothing. | Declared vs observed | | INV-03 | Personal data (`email`, `phone`, `aadhaar`, `pan`, `iban`, `card`) sent to a destination not in `allowedDestinations`. | `declaredIntent` has any field set. | Declared vs observed | | INV-04 | Observed data classes outside `allowedDataClasses`. | `declaredIntent` has any field set. | Declared vs observed | -| INV-05 | Deciding without a receipt when receipts are required. Fails closed if no receipt writer is available. | `ReceiptsRequired` is true. It is false today, so INV-05 never fires yet. | Platform | +| INV-05 | Deciding without a receipt when receipts are required. Fails closed if no receipt writer is available or the receipt append fails. | `RECEIPTS_REQUIRED=true`. Off by default. See [receipts](../receipts.md). | Platform | Reasons never contain matched content. They name the class or host only. diff --git a/docs/receipts.md b/docs/receipts.md new file mode 100644 index 0000000..d51cb05 --- /dev/null +++ b/docs/receipts.md @@ -0,0 +1,177 @@ +# Decision receipts + +Opt-in. Off by default. Direct action path only. + +When receipts are on, the operator writes a signed receipt for every decision +on the legacy direct action path, before it executes the action. The receipt +uses the AgentGate receipt format (`github.com/Clawdlinux/agentgate/pkg/receiptspec`), +so `agentgate-verify` checks the chain without changes. See the +[decision architecture](architecture/decision-architecture.md) build order, +step 3. + +## Parts + +| Part | What it does | +|---|---| +| `pkg/receipts` | `DecisionRecord`, `Writer` interface, `LocalWriter` (file chain), `RemoteWriter` (HTTP client), export and offline verify | +| `cmd/receipt-writer` | HTTP service. Owns the Ed25519 signing key and the chain. Single writer | +| Controller | Builds the record, calls `Append`, then executes or denies | +| `agentctl receipts` | `export` from the writer, `verify` offline | +| Chart | `global.receipts` block: StatefulSet, PVC, Service, token Secret, NetworkPolicy | + +The operator never holds the signing key. It sends records to the writer with +a bearer token. + +## What is recorded + +Each receipt is a v1 AgentGate receipt: + +| Receipt field | Value | +|---|---| +| `service` | `clawdlinux` | +| `action` | `decision:` (clipped to 128 bytes) | +| `policy_decision` | `allow` for allow. `deny` for deny and require_approval | +| `status_code` | 200 allow, 202 require_approval, 403 deny | +| `params_sha256` | SHA-256 of the canonical decision record | +| `human_principal` | `system:clawdlinux-operator` | +| `agent_key_id` | workload UID | + +The decision record sits beside the receipt in `records.jsonl`. Fields: +`schema_version`, `workload` (namespace, name, uid), `action`, `layer` +(`invariant`, `rules`, `threshold`; `human` and `model` reserved), `outcome`, +`reasons` (rule ID plus reason), `input_hash`, `declared`, `observed`, +`claimed`, `policy_packs`, `threshold_mode`, `replay_hint`. A `model` block is +reserved and absent until a model decides. + +`input_hash` is the SHA-256 of the canonical JSON of the full decision input: +declared, observed, and agent-claimed (labelled `agent_claimed`). It covers +the raw values without storing them. + +Numbers are integers or decimal strings. Never floats. Canonical JSON follows +receiptspec `DECISION.md` (RFC 8785 subset). + +## What is not recorded + +- Raw action params and the proposal payload. +- Matched content: no email, phone, card, or credential value. Only class + names like `email`. +- Declared purpose, agent-claimed intent, caller identity. Hashed only. +- Destination path and query. Only the host. +- Execution outcome. The receipt records the decision, not what the MCP + server did after. + +Reason strings come from invariants, packs, and the threshold evaluator. They +name classes and hosts, never matched values. A pack load error message is +recorded as is. + +## Write-ahead and fail closed + +Order on the direct path: + +1. Invariants, packs, threshold run. `decision.Decide` picks the outcome. +2. The operator appends the receipt. `Append` returns only after the record + and receipt are fsynced on the writer. +3. Only then is the action executed or held. + +`RECEIPTS_REQUIRED=true`: + +- Writer not configured or not ready (`/healthz` not 200): INV-05 denies. The + operator still tries to append the deny record. +- `Append` fails or times out, for example a bad token: INV-05 is added and + the decision is re-run. The outcome becomes deny. The action is not executed. +- In strict mode the workload goes to `PolicyDenied`. Otherwise + `PendingApproval`, same as any invariant. +- The INV-05 deny itself has no receipt, because the writer just failed. + +`RECEIPTS_ENABLED=true`, not required: an append failure is logged and the +action proceeds. Use this only to try receipts out. It gives no guarantee that +every action has a receipt. + +Receipts disabled (default): nothing changes. + +## Configuration + +Operator env: + +| Variable | Default | Meaning | +|---|---|---| +| `RECEIPTS_ENABLED` | false | Write receipts | +| `RECEIPTS_REQUIRED` | false | Fail closed. Implies enabled | +| `RECEIPTS_WRITER_URL` | | Writer base URL | +| `RECEIPTS_WRITER_TOKEN_FILE` | | Bearer token file, read per request | +| `RECEIPTS_WRITER_TIMEOUT` | 5s | Per request timeout | + +receipt-writer env: + +| Variable | Meaning | +|---|---| +| `RECEIPT_DATA_DIR` | Chain directory. `receipts.jsonl`, `records.jsonl`, `writer.lock` | +| `RECEIPT_KEY_FILE` | Hex Ed25519 seed (32 bytes) or private key (64 bytes) | +| `RECEIPT_KEY_GENERATE` | `true` creates the key file if missing. Otherwise the writer refuses to start | +| `RECEIPT_TOKEN_FILE` | Bearer token, at least 32 characters. Read at start | +| `RECEIPT_ADDR` | Listen address, default `:8080` | + +Routes: `POST /v1/records`, `GET /v1/head`, `GET /v1/export` (all bearer +auth), `GET /healthz` (no auth). Bodies over 64 KiB, unknown fields, and +trailing data are rejected. + +Helm: + +```sh +kubectl -n agentic-system create secret generic receipt-signing-key \ + --from-literal=signing-key=$(openssl rand -hex 32) +helm upgrade --install rel charts -n agentic-system \ + --set global.receipts.enabled=true \ + --set global.receipts.required=true \ + --set global.receipts.signingKey.existingSecret=receipt-signing-key +``` + +The block lives under `global` because the operator Deployment is a subchart. +The chart never generates the signing key. It creates the token Secret once +unless `token.existingSecret` is set. A NetworkPolicy lets only operator pods +reach the writer and blocks writer egress. + +## Key custody + +- The key lives in one Secret, mounted read-only into one pod. +- Keep an offline copy. Losing it means new receipts start a chain that old + trust files do not cover. +- The writer refuses to resume a chain whose head was signed by another key. + Rotation is not supported yet. +- Publish the trust root (`trust.json` from an export, or the `kid` and public + key) through a channel you trust, once. Verify against that pinned copy. + +## Verify offline + +```sh +agentctl receipts export --writer http://localhost:8080 \ + --token-file ./token --out ./evidence +agentctl receipts verify ./evidence --trust-root ./pinned-trust.json +``` + +`verify` runs `receiptspec.VerifyBundle`, the check `agentgate-verify` runs, +then `receiptspec.VerifyRecordBinding` for every record. It prints PASS or +FAIL lines and exits nonzero on failure. Without `--trust-root` it falls back +to the export's own `trust.json`, which proves consistency, not origin. + +The chain alone also verifies with AgentGate: + +```sh +agentgate-verify --source jsonl --path ./evidence/receipts.jsonl \ + --trust-root ./pinned-trust.json +``` + +That checks receipts only, not decision records. + +## Gaps + +- Runtime-adapter path (`spec.orchestration`) writes no receipts. +- Execution outcome is not receipted. Only the pre-execution decision. +- Human approve, reject, and edit receipts are not written yet. The outcomes + exist in the record schema. +- Key rotation: not supported. The writer refuses a key change. +- Retention and compaction: none. Files grow. Export reads the whole chain. +- Writer transport is plain HTTP inside the cluster, guarded by the + NetworkPolicy and bearer token. No TLS yet. +- Token rotation needs a writer restart. +- No receipt-writer image is published yet. See `BLOCKERS.md`. From bb55386b9d0c2b5eff2c487b1b6b3ebc3c1aecf3 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:07:44 +0530 Subject: [PATCH 09/54] feat(approvals): human approve, reject, edit protocol and signed approval dataset Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- CHANGELOG.md | 3 + README.md | 1 + ROADMAP.md | 1 + api/v1alpha1/agentworkload_admission.go | 78 ++++ api/v1alpha1/agentworkload_admission_test.go | 108 +++++ api/v1alpha1/agentworkload_types.go | 110 +++++ api/v1alpha1/agentworkload_webhook.go | 4 + api/v1alpha1/zz_generated.deepcopy.go | 62 +++ cmd/agentctl/commands.go | 45 +- cmd/agentctl/dataset.go | 136 ++++++ cmd/agentctl/dataset_test.go | 186 ++++++++ cmd/agentctl/root.go | 2 + cmd/main.go | 6 + cmd/receipt-writer/approvals_test.go | 82 ++++ cmd/receipt-writer/main.go | 56 ++- cmd/receipt-writer/main_test.go | 19 +- ...agentic.clawdlinux.org_agentworkloads.yaml | 113 +++++ docs/approvals.md | 159 +++++++ docs/architecture/decision-architecture.md | 9 +- docs/receipts.md | 20 +- .../controller/agentworkload_controller.go | 65 +-- .../agentworkload_controller_approval_test.go | 325 ++++++++++++++ internal/controller/approvals.go | 400 ++++++++++++++++++ internal/controller/receipts.go | 59 ++- pkg/agentctl/approve.go | 29 +- pkg/agentctl/decide.go | 108 +++++ pkg/agentctl/decide_test.go | 112 +++++ pkg/agentctl/reject.go | 30 +- pkg/agentctl/types.go | 6 + pkg/approval/approval.go | 257 +++++++++++ pkg/approval/approval_test.go | 158 +++++++ pkg/dataset/dataset.go | 347 +++++++++++++++ pkg/dataset/dataset_test.go | 293 +++++++++++++ pkg/dataset/remote.go | 113 +++++ pkg/dataset/store.go | 118 ++++++ pkg/dataset/verify.go | 115 +++++ pkg/receipts/human_test.go | 95 +++++ pkg/receipts/local.go | 40 ++ pkg/receipts/record.go | 92 +++- 39 files changed, 3870 insertions(+), 92 deletions(-) create mode 100644 api/v1alpha1/agentworkload_admission.go create mode 100644 api/v1alpha1/agentworkload_admission_test.go create mode 100644 cmd/agentctl/dataset.go create mode 100644 cmd/agentctl/dataset_test.go create mode 100644 cmd/receipt-writer/approvals_test.go create mode 100644 docs/approvals.md create mode 100644 internal/controller/agentworkload_controller_approval_test.go create mode 100644 internal/controller/approvals.go create mode 100644 pkg/agentctl/decide.go create mode 100644 pkg/agentctl/decide_test.go create mode 100644 pkg/approval/approval.go create mode 100644 pkg/approval/approval_test.go create mode 100644 pkg/dataset/dataset.go create mode 100644 pkg/dataset/dataset_test.go create mode 100644 pkg/dataset/remote.go create mode 100644 pkg/dataset/store.go create mode 100644 pkg/dataset/verify.go create mode 100644 pkg/receipts/human_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f2e86..715d1cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added optional `spec.declaredIntent` on `AgentWorkload` and observed decision inputs. The legacy action path now denies or escalates actions that send undeclared data classes or call undeclared hosts. Agent-claimed confidence and cluster health can only tighten. New packages: `pkg/dataclass`, `pkg/decision`, `pkg/decision/input`, `pkg/decision/observe`. See `docs/policy-input.md`. - Added invariants (`pkg/invariants`, INV-01 to INV-05), an embedded OPA rule engine (`pkg/rules/engine`, OPA v1.19.1), and opt-in policy packs `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` via `spec.policyPacks`. Packs only add deny or require approval. Packs are engineering controls, not legal advice. The `credential` data class is now detected and always denied (INV-01), even without `declaredIntent`. Direct action path only. See `docs/policy-packs.md`. - Added opt-in signed decision receipts (`pkg/receipts`, `cmd/receipt-writer`). Each direct-path decision gets an AgentGate-format Ed25519 receipt before the action runs. `RECEIPTS_REQUIRED=true` makes INV-05 deny when no receipt can be written. Added `agentctl receipts export` and `agentctl receipts verify`, and a `global.receipts` chart block. Records hold hashes and class names, not raw params or matched data. Execution outcome and the runtime-adapter path are not receipted yet. Uses a local `replace` for `github.com/Clawdlinux/agentgate` until v0.1.4 is tagged (see `BLOCKERS.md`). See `docs/receipts.md`. +- Added human approvals on the direct action path (`pkg/approval`, `pkg/dataset`). A held action is stored in `status.pendingApproval`. Humans decide with `clawdlinux.org/approval-decision` (approve, reject, edit). The webhook stamps the approver; the controller refuses unstamped decisions. Each decision gets a layer `human` receipt and an example in `approvals.jsonl` on the receipt-writer (`POST|GET /v1/approvals`). Edited and approved actions re-run invariants and packs first. Added `spec.approvalCapture.content` (none, redacted, full; default redacted), phase `Rejected`, `agentctl edit-approve`, `agentctl approve --reason`, and `agentctl dataset export|verify`. See `docs/approvals.md`. ### Changed - Moved the threshold evaluator from `pkg/opa` to `pkg/rules/threshold`. `pkg/opa` remains as a deprecated alias package. - `agentctl` table output now uses tablewriter v1 (box-drawn borders). OPA v1.19.1 requires it. - An unknown or unenforceable `spec.policyPacks` entry fails the workload closed with condition `PolicyPackInvalid`. The validating webhook also rejects unknown packs. +- The AgentWorkload webhooks now register handlers. Before, `SetupWebhookWithManager` registered none, so enabling `agentWorkloadEnabled` sent requests to missing paths. +- A `PendingApproval` workload with a stored pending action waits for a human decision instead of re-proposing every hour. - Docs: removed claims not backed by code. Added the decision architecture ADR (`docs/architecture/decision-architecture.md`). Rego files are labelled samples; the action path is a Go threshold evaluator. - Changed the internal Go `CostReporter.RecordUsage` and `Provider.CallModel` interfaces to carry operation IDs for idempotency. This breaks out-of-tree implementations. - Added the optional `litellm.anthropicKey` chart value. Use `litellm.existingSecret` for booth and production deployments. diff --git a/README.md b/README.md index 4fb3c7a..530f2da 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,7 @@ assets/ Branding assets (logo, etc.) | [Policy input](docs/policy-input.md) | Declared, observed, and agent-claimed decision inputs | | [Policy packs](docs/policy-packs.md) | Invariants and opt-in Rego packs (`dpdp-in`, `gdpr-eu`) | | [Receipts](docs/receipts.md) | Opt-in signed decision receipts, receipt-writer, offline verify | +| [Approvals](docs/approvals.md) | Human approve, reject, edit protocol and the signed approval dataset | | [Troubleshooting](docs/10-troubleshooting.md) | Common issues and fixes | --- diff --git a/ROADMAP.md b/ROADMAP.md index 56453f9..8ed5ecc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -54,6 +54,7 @@ Strong CNCF base runtimes for agents on Kubernetes exist and are improving. Claw - [x] Rule engine (OPA Go library) with invariants and policy packs. 5 invariants, `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` packs. Direct action path only; orchestrated runtimes reject packs. See [policy packs](docs/policy-packs.md) - [x] Signed decision receipts, opt-in. AgentGate receipt format, `receipt-writer` service, write-ahead, fail closed with `RECEIPTS_REQUIRED`, `agentctl receipts export|verify`. Direct action path only. See [receipts](docs/receipts.md) +- [x] Human approval protocol and signed approval dataset. Webhook-stamped approver, append-once decisions, edit re-validated against invariants, `spec.approvalCapture`, `approvals.jsonl` on the receipt-writer, `agentctl dataset export|verify`. Direct action path only; Argo approval gates not covered. See [approvals](docs/approvals.md) ## Now diff --git a/api/v1alpha1/agentworkload_admission.go b/api/v1alpha1/agentworkload_admission.go new file mode 100644 index 0000000..738fec0 --- /dev/null +++ b/api/v1alpha1/agentworkload_admission.go @@ -0,0 +1,78 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package v1alpha1 + +import ( + "context" + "encoding/json" + "fmt" + + admissionv1 "k8s.io/api/admission/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/Clawdlinux/agentic-operator-core/pkg/approval" +) + +// workloadAdmission adapts the AgentWorkload Default and Validate methods to +// the controller-runtime webhook builder, and stamps approval decisions. +type workloadAdmission struct{} + +var ( + _ admission.Defaulter[*AgentWorkload] = workloadAdmission{} + _ admission.Validator[*AgentWorkload] = workloadAdmission{} +) + +// Default applies spec defaults, then the approval protocol: a new decision +// is stamped with the requesting user, a client-set stamp or a change to a +// recorded decision is rejected. See docs/approvals.md. +func (workloadAdmission) Default(ctx context.Context, obj *AgentWorkload) error { + obj.Default() + req, err := admission.RequestFromContext(ctx) + if err != nil { + return err + } + var oldAnn map[string]string + pendingID := "" + if req.Operation == admissionv1.Update { + old := &AgentWorkload{} + if err := json.Unmarshal(req.OldObject.Raw, old); err != nil { + return fmt.Errorf("decode old object: %w", err) + } + oldAnn = old.Annotations + if old.Status.PendingApproval != nil && old.Status.Phase == PhasePendingApproval { + pendingID = old.Status.PendingApproval.ID + } + } + return obj.AdmitApproval(oldAnn, pendingID, req.UserInfo.Username, req.UserInfo.Groups) +} + +// AdmitApproval runs the approval annotation rules against the stored +// annotations and pending action id. +func (r *AgentWorkload) AdmitApproval(oldAnn map[string]string, pendingID, username string, groups []string) error { + if r.Annotations == nil { + r.Annotations = map[string]string{} + } + if err := approval.Admit(oldAnn, r.Annotations, pendingID, username, groups); err != nil { + return apierrors.NewForbidden(GroupVersion.WithResource("agentworkloads").GroupResource(), r.Name, err) + } + if len(r.Annotations) == 0 { + r.Annotations = nil + } + return nil +} + +func (workloadAdmission) ValidateCreate(_ context.Context, obj *AgentWorkload) (admission.Warnings, error) { + return nil, obj.ValidateCreate() +} + +func (workloadAdmission) ValidateUpdate(_ context.Context, oldObj, newObj *AgentWorkload) (admission.Warnings, error) { + return nil, newObj.ValidateUpdate(oldObj) +} + +func (workloadAdmission) ValidateDelete(_ context.Context, obj *AgentWorkload) (admission.Warnings, error) { + return nil, obj.ValidateDelete() +} diff --git a/api/v1alpha1/agentworkload_admission_test.go b/api/v1alpha1/agentworkload_admission_test.go new file mode 100644 index 0000000..9afcf21 --- /dev/null +++ b/api/v1alpha1/agentworkload_admission_test.go @@ -0,0 +1,108 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package v1alpha1 + +import ( + "context" + "encoding/json" + "strings" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/Clawdlinux/agentic-operator-core/pkg/approval" +) + +func admissionCtx(t *testing.T, op admissionv1.Operation, old *AgentWorkload) context.Context { + t.Helper() + req := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: op, + UserInfo: authenticationv1.UserInfo{Username: "alice", Groups: []string{"sre"}}, + }} + if old != nil { + raw, err := json.Marshal(old) + if err != nil { + t.Fatal(err) + } + req.OldObject = runtime.RawExtension{Raw: raw} + } + return admission.NewContextWithRequest(context.Background(), req) +} + +func pendingWorkload(ann map[string]string) *AgentWorkload { + return &AgentWorkload{ + ObjectMeta: metav1.ObjectMeta{Name: "wl", Annotations: ann}, + Status: AgentWorkloadStatus{Phase: PhasePendingApproval, PendingApproval: &PendingApproval{ID: "p1"}}, + } +} + +func TestAdmissionStampsApproval(t *testing.T) { + tests := []struct { + name string + op admissionv1.Operation + old *AgentWorkload + ann map[string]string + wantErr string + wantStamp bool + }{ + {name: "create without decision", op: admissionv1.Create}, + {name: "create with decision rejected", op: admissionv1.Create, ann: map[string]string{approval.AnnotationDecision: "approve"}, wantErr: "no pending action"}, + {name: "update stamps user", op: admissionv1.Update, old: pendingWorkload(nil), ann: map[string]string{approval.AnnotationDecision: "approve"}, wantStamp: true}, + {name: "client stamp rejected", op: admissionv1.Update, old: pendingWorkload(nil), + ann: map[string]string{approval.AnnotationDecision: "approve", approval.AnnotationBy: `{"username":"root"}`}, wantErr: "admission webhook only"}, + {name: "not pending rejected", op: admissionv1.Update, old: &AgentWorkload{Status: AgentWorkloadStatus{Phase: "Completed", PendingApproval: &PendingApproval{ID: "p1"}}}, + ann: map[string]string{approval.AnnotationDecision: "approve"}, wantErr: "no pending action"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + wl := &AgentWorkload{ObjectMeta: metav1.ObjectMeta{Name: "wl", Annotations: tc.ann}} + err := workloadAdmission{}.Default(admissionCtx(t, tc.op, tc.old), wl) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if wl.Spec.OPAPolicy == nil { + t.Fatal("spec defaults not applied") + } + if tc.wantStamp { + d, ok, err := approval.Read(wl.Annotations, "p1") + if !ok || err != nil || d.Approver.Username != "alice" { + t.Fatalf("decision = %+v %v %v", d, ok, err) + } + } + }) + } + + // A recorded decision for the live pending action cannot change. + first := &AgentWorkload{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{approval.AnnotationDecision: "approve"}}} + if err := (workloadAdmission{}).Default(admissionCtx(t, admissionv1.Update, pendingWorkload(nil)), first); err != nil { + t.Fatal(err) + } + second := &AgentWorkload{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}} + for k, v := range first.Annotations { + second.Annotations[k] = v + } + second.Annotations[approval.AnnotationDecision] = "reject" + err := workloadAdmission{}.Default(admissionCtx(t, admissionv1.Update, pendingWorkload(first.Annotations)), second) + if err == nil || !strings.Contains(err.Error(), "append-once") { + t.Fatalf("changed decision err = %v", err) + } +} + +func TestAdmissionNeedsRequest(t *testing.T) { + if err := (workloadAdmission{}).Default(context.Background(), &AgentWorkload{}); err == nil { + t.Fatal("defaulter without admission request must fail") + } +} diff --git a/api/v1alpha1/agentworkload_types.go b/api/v1alpha1/agentworkload_types.go index c218b58..9e94b43 100644 --- a/api/v1alpha1/agentworkload_types.go +++ b/api/v1alpha1/agentworkload_types.go @@ -155,6 +155,38 @@ type AgentWorkloadSpec struct { // +listType=set // +optional PolicyPacks []string `json:"policyPacks,omitempty"` + + // approvalCapture controls what the approval dataset stores about the + // action content of each human decision. See docs/approvals.md. + // +optional + ApprovalCapture *ApprovalCapture `json:"approvalCapture,omitempty"` +} + +// ApprovalCapture sets the content capture mode for approval examples. +type ApprovalCapture struct { + // content is none (hashes and class names only), redacted (action name and + // params with detected data classes and credentials replaced by [class]), + // or full (raw content, may hold personal data, choose it explicitly). + // Receipts never hold raw params in any mode. + // +kubebuilder:validation:Enum=none;redacted;full + // +kubebuilder:default=redacted + // +optional + Content string `json:"content,omitempty"` +} + +// Approval capture modes. +const ( + CaptureNone = "none" + CaptureRedacted = "redacted" + CaptureFull = "full" +) + +// CaptureMode returns the effective capture mode. Unset means redacted. +func (s AgentWorkloadSpec) CaptureMode() string { + if s.ApprovalCapture == nil || s.ApprovalCapture.Content == "" { + return CaptureRedacted + } + return s.ApprovalCapture.Content } // DeclaredIntent is the human-reviewed purpose and allow lists for a workload. @@ -428,6 +460,84 @@ type AgentWorkloadStatus struct { // agentStatuses reports per-agent status when collaborationMode is "team" or "delegation" // +optional AgentStatuses []AgentInstanceStatus `json:"agentStatuses,omitempty"` + + // pendingApproval is the direct-path action waiting for a human decision. + // +optional + PendingApproval *PendingApproval `json:"pendingApproval,omitempty"` + + // lastApproval records the last human decision the controller handled. + // +optional + LastApproval *ApprovalOutcome `json:"lastApproval,omitempty"` +} + +// Workload phases set by the direct action path. +const ( + PhasePendingApproval = "PendingApproval" + PhaseRejected = "Rejected" +) + +// Pending approval states. A decision moves "" to Recording to Executing. +const ( + ApprovalStateRecording = "Recording" + ApprovalStateExecuting = "Executing" +) + +// PendingApproval is an action held for a human decision. +type PendingApproval struct { + // id binds a decision to this action. The webhook stamps it into + // clawdlinux.org/approval-for. + ID string `json:"id"` + + Action string `json:"action"` + Description string `json:"description"` + Confidence string `json:"confidence"` + + // clusterHealth is the value the threshold used, as a decimal string. + ClusterHealth string `json:"clusterHealth"` + + // claimedClusterHealth is set when the MCP server reported health. + // +optional + ClaimedClusterHealth string `json:"claimedClusterHealth,omitempty"` + + // proposal is the raw MCP proposal JSON. approve executes it as is. + // It may hold personal data. Receipts and the dataset never copy it raw + // unless approvalCapture.content is full. + Proposal string `json:"proposal"` + + // record is the canonical JSON of the original decision record. It holds + // hashes and class names only. + Record string `json:"record"` + + // receiptSeq and receiptEntryHash identify the original decision receipt. + // Zero when receipts are off. + // +optional + ReceiptSeq int64 `json:"receiptSeq,omitempty"` + // +optional + ReceiptEntryHash string `json:"receiptEntryHash,omitempty"` + + ProposedAt metav1.Time `json:"proposedAt"` + + // state tracks a decision in progress so a replayed reconcile never + // executes twice. + // +kubebuilder:validation:Enum="";Recording;Executing + // +optional + State string `json:"state,omitempty"` + + // decision is the label being handled while state is set. + // +optional + Decision string `json:"decision,omitempty"` +} + +// ApprovalOutcome is the result of one handled human decision. +type ApprovalOutcome struct { + ID string `json:"id"` + Decision string `json:"decision"` + // outcome is executed, failed, rejected, denied, or unknown. + Outcome string `json:"outcome"` + ApproverSHA256 string `json:"approverSHA256"` + // +optional + DecisionReceiptSeq int64 `json:"decisionReceiptSeq,omitempty"` + DecidedAt metav1.Time `json:"decidedAt"` } const ( diff --git a/api/v1alpha1/agentworkload_webhook.go b/api/v1alpha1/agentworkload_webhook.go index de31991..5befcf0 100644 --- a/api/v1alpha1/agentworkload_webhook.go +++ b/api/v1alpha1/agentworkload_webhook.go @@ -38,8 +38,12 @@ import ( var agentworkloadlog = logf.Log.WithName("agentworkload-resource") +// SetupWebhookWithManager registers the defaulting and validating webhooks. +// Without WithDefaulter and WithValidator the builder registers no handler. func (r *AgentWorkload) SetupWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr, r). + WithDefaulter(workloadAdmission{}). + WithValidator(workloadAdmission{}). Complete() } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 7de6ab5..a5988e2 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -535,6 +535,11 @@ func (in *AgentWorkloadSpec) DeepCopyInto(out *AgentWorkloadSpec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.ApprovalCapture != nil { + in, out := &in.ApprovalCapture, &out.ApprovalCapture + *out = new(ApprovalCapture) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentWorkloadSpec. @@ -594,6 +599,16 @@ func (in *AgentWorkloadStatus) DeepCopyInto(out *AgentWorkloadStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.PendingApproval != nil { + in, out := &in.PendingApproval, &out.PendingApproval + *out = new(PendingApproval) + (*in).DeepCopyInto(*out) + } + if in.LastApproval != nil { + in, out := &in.LastApproval, &out.LastApproval + *out = new(ApprovalOutcome) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentWorkloadStatus. @@ -606,6 +621,37 @@ func (in *AgentWorkloadStatus) DeepCopy() *AgentWorkloadStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApprovalCapture) DeepCopyInto(out *ApprovalCapture) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApprovalCapture. +func (in *ApprovalCapture) DeepCopy() *ApprovalCapture { + if in == nil { + return nil + } + out := new(ApprovalCapture) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApprovalOutcome) DeepCopyInto(out *ApprovalOutcome) { + *out = *in + in.DecidedAt.DeepCopyInto(&out.DecidedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApprovalOutcome. +func (in *ApprovalOutcome) DeepCopy() *ApprovalOutcome { + if in == nil { + return nil + } + out := new(ApprovalOutcome) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ArgoWorkflowRef) DeepCopyInto(out *ArgoWorkflowRef) { *out = *in @@ -707,6 +753,22 @@ func (in *OrchestrationSpec) DeepCopy() *OrchestrationSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PendingApproval) DeepCopyInto(out *PendingApproval) { + *out = *in + in.ProposedAt.DeepCopyInto(&out.ProposedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PendingApproval. +func (in *PendingApproval) DeepCopy() *PendingApproval { + if in == nil { + return nil + } + out := new(PendingApproval) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceRequirements) DeepCopyInto(out *ResourceRequirements) { *out = *in diff --git a/cmd/agentctl/commands.go b/cmd/agentctl/commands.go index 83eacae..fb6fc9f 100644 --- a/cmd/agentctl/commands.go +++ b/cmd/agentctl/commands.go @@ -169,25 +169,29 @@ func runInit(ctx context.Context, opts *cliOptions, cmd *cobra.Command) error { // ── approve ───────────────────────────────────────────────────────────────── func newApproveCommand(opts *cliOptions) *cobra.Command { + var reason string cmd := &cobra.Command{ Use: "approve ", Short: "Resume a PendingApproval workload", Long: `Approve a workload that is paused at an approval gate. -Sets the workload's phase annotation to trigger the controller to resume execution. -If the workload uses Argo Workflows, also attempts to resume the suspended workflow.`, +For a direct-path action held for review, sets clawdlinux.org/approval-decision=approve. +The admission webhook stamps your identity. The operator re-checks invariants before +it executes. If the workload uses Argo Workflows, also attempts to resume the +suspended workflow.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runApprove(cmd.Context(), opts, args[0], cmd) + return runApprove(cmd.Context(), opts, args[0], reason, cmd) }, } + cmd.Flags().StringVar(&reason, "reason", "", "Free-text reason, at most 1024 bytes. Stored as a hash in receipts and the dataset") return cmd } -func runApprove(ctx context.Context, opts *cliOptions, name string, cmd *cobra.Command) error { +func runApprove(ctx context.Context, opts *cliOptions, name, reason string, cmd *cobra.Command) error { w := cmd.OutOrStdout() - result, err := opts.client.ApproveWorkload(ctx, opts.Namespace, name, "agentctl") + result, err := opts.client.ApproveWorkloadWithReason(ctx, opts.Namespace, name, "agentctl", reason) if err != nil { // If the error is about wrong phase, the library still returns a result if result != nil && result.PreviousPhase != "" { @@ -205,6 +209,37 @@ func runApprove(ctx context.Context, opts *cliOptions, name string, cmd *cobra.C return nil } +// ── edit-approve ──────────────────────────────────────────────────────────── + +func newEditApproveCommand(opts *cliOptions) *cobra.Command { + var editFile, reason string + cmd := &cobra.Command{ + Use: "edit-approve ", + Short: "Approve a pending action with a replacement action", + Long: `Approve the pending direct-path action, replaced by the action in --edit-file. + +The file holds {"name": ..., "description": ..., "params": {...}}. The operator re-runs +invariants, policy packs, and the threshold on the edited action before execution. +An edit that breaks an invariant is denied.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + data, err := os.ReadFile(editFile) + if err != nil { + return err + } + if _, err := opts.client.EditApproveWorkload(cmd.Context(), opts.Namespace, args[0], string(data), reason); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Edit decision recorded for %q. The operator re-validates it before execution.\n", args[0]) + return nil + }, + } + cmd.Flags().StringVar(&editFile, "edit-file", "", "JSON file with the replacement action") + cmd.Flags().StringVar(&reason, "reason", "", "Free-text reason, at most 1024 bytes") + _ = cmd.MarkFlagRequired("edit-file") + return cmd +} + // ── reject ────────────────────────────────────────────────────────────────── func newRejectCommand(opts *cliOptions) *cobra.Command { diff --git a/cmd/agentctl/dataset.go b/cmd/agentctl/dataset.go new file mode 100644 index 0000000..9df93bb --- /dev/null +++ b/cmd/agentctl/dataset.go @@ -0,0 +1,136 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +func newDatasetCommand(opts *cliOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "dataset", + Short: "Export and verify the signed approval dataset", + // The dataset works offline. Do not load a kubeconfig. + PersistentPreRunE: func(*cobra.Command, []string) error { return opts.validateOutput() }, + } + cmd.AddCommand(newDatasetExportCommand(), newDatasetVerifyCommand()) + return cmd +} + +func newDatasetExportCommand() *cobra.Command { + var writer, tokenFile, out string + var timeout time.Duration + cmd := &cobra.Command{ + Use: "export", + Short: "Download approvals.jsonl with the receipt chain it binds to", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + rw, err := receipts.NewRemoteWriter(writer, receipts.TokenFile(tokenFile), timeout) + if err != nil { + return err + } + ds, err := dataset.NewRemote(writer, receipts.TokenFile(tokenFile), timeout) + if err != nil { + return err + } + // Approvals first: every example then has its receipt in the later chain export. + approvals, err := ds.Export(cmd.Context()) + if err != nil { + return err + } + e, err := rw.Export(cmd.Context()) + if err != nil { + return err + } + if err := e.WriteDir(out); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(out, dataset.ApprovalsFile), []byte(approvals), 0o600); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "exported %d approval examples to %s (%s plus the receipt export)\n", + strings.Count(approvals, "\n"), out, dataset.ApprovalsFile) + return nil + }, + } + cmd.Flags().StringVar(&writer, "writer", "", "receipt-writer base URL, for example http://localhost:8080") + cmd.Flags().StringVar(&tokenFile, "token-file", "", "file holding the receipt-writer bearer token") + cmd.Flags().StringVar(&out, "out", "", "output directory") + cmd.Flags().DurationVar(&timeout, "timeout", 60*time.Second, "HTTP timeout") + for _, f := range []string{"writer", "token-file", "out"} { + _ = cmd.MarkFlagRequired(f) + } + return cmd +} + +func newDatasetVerifyCommand() *cobra.Command { + var trustRoot string + cmd := &cobra.Command{ + Use: "verify ", + Short: "Verify an approval dataset export offline", + Long: "Verifies the receipt chain and decision records like 'agentctl receipts verify', then checks " + + "that every example's receipt_seq and entry hash exist in the chain and that its label matches " + + "the human decision receipt.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var trusted []receiptspec.TrustedKey + if trustRoot != "" { + data, err := os.ReadFile(trustRoot) + if err != nil { + return err + } + if trusted, err = receiptspec.LoadTrustedKeys(data); err != nil { + return err + } + } + e, approvals, err := dataset.ReadDir(args[0]) + if err != nil { + return err + } + stdout, stderr := cmd.OutOrStdout(), cmd.ErrOrStderr() + rep, err := dataset.Verify(e, approvals, trusted) + if receipts.IsManifestError(err) { + fmt.Fprintf(stderr, "FAIL: manifest: %v\n", err) + return errVerifyFailed + } + if err != nil { + return err + } + c := rep.Receipts.Chain + if c.OK { + fmt.Fprintf(stdout, "PASS: %d receipts verified, head seq=%d\n", c.VerifiedCount, c.HeadSeq) + } else { + fmt.Fprintf(stderr, "FAIL: seq=%d reason=%s\n", c.FailedAtSeq, c.Reason) + } + for _, f := range rep.Receipts.RecordFailures { + fmt.Fprintf(stderr, "FAIL: record seq=%d reason=%s\n", f.Seq, f.Reason) + } + for _, f := range rep.Failures { + fmt.Fprintf(stderr, "FAIL: example line=%d seq=%d reason=%s\n", f.Line, f.Seq, f.Reason) + } + if len(rep.Failures) == 0 { + fmt.Fprintf(stdout, "PASS: %d approval examples bound to their receipts\n", rep.ExamplesBound) + } + if !rep.OK() { + return errVerifyFailed + } + return nil + }, + } + cmd.Flags().StringVar(&trustRoot, "trust-root", "", "trust file pinned out of band (receiptspec trust file format)") + return cmd +} diff --git a/cmd/agentctl/dataset_test.go b/cmd/agentctl/dataset_test.go new file mode 100644 index 0000000..7dd7fbc --- /dev/null +++ b/cmd/agentctl/dataset_test.go @@ -0,0 +1,186 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +// datasetExport returns a receipt export with one escalated decision, one +// human decision, and approvals.jsonl binding to it. +func datasetExport(t *testing.T) (receipts.Export, string) { + t.Helper() + dir := t.TempDir() + w, err := receipts.OpenLocal(dir, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{4}, 32))) + if err != nil { + t.Fatal(err) + } + defer func() { _ = w.Close() }() + ctx := context.Background() + wl := receipts.Workload{Namespace: "ns", Name: "wl", UID: "uid"} + orig, err := receipts.NewDecisionRecord(receipts.Params{ + Workload: wl, Action: "scale", + Layers: decision.Layers{ThresholdReasons: []string{"low confidence"}}, + Result: decision.Result{Outcome: decision.RequireApproval, Layer: decision.LayerThreshold}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := w.Append(ctx, orig); err != nil { + t.Fatal(err) + } + 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) + } + r, err := w.Append(ctx, human) + if err != nil { + t.Fatal(err) + } + ds, err := dataset.Open(dir, w.Lookup) + if err != nil { + t.Fatal(err) + } + defer func() { _ = ds.Close() }() + ex, err := dataset.NewExample(dataset.ExampleParams{Receipt: r, Human: human, Original: orig, Mode: dataset.CaptureRedacted, Action: dataset.Action{Name: "scale"}, Timestamp: "2026-10-02T00:00:00Z"}) + if err != nil { + t.Fatal(err) + } + if err := ds.Append(ex); err != nil { + t.Fatal(err) + } + approvals, err := ds.Export() + if err != nil { + t.Fatal(err) + } + e, err := w.Export() + if err != nil { + t.Fatal(err) + } + return e, approvals +} + +func TestDatasetVerify(t *testing.T) { + tests := []struct { + name string + mutate func(e *receipts.Export, approvals *string) + noFile bool + wantOut string + wantErr string + }{ + {name: "passes", wantOut: "PASS: 1 approval examples bound"}, + {name: "label inconsistent with receipt", mutate: func(_ *receipts.Export, a *string) { + *a = strings.Replace(*a, `"label":"approve"`, `"label":"reject"`, 1) + }, wantErr: "does not match receipt outcome"}, + {name: "seq not in chain", mutate: func(_ *receipts.Export, a *string) { + *a = strings.Replace(*a, `"receipt_seq":2`, `"receipt_seq":5`, 1) + }, wantErr: "receipt_seq not in chain"}, + {name: "entry hash wrong", mutate: func(_ *receipts.Export, a *string) { + i := strings.Index(*a, `"receipt_entry_hash":"`) + len(`"receipt_entry_hash":"`) + *a = (*a)[:i] + strings.Repeat("0", 64) + (*a)[i+64:] + }, wantErr: "entry hash mismatch"}, + {name: "receipt chain tampered", mutate: func(e *receipts.Export, _ *string) { + e.ReceiptsJSONL = strings.Replace(e.ReceiptsJSONL, `"status_code":200`, `"status_code":201`, 1) + }, wantErr: "FAIL: seq="}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + e, approvals := datasetExport(t) + if tc.mutate != nil { + tc.mutate(&e, &approvals) + } + dir := filepath.Join(t.TempDir(), "export") + if err := e.WriteDir(dir); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(dir, dataset.ApprovalsFile), []byte(approvals), 0o600) + out, errOut, err := runAgentctl(t, "dataset", "verify", dir) + if tc.wantErr != "" { + if !errors.Is(err, errVerifyFailed) || !strings.Contains(errOut, tc.wantErr) { + t.Fatalf("err = %v, stderr = %q, want %q", err, errOut, tc.wantErr) + } + return + } + if err != nil || !strings.Contains(out, tc.wantOut) { + t.Fatalf("err = %v, stdout = %q, stderr = %q", err, out, errOut) + } + }) + } + + e, _ := datasetExport(t) + dir := filepath.Join(t.TempDir(), "noapprovals") + _ = e.WriteDir(dir) + if _, _, err := runAgentctl(t, "dataset", "verify", dir); err == nil || !strings.Contains(err.Error(), dataset.ApprovalsFile) { + t.Fatalf("missing approvals file err = %v", err) + } +} + +func TestDatasetExport(t *testing.T) { + e, approvals := datasetExport(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer secret-token" { + http.Error(w, "no", http.StatusUnauthorized) + return + } + switch r.URL.Path { + case "/v1/export": + _ = json.NewEncoder(w).Encode(e) + case "/v1/approvals": + _ = json.NewEncoder(w).Encode(dataset.ExportResponse{Format: dataset.ExportFormat, ApprovalsJSONL: approvals}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + dir := t.TempDir() + tokenFile := filepath.Join(dir, "token") + _ = os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600) + out := filepath.Join(dir, "export") + stdout, _, err := runAgentctl(t, "dataset", "export", "--writer", srv.URL, "--token-file", tokenFile, "--out", out) + if err != nil || !strings.Contains(stdout, "exported 1 approval examples") { + t.Fatalf("export: %v %q", err, stdout) + } + if stdout, _, err := runAgentctl(t, "dataset", "verify", out); err != nil || !strings.Contains(stdout, "PASS: 1 approval examples") { + t.Fatalf("verify exported dir: %v %q", err, stdout) + } + _ = os.WriteFile(tokenFile, []byte("wrong\n"), 0o600) + if _, _, err := runAgentctl(t, "dataset", "export", "--writer", srv.URL, "--token-file", tokenFile, "--out", out); err == nil { + t.Fatal("bad token export succeeded") + } +} + +func TestApprovalCommandsHelp(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"approve", "--help"}, "--reason"}, + {[]string{"edit-approve", "--help"}, "--edit-file"}, + {[]string{"reject", "--help"}, "--reason"}, + } { + out, _, err := runAgentctl(t, tc.args...) + if err != nil || !strings.Contains(out, tc.want) { + t.Fatalf("%v: err %v, out %q", tc.args, err, out) + } + } +} diff --git a/cmd/agentctl/root.go b/cmd/agentctl/root.go index 1e48f6c..d99c4b6 100644 --- a/cmd/agentctl/root.go +++ b/cmd/agentctl/root.go @@ -108,11 +108,13 @@ func newRootCommand() *cobra.Command { cmd.AddCommand(newVersionCommand(opts)) cmd.AddCommand(newInitCommand(opts)) cmd.AddCommand(newApproveCommand(opts)) + cmd.AddCommand(newEditApproveCommand(opts)) cmd.AddCommand(newRejectCommand(opts)) cmd.AddCommand(newWorkflowsCommand(opts)) cmd.AddCommand(newStatusCommand(opts)) cmd.AddCommand(newMCPCommand(opts)) cmd.AddCommand(newReceiptsCommand(opts)) + cmd.AddCommand(newDatasetCommand(opts)) return cmd } diff --git a/cmd/main.go b/cmd/main.go index 558f78c..32125aa 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -57,6 +57,7 @@ import ( "github.com/Clawdlinux/agentic-operator-core/internal/controller" "github.com/Clawdlinux/agentic-operator-core/internal/netpolicy" "github.com/Clawdlinux/agentic-operator-core/internal/netpolicy/netprobe" + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" "github.com/Clawdlinux/agentic-operator-core/pkg/evaluation" "github.com/Clawdlinux/agentic-operator-core/pkg/finops" "github.com/Clawdlinux/agentic-operator-core/pkg/governance" @@ -378,6 +379,11 @@ func buildReceiptsConfig(cfg receipts.Config, logger logr.Logger) controller.Rec return out } out.Writer = w + if ds, err := dataset.NewRemote(cfg.WriterURL, receipts.TokenFile(cfg.TokenFile), cfg.Timeout); err == nil { + out.Approvals = ds + } else { + logger.Error(err, "Approval dataset client misconfigured; examples will not be stored") + } logger.Info("Decision receipts enabled", "writer", cfg.WriterURL, "required", cfg.Required) return out } diff --git a/cmd/receipt-writer/approvals_test.go b/cmd/receipt-writer/approvals_test.go new file mode 100644 index 0000000..eaa8321 --- /dev/null +++ b/cmd/receipt-writer/approvals_test.go @@ -0,0 +1,82 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +func TestApprovalsRoutes(t *testing.T) { + srv, w := newTestServerWriter(t) + ctx := context.Background() + orig := testRecord(t) + if _, err := w.Append(ctx, orig); err != nil { + t.Fatal(err) + } + human, err := receipts.NewHumanRecord(receipts.HumanParams{ + Workload: orig.Workload, + 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) + } + r, err := w.Append(ctx, human) + if err != nil { + t.Fatal(err) + } + ex, err := dataset.NewExample(dataset.ExampleParams{Receipt: r, Human: human, Original: orig, Mode: dataset.CaptureNone, Action: dataset.Action{Name: "scale"}, Timestamp: "2026-10-02T00:00:00Z"}) + if err != nil { + t.Fatal(err) + } + good, _ := json.Marshal(ex) + flipped := bytes.Replace(good, []byte(`"label":"approve"`), []byte(`"label":"reject"`), 1) + + tests := []struct { + name string + token string + body []byte + status int + }{ + {"no token", "", good, http.StatusUnauthorized}, + {"unknown field", testToken, []byte(`{"bogus":1}`), http.StatusBadRequest}, + {"trailing data", testToken, append(append([]byte{}, good...), []byte(" {}")...), http.StatusBadRequest}, + {"too large", testToken, bytes.Repeat([]byte(" "), dataset.MaxExampleBody+1), http.StatusRequestEntityTooLarge}, + {"label not bound", testToken, flipped, http.StatusUnprocessableEntity}, + {"stored", testToken, good, http.StatusOK}, + {"duplicate", testToken, good, http.StatusConflict}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if status, body := do(t, http.MethodPost, srv.URL+"/v1/approvals", tc.token, tc.body); status != tc.status { + t.Fatalf("status = %d, want %d: %s", status, tc.status, body) + } + }) + } + + if status, _ := do(t, http.MethodGet, srv.URL+"/v1/approvals", "", nil); status != http.StatusUnauthorized { + t.Fatalf("export without token = %d", status) + } + rm, err := dataset.NewRemote(srv.URL, func() (string, error) { return testToken, nil }, 0) + if err != nil { + t.Fatal(err) + } + out, err := rm.Export(ctx) + if err != nil || strings.Count(out, "\n") != 1 || !strings.Contains(out, `"example_id":"p1"`) { + t.Fatalf("export = %q, %v", out, err) + } + if err := rm.AppendExample(ctx, ex); err != nil { + t.Fatalf("idempotent re-append: %v", err) + } +} diff --git a/cmd/receipt-writer/main.go b/cmd/receipt-writer/main.go index f999607..f212193 100644 --- a/cmd/receipt-writer/main.go +++ b/cmd/receipt-writer/main.go @@ -41,6 +41,7 @@ import ( "github.com/Clawdlinux/agentgate/pkg/receiptspec" + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" ) @@ -106,13 +107,18 @@ func run(ctx context.Context, getenv func(string) string, logger *log.Logger) er return err } defer func() { _ = w.Close() }() + ds, err := dataset.Open(cfg.dataDir, w.Lookup) + if err != nil { + return err + } + defer func() { _ = ds.Close() }() ln, err := net.Listen("tcp", cfg.addr) if err != nil { return err } srv := &http.Server{ - Handler: newHandler(w, token, logger), + Handler: newHandler(w, ds, token, logger), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 60 * time.Second, @@ -203,17 +209,20 @@ func loadToken(path string) (string, error) { type server struct { w *receipts.LocalWriter + ds *dataset.Store token [32]byte logger *log.Logger } -func newHandler(w *receipts.LocalWriter, token string, logger *log.Logger) http.Handler { - s := &server{w: w, token: sha256.Sum256([]byte(token)), logger: logger} +func newHandler(w *receipts.LocalWriter, ds *dataset.Store, token string, logger *log.Logger) http.Handler { + s := &server{w: w, ds: ds, token: sha256.Sum256([]byte(token)), logger: logger} mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.healthz) mux.HandleFunc("POST /v1/records", s.auth(s.postRecord)) mux.HandleFunc("GET /v1/head", s.auth(s.head)) mux.HandleFunc("GET /v1/export", s.auth(s.export)) + mux.HandleFunc("POST /v1/approvals", s.auth(s.postApproval)) + mux.HandleFunc("GET /v1/approvals", s.auth(s.exportApprovals)) return mux } @@ -294,6 +303,47 @@ func (s *server) export(w http.ResponseWriter, _ *http.Request) { writeJSON(w, e) } +func (s *server) postApproval(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, dataset.MaxExampleBody) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + var ex dataset.Example + if err := dec.Decode(&ex); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + writeError(w, http.StatusRequestEntityTooLarge, "example too large") + return + } + writeError(w, http.StatusBadRequest, "invalid example JSON") + return + } + if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "trailing data after example") + return + } + switch err := s.ds.Append(ex); { + case err == nil: + writeJSON(w, map[string]uint64{"receipt_seq": ex.ReceiptSeq}) + case errors.Is(err, dataset.ErrDuplicate): + writeError(w, http.StatusConflict, "example already recorded") + case errors.Is(err, dataset.ErrUnbound): + writeError(w, http.StatusUnprocessableEntity, "example does not bind to its receipt") + default: + s.logger.Printf("approval append failed: %v", err) + writeError(w, http.StatusBadRequest, "invalid example") + } +} + +func (s *server) exportApprovals(w http.ResponseWriter, _ *http.Request) { + data, err := s.ds.Export() + if err != nil { + s.logger.Printf("approvals export failed: %v", err) + writeError(w, http.StatusInternalServerError, "export failed") + return + } + writeJSON(w, dataset.ExportResponse{Format: dataset.ExportFormat, ApprovalsJSONL: data}) +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") diff --git a/cmd/receipt-writer/main_test.go b/cmd/receipt-writer/main_test.go index 6ea407c..af8df90 100644 --- a/cmd/receipt-writer/main_test.go +++ b/cmd/receipt-writer/main_test.go @@ -22,6 +22,7 @@ import ( "github.com/Clawdlinux/agentgate/pkg/receiptspec" + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" "github.com/Clawdlinux/agentic-operator-core/pkg/decision" "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" ) @@ -45,14 +46,26 @@ func testRecord(t *testing.T) receipts.DecisionRecord { func newTestServer(t *testing.T) *httptest.Server { t.Helper() - w, err := receipts.OpenLocal(t.TempDir(), ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, 32))) + srv, _ := newTestServerWriter(t) + return srv +} + +func newTestServerWriter(t *testing.T) (*httptest.Server, *receipts.LocalWriter) { + t.Helper() + dir := t.TempDir() + w, err := receipts.OpenLocal(dir, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, 32))) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = w.Close() }) - srv := httptest.NewServer(newHandler(w, testToken, log.New(io.Discard, "", 0))) + ds, err := dataset.Open(dir, w.Lookup) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ds.Close() }) + srv := httptest.NewServer(newHandler(w, ds, testToken, log.New(io.Discard, "", 0))) t.Cleanup(srv.Close) - return srv + return srv, w } func do(t *testing.T, method, url, token string, body []byte) (int, []byte) { diff --git a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml index 9840f4c..41b20e0 100644 --- a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml +++ b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml @@ -65,6 +65,24 @@ spec: items: type: string type: array + approvalCapture: + description: |- + approvalCapture controls what the approval dataset stores about the + action content of each human decision. See docs/approvals.md. + properties: + content: + default: redacted + description: |- + content is none (hashes and class names only), redacted (action name and + params with detected data classes and credentials replaced by [class]), + or full (raw content, may hold personal data, choose it explicitly). + Receipts never hold raw params in any mode. + enum: + - none + - redacted + - full + type: string + type: object autoApproveThreshold: default: "0.95" description: autoApproveThreshold is the confidence level (0-1) above @@ -542,6 +560,33 @@ spec: - name type: object type: array + lastApproval: + description: lastApproval records the last human decision the controller + handled. + properties: + approverSHA256: + type: string + decidedAt: + format: date-time + type: string + decision: + type: string + decisionReceiptSeq: + format: int64 + type: integer + id: + type: string + outcome: + description: outcome is executed, failed, rejected, denied, or + unknown. + type: string + required: + - approverSHA256 + - decidedAt + - decision + - id + - outcome + type: object lastReconcileTime: description: lastReconcileTime is the last time the resource was reconciled format: date-time @@ -551,6 +596,74 @@ spec: modelRoutingOperationID is the stable idempotency key for the current workload generation's model call. type: string + pendingApproval: + description: pendingApproval is the direct-path action waiting for + a human decision. + properties: + action: + type: string + claimedClusterHealth: + description: claimedClusterHealth is set when the MCP server reported + health. + type: string + clusterHealth: + description: clusterHealth is the value the threshold used, as + a decimal string. + type: string + confidence: + type: string + decision: + description: decision is the label being handled while state is + set. + type: string + description: + type: string + id: + description: |- + id binds a decision to this action. The webhook stamps it into + clawdlinux.org/approval-for. + type: string + proposal: + description: |- + proposal is the raw MCP proposal JSON. approve executes it as is. + It may hold personal data. Receipts and the dataset never copy it raw + unless approvalCapture.content is full. + type: string + proposedAt: + format: date-time + type: string + receiptEntryHash: + type: string + receiptSeq: + description: |- + receiptSeq and receiptEntryHash identify the original decision receipt. + Zero when receipts are off. + format: int64 + type: integer + record: + description: |- + record is the canonical JSON of the original decision record. It holds + hashes and class names only. + type: string + state: + description: |- + state tracks a decision in progress so a replayed reconcile never + executes twice. + enum: + - "" + - Recording + - Executing + type: string + required: + - action + - clusterHealth + - confidence + - description + - id + - proposal + - proposedAt + - record + type: object phase: description: 'phase is the current lifecycle phase: Pending, Running, Completed, Failed' diff --git a/docs/approvals.md b/docs/approvals.md new file mode 100644 index 0000000..20fbd02 --- /dev/null +++ b/docs/approvals.md @@ -0,0 +1,159 @@ +# Human approvals and the approval dataset + +Direct action path only. Approvals need receipts on for the signed dataset. + +When the operator holds an action in `PendingApproval`, a human decides it. +Each decision is signed as a receipt and stored as one labelled example. This +is layer 3 of the [decision architecture](architecture/decision-architecture.md). +The dataset feeds the decision model later (build order step 5). + +## Protocol + +A held action is stored in `status.pendingApproval` with an `id`. The +workload stops re-proposing while it waits. A human records a decision with +annotations: + +| Annotation | Set by | Value | +|---|---|---| +| `clawdlinux.org/approval-decision` | client | `approve`, `reject`, or `edit` | +| `clawdlinux.org/approval-reason` | client | Optional free text, at most 1024 bytes | +| `clawdlinux.org/approval-edit` | client | For `edit`: `{"name", "description", "params"}`, at most 16 KiB | +| `clawdlinux.org/approval-by` | webhook | JSON `{"username", "groups_sha256"}` from the admission request | +| `clawdlinux.org/approval-for` | webhook | The pending action `id` the decision is for | + +With agentctl: + +```sh +agentctl approve my-workload --reason "checked the target" +agentctl reject my-workload --rule budget --reason "too many replicas" +agentctl edit-approve my-workload --edit-file edit.json --reason "smaller change" +``` + +`approve` and `reject` keep their legacy annotations and the Argo resume. They +add the protocol annotations only when a direct-path action is pending. + +## Identity and the webhook + +The approver is never taken from the client. The mutating webhook stamps +`approval-by` and `approval-for` from the admission request user. It rejects: + +- a request that sets either stamp itself, +- a change or removal of a decision already recorded for the pending action + (decisions are append-once per pending action), +- a decision when no action is pending. + +Enable it with `agenticOperator.webhook.agentWorkloadEnabled=true`. This also +turns on the existing validating rules, for example `https` MCP endpoints. + +Before this change the AgentWorkload webhook registered no handler at all. +`SetupWebhookWithManager` now wires the defaulter and validator. + +Without the webhook, the controller refuses any decision that has no +`approval-by` stamp. It sets condition `ApprovalDecision` to `False` with reason +`MissingApprovalStamp` and does not act. Without the webhook a client could +also forge the stamp. So run the webhook in any cluster where approvals matter. + +## What the controller does + +On a stamped decision for the current pending `id`: + +1. Approve and edit re-run invariants, policy packs, and the threshold on the + action that would execute. Edit runs them on the edited action. +2. It records the decision in `status.pendingApproval.state` (`Recording`). +3. It writes a layer `human` receipt: outcome `approved`, `rejected`, or + `edited`. The receipt `human_principal` is the approver username. Reasons + hold the approver, `sha256:` of the reason, and the pending `id`. +4. With `RECEIPTS_REQUIRED=true` and a failed write, it stops. Condition + reason `ReceiptFailed`. The action stays pending. +5. It appends the dataset example. +6. Then it acts: + - `reject`: phase `Rejected`. Never executes. Terminal for the direct path. + - `approve` or `edit` blocked by an invariant, a pack deny, or a pack + error: a deny receipt follows, phase `PolicyDenied`. A human resolves + threshold and pack approval findings. Never invariants. + - otherwise: state `Executing`, then `execute_action`, then `Completed` or + `Failed` as on the allow path. + +`status.lastApproval` records the outcome: `executed`, `failed`, `rejected`, +`denied`, or `unknown`. + +Replays are safe. A stale cached object fails the `Recording` status update +before any side effect. If the operator stops while `Executing`, the next +reconcile does not run the action again. It marks `Failed` with outcome +`unknown`. A crash between the receipt write and the next status update can +write a second identical human receipt. It cannot execute twice. + +## Capture modes + +`spec.approvalCapture.content` controls what the dataset stores about the +action: + +| Mode | Stores | +|---|---| +| `none` | `action_sha256` and detected class names only | +| `redacted` (default) | Name, description, and params. Any string holding a detected class or credential becomes `[class]` | +| `full` | Raw name, description, and params | + +`full` may hold personal data. Choose it explicitly and treat the writer volume +as personal data storage. Map keys are not scanned in `redacted` mode. + +Receipts and decision records never hold raw params, in any mode. + +`status.pendingApproval.proposal` holds the raw MCP proposal while an action +waits, because `approve` must execute it unchanged. Anyone who can read the +workload can read it. + +## Dataset + +The receipt-writer owns `approvals.jsonl` in its data dir. Routes, same bearer +token as receipts: `POST /v1/approvals` (strict decode, 256 KiB cap, unknown +fields and trailing data rejected) and `GET /v1/approvals`. The +writer rejects an example that does not bind to a stored human receipt (422) +and a second example for the same receipt or pending id (409). + +One example per line: + +| Field | Meaning | +|---|---| +| `schema_version` | `1` | +| `example_id` | Pending action `id` | +| `receipt_seq`, `receipt_entry_hash` | The human decision receipt | +| `workload` | Namespace, name, uid | +| `features` | Copied from the original decision record: original seq and input hash, declared decision type and allow lists, observed destination host, data classes, tool, history counts, claimed confidence and health (decimal strings), packs, threshold mode, `escalated_by` layer, reason codes | +| `label` | `approve`, `reject`, or `edit` | +| `reason_sha256`, `approver_sha256` | Hashes only | +| `timestamp` | RFC 3339 UTC | +| `capture_mode`, `content` | Per the capture mode | +| `edit_diff` | For edit: fields changed (`name`, `description`, `params`) and the edited content per mode | + +A failed dataset write is logged and shown in condition `ApprovalDataset`. It +does not block the decision. + +## Export and verify + +```sh +agentctl dataset export --writer http://localhost:8080 \ + --token-file ./token --out ./dataset +agentctl dataset verify ./dataset --trust-root ./pinned-trust.json +``` + +`verify` runs the same chain and record checks as `agentctl receipts verify`. +Then for each example it checks that `receipt_seq` and `receipt_entry_hash` +exist in the chain, that the receipt is a `human` record, and that the label, +pending id, approver hash, reason hash, and workload match it. It prints PASS +or FAIL lines and exits 1 on any failure. + +## Gaps + +- Orchestrated (Argo) workloads: the approval gate is Argo `suspend`, resumed + by `agentctl approve`. That path has no stamped identity, no human receipt, + and no dataset example. Not changed here. +- `agentctl-web` approvals are stamped with the web service account, not the + signed-in user. +- Receipts off means no human receipt and no dataset example. Decisions still + work. +- Lost examples: if the dataset write fails, the label is not retried. +- `Rejected` is terminal. Recreate the workload to retry. +- `POST /v1/approvals` binding check scans the chain files. Fine for now, slow + for very long chains. +- No synthetic seed set yet. The dataset starts empty. diff --git a/docs/architecture/decision-architecture.md b/docs/architecture/decision-architecture.md index e7dee17..7b1b08e 100644 --- a/docs/architecture/decision-architecture.md +++ b/docs/architecture/decision-architecture.md @@ -38,6 +38,11 @@ model id and version, input hash, option set and order, per-option probabilities model decides. Execution outcome and the runtime-adapter path are not receipted. - Layers combine in pkg/decision.Decide. Strictest wins. +- Human approval (layer 3) works on the direct path. Decisions are + annotations; the webhook stamps the approver. Each decision gets a signed + layer "human" receipt and one example in approvals.jsonl. Approve and edit + re-run invariants and packs first. A human never overrides an invariant. + See [approvals](../approvals.md). Gap: Argo approval gates are not covered. - Gap: the runtime-adapter path (reconcileViaRuntime) runs neither invariants nor packs. Packs on that path fail closed. - Input confidence comes from the propose_action tool reply. cluster_health comes from the MCP status reply, default 75 when absent. Platform measures neither. @@ -52,7 +57,9 @@ model id and version, input hash, option set and order, per-option probabilities 2. Real rule engine (OPA Go lib) for invariants and packs. 3. Receipts for every decision (shared receiptspec). Shipped on the direct path, opt-in. See [receipts](../receipts.md). -4. Approval dataset (already in goal prompt phase 4). +4. Approval dataset (already in goal prompt phase 4). Shipped on the direct + path: signed approve, reject, edit receipts plus approvals.jsonl. See + [approvals](../approvals.md). 5. Decision model phase A (escalate-only), evaluated offline on approval dataset first, shadow mode before it can affect anything. 6. Phase B only after shadow results and calibration are documented. diff --git a/docs/receipts.md b/docs/receipts.md index d51cb05..e005939 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -33,15 +33,16 @@ Each receipt is a v1 AgentGate receipt: | `policy_decision` | `allow` for allow. `deny` for deny and require_approval | | `status_code` | 200 allow, 202 require_approval, 403 deny | | `params_sha256` | SHA-256 of the canonical decision record | -| `human_principal` | `system:clawdlinux-operator` | +| `human_principal` | `system:clawdlinux-operator`, or the approver username for layer `human` | | `agent_key_id` | workload UID | The decision record sits beside the receipt in `records.jsonl`. Fields: `schema_version`, `workload` (namespace, name, uid), `action`, `layer` -(`invariant`, `rules`, `threshold`; `human` and `model` reserved), `outcome`, +(`invariant`, `rules`, `threshold`, `human`; `model` reserved), `outcome`, `reasons` (rule ID plus reason), `input_hash`, `declared`, `observed`, `claimed`, `policy_packs`, `threshold_mode`, `replay_hint`. A `model` block is -reserved and absent until a model decides. +reserved and absent until a model decides. Human decisions add an `approval` +block and set `human_principal` to the approver. See [approvals](approvals.md). `input_hash` is the SHA-256 of the canonical JSON of the full decision input: declared, observed, and agent-claimed (labelled `agent_claimed`). It covers @@ -105,15 +106,16 @@ receipt-writer env: | Variable | Meaning | |---|---| -| `RECEIPT_DATA_DIR` | Chain directory. `receipts.jsonl`, `records.jsonl`, `writer.lock` | +| `RECEIPT_DATA_DIR` | Chain directory. `receipts.jsonl`, `records.jsonl`, `approvals.jsonl`, `writer.lock` | | `RECEIPT_KEY_FILE` | Hex Ed25519 seed (32 bytes) or private key (64 bytes) | | `RECEIPT_KEY_GENERATE` | `true` creates the key file if missing. Otherwise the writer refuses to start | | `RECEIPT_TOKEN_FILE` | Bearer token, at least 32 characters. Read at start | | `RECEIPT_ADDR` | Listen address, default `:8080` | -Routes: `POST /v1/records`, `GET /v1/head`, `GET /v1/export` (all bearer -auth), `GET /healthz` (no auth). Bodies over 64 KiB, unknown fields, and -trailing data are rejected. +Routes: `POST /v1/records`, `GET /v1/head`, `GET /v1/export`, +`POST /v1/approvals`, `GET /v1/approvals` (all bearer auth), `GET /healthz` +(no auth). Record bodies over 64 KiB, approval bodies over 256 KiB, unknown +fields, and trailing data are rejected. Helm: @@ -167,8 +169,8 @@ That checks receipts only, not decision records. - Runtime-adapter path (`spec.orchestration`) writes no receipts. - Execution outcome is not receipted. Only the pre-execution decision. -- Human approve, reject, and edit receipts are not written yet. The outcomes - exist in the record schema. +- Human approve, reject, and edit receipts are written on the direct path. See + [approvals](approvals.md). Argo approval gates are not receipted. - Key rotation: not supported. The writer refuses a key change. - Retention and compaction: none. Files grow. Export reads the whole chain. - Writer transport is plain HTTP inside the cluster, guarded by the diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index 75d2980..27b95de 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -45,16 +45,13 @@ import ( "github.com/Clawdlinux/agentic-operator-core/pkg/decision/observe" "github.com/Clawdlinux/agentic-operator-core/pkg/evaluation" "github.com/Clawdlinux/agentic-operator-core/pkg/finops" - "github.com/Clawdlinux/agentic-operator-core/pkg/invariants" "github.com/Clawdlinux/agentic-operator-core/pkg/llm" "github.com/Clawdlinux/agentic-operator-core/pkg/mcp" "github.com/Clawdlinux/agentic-operator-core/pkg/metrics" "github.com/Clawdlinux/agentic-operator-core/pkg/multitenancy" "github.com/Clawdlinux/agentic-operator-core/pkg/resilience" "github.com/Clawdlinux/agentic-operator-core/pkg/routing" - "github.com/Clawdlinux/agentic-operator-core/pkg/rules/engine" "github.com/Clawdlinux/agentic-operator-core/pkg/rules/packs" - "github.com/Clawdlinux/agentic-operator-core/pkg/rules/threshold" runtimeadapter "github.com/Clawdlinux/agentic-operator-core/pkg/runtime" ) @@ -468,6 +465,15 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques return r.reconcileViaRuntime(ctx, &workload) } + // Rejected is terminal on the direct path. Recreate the workload to retry. + if workload.Status.Phase == agenticv1alpha1.PhaseRejected { + return ctrl.Result{}, nil + } + // A held action is resolved by a human decision, not re-proposed. + if workload.Status.Phase == agenticv1alpha1.PhasePendingApproval && workload.Status.PendingApproval != nil { + return r.reconcileApproval(ctx, &workload) + } + // Step 2: Connect to MCP server and fetch status mcpEndpoint := "" if workload.Spec.MCPServerEndpoint != nil { @@ -580,31 +586,12 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques confidenceStr := fmt.Sprintf("%.2f", confidence) - // Create OPA evaluator and evaluate action - // Use the appropriate evaluation mode based on policy setting - evaluator := threshold.NewPolicyEvaluator() - // Determine OPA policy mode with nil guard (default to strict if nil) opaPolicyMode := "strict" if workload.Spec.OPAPolicy != nil { opaPolicyMode = *workload.Spec.OPAPolicy } - - opaInput := &threshold.EvaluationInput{ - ActionType: actionName, - Confidence: confidence, - ClusterHealthScore: clusterHealth, - OPAPolicyMode: opaPolicyMode, - } - - // Apply mode-specific evaluation logic - var opaResult *threshold.EvaluationResult - if opaPolicyMode == "permissive" { - opaResult = evaluator.EvaluatePermissive(opaInput) - } else { - // Default to strict mode - opaResult = evaluator.EvaluateStrict(opaInput) - } + opaResult := evaluateThreshold(opaPolicyMode, actionName, confidence, clusterHealth) log.Info("OPA evaluation result", "allowed", opaResult.Allowed, "confidence", opaResult.Confidence, "reasons", opaResult.Reasons) @@ -626,27 +613,10 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Layers run in order: invariants, policy packs, threshold. Decide keeps // the strictest outcome, so no layer or claim can loosen another. - layers := decision.Layers{ThresholdAllowed: opaResult.Allowed, ThresholdReasons: opaResult.Reasons} - for _, res := range invariants.Check(decisionInput, r.receiptContext(ctx)) { - layers.Invariants = append(layers.Invariants, res.String()) - } - if len(workload.Spec.PolicyPacks) > 0 { - var verdict engine.Verdict - eng, err := engine.LoadPacks(ctx, workload.Spec.PolicyPacks...) - if err == nil { - verdict, err = eng.Eval(ctx, engine.Doc(decisionInput)) - } - layers.PackErr = err - for _, f := range verdict.Deny { - layers.PackDeny = append(layers.PackDeny, f.String()) - } - for _, f := range verdict.RequireApproval { - layers.PackApproval = append(layers.PackApproval, f.String()) - } - } + layers := r.evaluateLayers(ctx, &workload, decisionInput, opaResult) result := decision.Decide(layers) // Write-ahead: the receipt is stored before the action can run. - result = r.recordDecision(ctx, &workload, actionName, decisionInput, layers, result, opaPolicyMode) + result, recorded := r.recordDecision(ctx, &workload, actionName, decisionInput, layers, result, opaPolicyMode) log.Info("decision", "outcome", result.Outcome, "layer", result.Layer, "destination", decisionInput.Observed.Destination, "dataClasses", decisionInput.Observed.DataClasses, "reasons", result.Reasons) // Step 5: Handle action execution or approval pending @@ -717,6 +687,17 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques } } + // A held action waits for a human decision instead of being re-proposed. + workload.Status.PendingApproval = nil + if workload.Status.Phase == agenticv1alpha1.PhasePendingApproval { + pending, err := newPendingApproval(&workload, action, proposal, clusterHealth, claimedHealth, recorded, now) + if err != nil { + log.Error(err, "failed to record pending approval; action will be re-proposed") + } else { + workload.Status.PendingApproval = pending + } + } + // Step 6: Update status if workload.Status.Phase == "" || (workload.Status.Phase != "Completed" && workload.Status.Phase != "Failed" && workload.Status.Phase != "PendingApproval" && workload.Status.Phase != "PolicyDenied") { workload.Status.Phase = "Running" diff --git a/internal/controller/agentworkload_controller_approval_test.go b/internal/controller/agentworkload_controller_approval_test.go new file mode 100644 index 0000000..8cf03ea --- /dev/null +++ b/internal/controller/agentworkload_controller_approval_test.go @@ -0,0 +1,325 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/approval" + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +type fakeApprovals struct { + mu sync.Mutex + examples []dataset.Example +} + +func (f *fakeApprovals) AppendExample(_ context.Context, ex dataset.Example) error { + f.mu.Lock() + defer f.mu.Unlock() + f.examples = append(f.examples, ex) + return nil +} + +// executeLog records execute_action calls and the action they carried. +type executeLog struct { + mu sync.Mutex + actions []string + tools []string +} + +func (l *executeLog) snapshot() ([]string, []string) { + l.mu.Lock() + defer l.mu.Unlock() + return append([]string{}, l.tools...), append([]string{}, l.actions...) +} + +func newExecuteLoggingMCP(t *testing.T, scenario mockMCPScenario, events *eventLog) (*httptest.Server, *executeLog) { + t.Helper() + inner := newMockMCPServer(scenario) + t.Cleanup(inner.Close) + l := &executeLog{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req mockMCPRequest + _ = json.Unmarshal(body, &req) + l.mu.Lock() + l.tools = append(l.tools, req.Tool) + if req.Tool == "execute_action" { + name, _ := req.Params["action"].(string) + l.actions = append(l.actions, name) + } + l.mu.Unlock() + events.add(req.Tool) + r.Body = io.NopCloser(bytes.NewReader(body)) + inner.Config.Handler.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + return srv, l +} + +type approvalEnv struct { + ctx context.Context + k8s client.Client + r *AgentWorkloadReconciler + writer *fakeReceiptWriter + ds *fakeApprovals + exec *executeLog + events *eventLog + key types.NamespacedName + pendingI string +} + +func newApprovalEnv(t *testing.T, name, policy, description string, declared *agenticv1alpha1.DeclaredIntent) *approvalEnv { + t.Helper() + ctx := context.Background() + scheme := newControllerTestScheme(t) + events := &eventLog{} + server, exec := newExecuteLoggingMCP(t, mockMCPScenario{confidence: "0.82", clusterHealth: 90.0, description: description}, events) + objective := "optimize resources" + spec := agenticv1alpha1.AgentWorkloadSpec{MCPServerEndpoint: &server.URL, Objective: &objective, DeclaredIntent: declared} + if policy != "" { + spec.OPAPolicy = &policy + } + wl := &agenticv1alpha1.AgentWorkload{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", UID: types.UID("uid-" + name)}, Spec: spec} + k8s := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&agenticv1alpha1.AgentWorkload{}). + WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, wl).Build() + writer := &fakeReceiptWriter{log: events, ready: true} + ds := &fakeApprovals{} + r := &AgentWorkloadReconciler{Client: k8s, Scheme: scheme, Receipts: ReceiptsConfig{Enabled: true, Required: true, Writer: writer, Approvals: ds}} + env := &approvalEnv{ctx: ctx, k8s: k8s, r: r, writer: writer, ds: ds, exec: exec, events: events, key: types.NamespacedName{Name: name, Namespace: "default"}} + env.reconcile(t) + got := env.get(t) + if got.Status.Phase != agenticv1alpha1.PhasePendingApproval || got.Status.PendingApproval == nil { + t.Fatalf("setup: phase %q pending %v", got.Status.Phase, got.Status.PendingApproval) + } + if got.Status.PendingApproval.ReceiptSeq != 1 || strings.Contains(got.Status.PendingApproval.Record, "AKIA") { + t.Fatalf("setup: pending = %+v", got.Status.PendingApproval) + } + env.pendingI = got.Status.PendingApproval.ID + return env +} + +func (e *approvalEnv) reconcile(t *testing.T) { + t.Helper() + if _, err := e.r.Reconcile(e.ctx, ctrl.Request{NamespacedName: e.key}); err != nil { + t.Fatalf("reconcile: %v", err) + } +} + +func (e *approvalEnv) get(t *testing.T) *agenticv1alpha1.AgentWorkload { + t.Helper() + wl := &agenticv1alpha1.AgentWorkload{} + if err := e.k8s.Get(e.ctx, e.key, wl); err != nil { + t.Fatal(err) + } + return wl +} + +// decide sets the decision annotations as the webhook would leave them. +func (e *approvalEnv) decide(t *testing.T, ann map[string]string, stamp bool, forID string) { + t.Helper() + wl := e.get(t) + if wl.Annotations == nil { + wl.Annotations = map[string]string{} + } + for k, v := range ann { + wl.Annotations[k] = v + } + if stamp { + s, err := approval.Stamp("alice", []string{"sre"}) + if err != nil { + t.Fatal(err) + } + wl.Annotations[approval.AnnotationBy] = s + wl.Annotations[approval.AnnotationFor] = forID + } + if err := e.k8s.Update(e.ctx, wl); err != nil { + t.Fatal(err) + } +} + +func TestReconcile_ApprovalDecisions(t *testing.T) { + t.Parallel() + + credential := `{"name":"notify","description":"send","params":{"key":"AKIAIOSFODNN7EXAMPLE"}}` + tests := []struct { + name string + policy string + description string + ann map[string]string + unstamped bool + staleFor bool + failAppend bool + wantPhase string + wantExecuted []string + wantOutcomes []string + wantExamples int + wantLabel string + wantCond string + }{ + {name: "approve executes original once", ann: map[string]string{approval.AnnotationDecision: approval.Approve, approval.AnnotationReason: "looks fine"}, + wantPhase: "Completed", wantExecuted: []string{"optimize"}, wantOutcomes: []string{receipts.OutcomeApproved}, wantExamples: 1, wantLabel: "approve", wantCond: "Executed"}, + {name: "reject never executes", ann: map[string]string{approval.AnnotationDecision: approval.Reject}, + wantPhase: agenticv1alpha1.PhaseRejected, wantOutcomes: []string{receipts.OutcomeRejected}, wantExamples: 1, wantLabel: "reject", wantCond: "Rejected"}, + {name: "edit executes the edited action", ann: map[string]string{approval.AnnotationDecision: approval.Edit, approval.AnnotationEdit: `{"name":"scale","description":"scale to 2","params":{"replicas":2}}`}, + wantPhase: "Completed", wantExecuted: []string{"scale"}, wantOutcomes: []string{receipts.OutcomeEdited}, wantExamples: 1, wantLabel: "edit", wantCond: "Executed"}, + {name: "edit cannot bypass invariants", ann: map[string]string{approval.AnnotationDecision: approval.Edit, approval.AnnotationEdit: credential}, + wantPhase: "PolicyDenied", wantOutcomes: []string{receipts.OutcomeEdited, receipts.OutcomeDeny}, wantExamples: 1, wantLabel: "edit", wantCond: "HumanDecisionBlocked"}, + {name: "approve cannot bypass an invariant on the original", policy: "permissive", description: "use key AKIAIOSFODNN7EXAMPLE", + ann: map[string]string{approval.AnnotationDecision: approval.Approve}, + wantPhase: "PolicyDenied", wantOutcomes: []string{receipts.OutcomeApproved, receipts.OutcomeDeny}, wantExamples: 1, wantLabel: "approve", wantCond: "HumanDecisionBlocked"}, + {name: "unstamped decision fails closed", ann: map[string]string{approval.AnnotationDecision: approval.Approve}, unstamped: true, + wantPhase: agenticv1alpha1.PhasePendingApproval, wantCond: "MissingApprovalStamp"}, + {name: "stale decision is ignored", ann: map[string]string{approval.AnnotationDecision: approval.Approve}, staleFor: true, + wantPhase: agenticv1alpha1.PhasePendingApproval}, + {name: "required receipt failure does not act", ann: map[string]string{approval.AnnotationDecision: approval.Approve}, failAppend: true, + wantPhase: agenticv1alpha1.PhasePendingApproval, wantCond: "ReceiptFailed"}, + } + for i, tc := range tests { + tc, i := tc, i + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + env := newApprovalEnv(t, "approval-"+string(rune('a'+i)), tc.policy, tc.description, nil) + forID := env.pendingI + if tc.staleFor { + forID = "some-older-action" + } + env.decide(t, tc.ann, !tc.unstamped, forID) + if tc.failAppend { + env.writer.appendErr = errors.New("writer down") + } + before := len(env.writer.records) + env.reconcile(t) + wl := env.get(t) + after := len(env.writer.records) + // A second reconcile may propose a new action. It must not run the decided one again. + env.reconcile(t) + + if wl.Status.Phase != tc.wantPhase { + t.Fatalf("phase = %q, want %q (conditions %#v)", wl.Status.Phase, tc.wantPhase, wl.Status.Conditions) + } + _, executed := env.exec.snapshot() + if !slices.Equal(executed, tc.wantExecuted) { + t.Fatalf("executed = %v, want %v", executed, tc.wantExecuted) + } + var outcomes []string + for _, rec := range env.writer.records[before:after] { + outcomes = append(outcomes, rec.Outcome) + if strings.Contains(rec.Action, "AKIA") || rec.Approval != nil && rec.Approval.Approver != "alice" { + t.Fatalf("bad record %+v", rec) + } + } + if !slices.Equal(outcomes, tc.wantOutcomes) { + t.Fatalf("receipt outcomes = %v, want %v", outcomes, tc.wantOutcomes) + } + if len(env.ds.examples) != tc.wantExamples { + t.Fatalf("examples = %d, want %d", len(env.ds.examples), tc.wantExamples) + } + if tc.wantExamples > 0 { + ex := env.ds.examples[0] + raw, _ := json.Marshal(ex) + if ex.Label != tc.wantLabel || ex.ExampleID != env.pendingI || ex.CaptureMode != dataset.CaptureRedacted || strings.Contains(string(raw), "AKIAIOSFODNN7EXAMPLE") { + t.Fatalf("example = %s", raw) + } + if ex.Features.OriginalSeq != 1 || ex.ReceiptSeq != 2 { + t.Fatalf("example binding = original %d receipt %d", ex.Features.OriginalSeq, ex.ReceiptSeq) + } + } + if tc.wantCond != "" { + c := findCondition(wl.Status.Conditions, approvalDecisionCondition) + if c == nil || c.Reason != tc.wantCond { + t.Fatalf("ApprovalDecision condition = %#v, want reason %q", c, tc.wantCond) + } + } + if wl.Status.Phase != agenticv1alpha1.PhasePendingApproval && tc.wantPhase != "Completed" { + if wl.Status.LastApproval == nil || wl.Status.LastApproval.ID != env.pendingI || wl.Status.PendingApproval != nil { + t.Fatalf("last approval = %+v pending = %+v", wl.Status.LastApproval, wl.Status.PendingApproval) + } + } + }) + } +} + +func TestReconcile_ApprovalHumanReceiptBeforeExecute(t *testing.T) { + t.Parallel() + env := newApprovalEnv(t, "approval-order", "", "", nil) + env.decide(t, map[string]string{approval.AnnotationDecision: approval.Approve}, true, env.pendingI) + start := len(env.events.list()) + env.reconcile(t) + events := env.events.list()[start:] + if r, x := slices.Index(events, "receipt"), slices.Index(events, "execute_action"); r < 0 || x < 0 || r > x { + t.Fatalf("human receipt must precede execute: %v", events) + } +} + +func TestReconcile_ApprovalStaleReplayDoesNothing(t *testing.T) { + t.Parallel() + env := newApprovalEnv(t, "approval-stale", "", "", nil) + env.decide(t, map[string]string{approval.AnnotationDecision: approval.Approve}, true, env.pendingI) + stale := env.get(t) + env.reconcile(t) + records := len(env.writer.records) + // A reconcile working from a stale cached object conflicts before any side effect. + if _, err := env.r.reconcileApproval(env.ctx, stale); err == nil { + t.Fatal("stale replay must fail on conflict") + } + if _, executed := env.exec.snapshot(); len(executed) != 1 || len(env.writer.records) != records { + t.Fatalf("stale replay acted: executed %v, receipts %d -> %d", executed, records, len(env.writer.records)) + } +} + +func TestReconcile_ApprovalExecutingIsAtMostOnce(t *testing.T) { + t.Parallel() + env := newApprovalEnv(t, "approval-crash", "", "", nil) + env.decide(t, map[string]string{approval.AnnotationDecision: approval.Approve}, true, env.pendingI) + wl := env.get(t) + wl.Status.PendingApproval.State = agenticv1alpha1.ApprovalStateExecuting + wl.Status.PendingApproval.Decision = approval.Approve + if err := env.k8s.Status().Update(env.ctx, wl); err != nil { + t.Fatal(err) + } + env.reconcile(t) + wl = env.get(t) + if _, executed := env.exec.snapshot(); len(executed) != 0 { + t.Fatalf("re-executed after crash: %v", executed) + } + if wl.Status.Phase != "Failed" || wl.Status.LastApproval == nil || wl.Status.LastApproval.Outcome != approvalUnknown { + t.Fatalf("phase %q last %+v", wl.Status.Phase, wl.Status.LastApproval) + } +} + +func TestReconcile_RejectedIsTerminal(t *testing.T) { + t.Parallel() + env := newApprovalEnv(t, "approval-terminal", "", "", nil) + env.decide(t, map[string]string{approval.AnnotationDecision: approval.Reject}, true, env.pendingI) + env.reconcile(t) + tools, _ := env.exec.snapshot() + env.reconcile(t) + if after, _ := env.exec.snapshot(); len(after) != len(tools) { + t.Fatalf("rejected workload called MCP again: %v", after[len(tools):]) + } +} diff --git a/internal/controller/approvals.go b/internal/controller/approvals.go new file mode 100644 index 0000000..50e3de8 --- /dev/null +++ b/internal/controller/approvals.go @@ -0,0 +1,400 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + apiMeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/approval" + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + decisioninput "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/observe" + "github.com/Clawdlinux/agentic-operator-core/pkg/invariants" + "github.com/Clawdlinux/agentic-operator-core/pkg/mcp" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" + "github.com/Clawdlinux/agentic-operator-core/pkg/rules/engine" + "github.com/Clawdlinux/agentic-operator-core/pkg/rules/threshold" +) + +// Approval condition types. +const ( + approvalDecisionCondition = "ApprovalDecision" + approvalDatasetCondition = "ApprovalDataset" +) + +// Approval outcomes in status.lastApproval. +const ( + approvalExecuted = "executed" + approvalFailed = "failed" + approvalRejected = "rejected" + approvalDenied = "denied" + approvalUnknown = "unknown" +) + +// evaluateThreshold runs the hardcoded threshold evaluator for mode. +func evaluateThreshold(mode, action string, confidence, health float64) *threshold.EvaluationResult { + in := &threshold.EvaluationInput{ActionType: action, Confidence: confidence, ClusterHealthScore: health, OPAPolicyMode: mode} + if mode == "permissive" { + return threshold.NewPolicyEvaluator().EvaluatePermissive(in) + } + return threshold.NewPolicyEvaluator().EvaluateStrict(in) +} + +// evaluateLayers runs invariants and policy packs on in, next to the given +// threshold result. +func (r *AgentWorkloadReconciler) evaluateLayers(ctx context.Context, wl *agenticv1alpha1.AgentWorkload, in decisioninput.Input, th *threshold.EvaluationResult) decision.Layers { + layers := decision.Layers{ThresholdAllowed: th.Allowed, ThresholdReasons: th.Reasons} + for _, res := range invariants.Check(in, r.receiptContext(ctx)) { + layers.Invariants = append(layers.Invariants, res.String()) + } + if len(wl.Spec.PolicyPacks) > 0 { + var verdict engine.Verdict + eng, err := engine.LoadPacks(ctx, wl.Spec.PolicyPacks...) + if err == nil { + verdict, err = eng.Eval(ctx, engine.Doc(in)) + } + layers.PackErr = err + for _, f := range verdict.Deny { + layers.PackDeny = append(layers.PackDeny, f.String()) + } + for _, f := range verdict.RequireApproval { + layers.PackApproval = append(layers.PackApproval, f.String()) + } + } + return layers +} + +// humanBlocked reports whether a human approval cannot execute the action. +// A human resolves threshold and pack approval findings. Never an invariant, +// a pack deny, or a pack error. +func humanBlocked(l decision.Layers) bool { + return len(l.Invariants) > 0 || l.PackErr != nil || len(l.PackDeny) > 0 +} + +// newPendingApproval holds the proposed action for a human decision. +func newPendingApproval( + wl *agenticv1alpha1.AgentWorkload, + action agenticv1alpha1.Action, + proposal map[string]interface{}, + clusterHealth float64, + claimedHealth *float64, + recorded decided, + now metav1.Time, +) (*agenticv1alpha1.PendingApproval, error) { + prop, err := json.Marshal(proposal) + if err != nil { + return nil, err + } + rec, err := receiptspec.CanonicalRecord(recorded.record) + if err != nil { + return nil, err + } + sum := sha256.Sum256([]byte(strings.Join([]string{string(wl.UID), recorded.record.InputHash, now.UTC().Format(time.RFC3339Nano), string(prop)}, "\n"))) + p := &agenticv1alpha1.PendingApproval{ + ID: hex.EncodeToString(sum[:16]), + Action: action.Name, + Description: action.Description, + Confidence: action.Confidence, + ClusterHealth: strconv.FormatFloat(clusterHealth, 'f', -1, 64), + Proposal: string(prop), + Record: string(rec), + ProposedAt: now, + } + if claimedHealth != nil { + p.ClaimedClusterHealth = strconv.FormatFloat(*claimedHealth, 'f', -1, 64) + } + if recorded.receipt != nil { + p.ReceiptSeq = int64(recorded.receipt.Seq) + p.ReceiptEntryHash = hex.EncodeToString(recorded.receipt.EntryHash[:]) + } + return p, nil +} + +// target is the action a human decision applies to. +type target struct { + name string + description string + payload map[string]interface{} +} + +// reconcileApproval handles a workload holding an action for a human +// decision. Order: validate, persist intent, write the human receipt, then +// act. A replayed reconcile never executes twice. +func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *agenticv1alpha1.AgentWorkload) (ctrl.Result, error) { + log := logf.FromContext(ctx) + p := wl.Status.PendingApproval + now := metav1.Now() + + if p.State == agenticv1alpha1.ApprovalStateExecuting { + // The previous reconcile may have executed. Never run it again. + return r.finishApproval(ctx, wl, p.Decision, approvalUnknown, "", 0, "Failed", + "ExecutionOutcomeUnknown", "the operator stopped while executing the approved action; it was not run again", now) + } + + d, ok, err := approval.Read(wl.Annotations, p.ID) + if err != nil { + reason := "InvalidDecision" + if errors.Is(err, approval.ErrUnstamped) { + reason = "MissingApprovalStamp" + } + return r.holdApproval(ctx, wl, reason, "not acting on the approval decision: "+err.Error(), time.Hour) + } + if !ok { + return ctrl.Result{RequeueAfter: time.Hour}, nil + } + + var proposal map[string]interface{} + dec := json.NewDecoder(bytes.NewReader([]byte(p.Proposal))) + dec.UseNumber() + if err := dec.Decode(&proposal); err != nil { + return r.holdApproval(ctx, wl, "PendingActionUnreadable", "stored proposal is unreadable: "+err.Error(), time.Hour) + } + orig := dataset.Action{Name: p.Action, Description: p.Description, Params: proposalParams(proposal)} + tgt := target{name: p.Action, description: p.Description, payload: proposal} + var edited *dataset.Action + if d.Label == approval.Edit { + tgt = target{name: d.Edit.Name, description: d.Edit.Description, payload: map[string]interface{}{ + "action": d.Edit.Name, "description": d.Edit.Description, "params": d.Edit.Params, + }} + edited = &dataset.Action{Name: d.Edit.Name, Description: d.Edit.Description, Params: d.Edit.Params} + } + + confidence, _ := strconv.ParseFloat(p.Confidence, 64) + health, _ := strconv.ParseFloat(p.ClusterHealth, 64) + var claimedHealth *float64 + if v, err := strconv.ParseFloat(p.ClaimedClusterHealth, 64); err == nil && p.ClaimedClusterHealth != "" { + claimedHealth = &v + } + endpoint := "" + if wl.Spec.MCPServerEndpoint != nil { + endpoint = *wl.Spec.MCPServerEndpoint + } + mode := "strict" + if wl.Spec.OPAPolicy != nil { + mode = *wl.Spec.OPAPolicy + } + priorTotal, priorDenied := observe.History(wl.Status.ExecutedActions, wl.Status.ProposedActions) + in := decisioninput.Input{ + Declared: observe.Declared(wl.Spec.DeclaredIntent), + Observed: observe.Observe(observe.Facts{Endpoint: endpoint, Tool: tgt.name, Payload: tgt.payload, PriorActionCount: priorTotal, PriorDeniedCount: priorDenied}), + Claimed: decisioninput.AgentClaimed{Confidence: &confidence, ClusterHealth: claimedHealth, Intent: tgt.description}, + } + + // Approve and edit re-run every layer on the action that would execute. + var layers decision.Layers + blocked := false + if d.Label != approval.Reject { + layers = r.evaluateLayers(ctx, wl, in, evaluateThreshold(mode, tgt.name, confidence, health)) + blocked = humanBlocked(layers) + } + + // Persist intent first. A stale read fails here, before any side effect. + p.State, p.Decision = agenticv1alpha1.ApprovalStateRecording, d.Label + if err := r.Status().Update(ctx, wl); err != nil { + return ctrl.Result{}, err + } + + var original receipts.DecisionRecord + _ = json.Unmarshal([]byte(p.Record), &original) + var editedFields []string + if edited != nil { + if editedFields, err = dataset.Diff(orig, *edited); err != nil { + editedFields = []string{"unknown"} + } + } + human, err := receipts.NewHumanRecord(receipts.HumanParams{ + Workload: workloadRef(wl), + Action: tgt.name, + Input: in, + PolicyPacks: wl.Spec.PolicyPacks, + ThresholdMode: mode, + Approval: receipts.Approval{ + Label: d.Label, + Approver: d.Approver.Username, + ApproverSHA256: d.ApproverSHA256(), + ReasonSHA256: d.ReasonSHA256(), + PendingID: p.ID, + OriginalSeq: uint64(p.ReceiptSeq), + OriginalInputHash: original.InputHash, + EditedFields: editedFields, + }, + }) + var humanReceipt *receiptspec.Receipt + if r.Receipts.Enabled { + if err == nil { + humanReceipt, err = r.appendRecord(ctx, human) + } + if err != nil && r.Receipts.Required { + log.Error(err, "approval receipt not written; receipts required, not acting", "decision", d.Label) + p.State, p.Decision = "", "" + return r.holdApproval(ctx, wl, "ReceiptFailed", "approval decision receipt could not be written and receipts are required; not acting", time.Minute) + } + if err != nil { + log.Error(err, "approval receipt not written; receipts not required, continuing", "decision", d.Label) + } + } + if humanReceipt != nil { + r.storeExample(ctx, wl, humanReceipt, human, original, orig, edited, now) + } + + ref := tgt.name + switch { + case d.Label == approval.Reject: + return r.finishApproval(ctx, wl, d.Label, approvalRejected, d.ApproverSHA256(), receiptSeq(humanReceipt), + agenticv1alpha1.PhaseRejected, "Rejected", fmt.Sprintf("action %q rejected by %s", ref, d.Approver.Username), now) + case blocked: + // A human cannot override invariants or pack denies. Record the deny. + r.recordDecision(ctx, wl, tgt.name, in, layers, decision.Decide(layers), mode) + return r.finishApproval(ctx, wl, d.Label, approvalDenied, d.ApproverSHA256(), receiptSeq(humanReceipt), + "PolicyDenied", "HumanDecisionBlocked", fmt.Sprintf("human %s of %q blocked: %s", d.Label, ref, strings.Join(decision.Decide(layers).Reasons, "; ")), now) + } + + p.State = agenticv1alpha1.ApprovalStateExecuting + if err := r.Status().Update(ctx, wl); err != nil { + return ctrl.Result{}, err + } + params := map[string]interface{}{"action": tgt.name, "params": tgt.payload, "confidence": p.Confidence} + _, execErr := mcp.NewMCPClient(endpoint).CallTool("execute_action", params) + executed := agenticv1alpha1.Action{Name: tgt.name, Description: tgt.description, Confidence: p.Confidence, Timestamp: &now} + if execErr != nil { + log.Error(execErr, "failed to execute approved action", "action", tgt.name) + executed.Approved = boolPtr(false) + wl.Status.ProposedActions = pruneActions(append(wl.Status.ProposedActions, executed), maxActionsInStatus) + return r.finishApproval(ctx, wl, d.Label, approvalFailed, d.ApproverSHA256(), receiptSeq(humanReceipt), + "Failed", "ExecutionFailed", fmt.Sprintf("approved action %q failed to execute", ref), now) + } + executed.Approved = boolPtr(true) + wl.Status.ExecutedActions = pruneActions(append(wl.Status.ExecutedActions, executed), maxActionsInStatus) + return r.finishApproval(ctx, wl, d.Label, approvalExecuted, d.ApproverSHA256(), receiptSeq(humanReceipt), + "Completed", "Executed", fmt.Sprintf("action %q executed after human %s by %s", ref, d.Label, d.Approver.Username), now) +} + +// storeExample appends the dataset example. A failure is logged and shown in +// a condition. It never blocks the decision. +func (r *AgentWorkloadReconciler) storeExample( + ctx context.Context, + wl *agenticv1alpha1.AgentWorkload, + rc *receiptspec.Receipt, + human, original receipts.DecisionRecord, + orig dataset.Action, + edited *dataset.Action, + now metav1.Time, +) { + if r.Receipts.Approvals == nil { + return + } + ex, err := dataset.NewExample(dataset.ExampleParams{ + Receipt: *rc, Human: human, Original: original, Mode: wl.Spec.CaptureMode(), + Action: orig, Edited: edited, Timestamp: now.UTC().Format(time.RFC3339), + }) + if err == nil { + err = r.Receipts.Approvals.AppendExample(ctx, ex) + } + if err != nil { + logf.FromContext(ctx).Error(err, "approval example not stored", "receiptSeq", rc.Seq) + wl.Status.Conditions = upsertCondition(wl.Status.Conditions, metav1.Condition{ + Type: approvalDatasetCondition, Status: metav1.ConditionFalse, ObservedGeneration: wl.Generation, + Reason: "StoreFailed", Message: fmt.Sprintf("approval example for receipt %d not stored", rc.Seq), LastTransitionTime: now, + }) + } +} + +// holdApproval records why a decision is not acted on and keeps waiting. +func (r *AgentWorkloadReconciler) holdApproval(ctx context.Context, wl *agenticv1alpha1.AgentWorkload, reason, msg string, after time.Duration) (ctrl.Result, error) { + c := apiMeta.FindStatusCondition(wl.Status.Conditions, approvalDecisionCondition) + if c != nil && c.Reason == reason && c.Message == msg && wl.Status.PendingApproval.State == "" { + return ctrl.Result{RequeueAfter: after}, nil + } + wl.Status.Conditions = upsertCondition(wl.Status.Conditions, metav1.Condition{ + Type: approvalDecisionCondition, Status: metav1.ConditionFalse, ObservedGeneration: wl.Generation, + Reason: reason, Message: msg, LastTransitionTime: metav1.Now(), + }) + if err := r.Status().Update(ctx, wl); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: after}, nil +} + +// finishApproval clears the pending action and records the outcome. +func (r *AgentWorkloadReconciler) finishApproval( + ctx context.Context, + wl *agenticv1alpha1.AgentWorkload, + label, outcome, approverSHA string, + seq int64, + phase, reason, msg string, + now metav1.Time, +) (ctrl.Result, error) { + p := wl.Status.PendingApproval + wl.Status.LastApproval = &agenticv1alpha1.ApprovalOutcome{ + ID: p.ID, Decision: label, Outcome: outcome, ApproverSHA256: approverSHA, DecisionReceiptSeq: seq, DecidedAt: now, + } + wl.Status.PendingApproval = nil + wl.Status.Phase = phase + wl.Status.LastReconcileTime = &now + status := metav1.ConditionTrue + if outcome != approvalExecuted && outcome != approvalRejected { + status = metav1.ConditionFalse + } + wl.Status.Conditions = upsertCondition(wl.Status.Conditions, metav1.Condition{ + Type: approvalDecisionCondition, Status: status, ObservedGeneration: wl.Generation, Reason: reason, Message: msg, LastTransitionTime: now, + }) + if c := apiMeta.FindStatusCondition(wl.Status.Conditions, "ApprovalRequired"); c != nil && c.Status == metav1.ConditionTrue { + wl.Status.Conditions = upsertCondition(wl.Status.Conditions, metav1.Condition{ + Type: "ApprovalRequired", Status: metav1.ConditionFalse, ObservedGeneration: wl.Generation, Reason: "HumanDecided", Message: msg, LastTransitionTime: now, + }) + } + if phase == "PolicyDenied" { + wl.Status.Conditions = upsertCondition(wl.Status.Conditions, metav1.Condition{ + Type: "PolicyDenied", Status: metav1.ConditionTrue, ObservedGeneration: wl.Generation, Reason: reason, Message: msg, LastTransitionTime: now, + }) + } + if err := r.Status().Update(ctx, wl); err != nil { + return ctrl.Result{}, err + } + switch phase { + case agenticv1alpha1.PhaseRejected, "PolicyDenied": + return ctrl.Result{RequeueAfter: time.Hour}, nil + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil +} + +// proposalParams is the proposal without the fields kept separately. +func proposalParams(proposal map[string]interface{}) map[string]interface{} { + out := map[string]interface{}{} + for k, v := range proposal { + switch k { + case "action", "description", "confidence": + default: + out[k] = v + } + } + return out +} + +func receiptSeq(rc *receiptspec.Receipt) int64 { + if rc == nil { + return 0 + } + return int64(rc.Seq) +} diff --git a/internal/controller/receipts.go b/internal/controller/receipts.go index 1c36f7a..9a924ed 100644 --- a/internal/controller/receipts.go +++ b/internal/controller/receipts.go @@ -12,7 +12,10 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" "github.com/Clawdlinux/agentic-operator-core/pkg/decision" decisioninput "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" "github.com/Clawdlinux/agentic-operator-core/pkg/invariants" @@ -28,6 +31,8 @@ type ReceiptsConfig struct { Required bool // Writer is nil when misconfigured. With Required set that denies. Writer receipts.Writer + // Approvals stores approval dataset examples. Nil disables the dataset. + Approvals dataset.Appender } // receiptContext tells invariants whether receipts are required and a writer @@ -42,6 +47,13 @@ func (r *AgentWorkloadReconciler) receiptContext(ctx context.Context) invariants } } +// decided is the decision record for one action and, when one was +// written, its receipt. +type decided struct { + record receipts.DecisionRecord + receipt *receiptspec.Receipt +} + // recordDecision appends the decision receipt before the action runs // (write-ahead). If receipts are required and the append fails, it adds the // INV-05 finding and returns the re-decided, denied result. @@ -53,18 +65,23 @@ func (r *AgentWorkloadReconciler) recordDecision( layers decision.Layers, result decision.Result, thresholdMode string, -) decision.Result { +) (decision.Result, decided) { + rec, err := newRecord(wl, action, in, layers, result, thresholdMode) + out := decided{record: rec} if !r.Receipts.Enabled { - return result + return result, out } log := logf.FromContext(ctx) - err := r.appendReceipt(ctx, wl, action, in, layers, result, thresholdMode) if err == nil { - return result + var rc *receiptspec.Receipt + if rc, err = r.appendRecord(ctx, rec); err == nil { + out.receipt = rc + return result, out + } } if !r.Receipts.Required { log.Error(err, "decision receipt not written; receipts not required, continuing", "action", action, "outcome", result.Outcome) - return result + return result, out } log.Error(err, "decision receipt not written; receipts required, failing closed", "action", action) for _, res := range invariants.Check(in, invariants.Context{ReceiptsRequired: true}) { @@ -72,23 +89,21 @@ func (r *AgentWorkloadReconciler) recordDecision( layers.Invariants = append(layers.Invariants, res.String()) } } - return decision.Decide(layers) + result = decision.Decide(layers) + out.record, _ = newRecord(wl, action, in, layers, result, thresholdMode) + return result, out } -func (r *AgentWorkloadReconciler) appendReceipt( - ctx context.Context, +func newRecord( wl *agenticv1alpha1.AgentWorkload, action string, in decisioninput.Input, layers decision.Layers, result decision.Result, thresholdMode string, -) error { - if r.Receipts.Writer == nil { - return errors.New("no receipt writer configured") - } - rec, err := receipts.NewDecisionRecord(receipts.Params{ - Workload: receipts.Workload{Namespace: wl.Namespace, Name: wl.Name, UID: string(wl.UID)}, +) (receipts.DecisionRecord, error) { + return receipts.NewDecisionRecord(receipts.Params{ + Workload: workloadRef(wl), Action: action, Input: in, Layers: layers, @@ -96,13 +111,21 @@ func (r *AgentWorkloadReconciler) appendReceipt( PolicyPacks: wl.Spec.PolicyPacks, ThresholdMode: thresholdMode, }) - if err != nil { - return err +} + +func workloadRef(wl *agenticv1alpha1.AgentWorkload) receipts.Workload { + return receipts.Workload{Namespace: wl.Namespace, Name: wl.Name, UID: string(wl.UID)} +} + +// appendRecord stores rec through the writer and returns its receipt. +func (r *AgentWorkloadReconciler) appendRecord(ctx context.Context, rec receipts.DecisionRecord) (*receiptspec.Receipt, error) { + if r.Receipts.Writer == nil { + return nil, errors.New("no receipt writer configured") } rc, err := r.Receipts.Writer.Append(ctx, rec) if err != nil { - return err + return nil, err } logf.FromContext(ctx).Info("decision receipt written", "seq", rc.Seq, "outcome", rec.Outcome, "layer", rec.Layer) - return nil + return &rc, nil } diff --git a/pkg/agentctl/approve.go b/pkg/agentctl/approve.go index c98c59b..fbb1383 100644 --- a/pkg/agentctl/approve.go +++ b/pkg/agentctl/approve.go @@ -8,10 +8,20 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + + "github.com/Clawdlinux/agentic-operator-core/pkg/approval" ) // ApproveWorkload approves a PendingApproval or Suspended workload. func (c *Client) ApproveWorkload(ctx context.Context, ns, name, approvedBy string) (*ApproveResult, error) { + return c.ApproveWorkloadWithReason(ctx, ns, name, approvedBy, "") +} + +// ApproveWorkloadWithReason approves a workload. When a direct-path action is +// pending it also sets the approval-decision protocol annotations. The +// approved-by annotation is informational. The trusted approver is stamped +// by the admission webhook. +func (c *Client) ApproveWorkloadWithReason(ctx context.Context, ns, name, approvedBy, reason string) (*ApproveResult, error) { wl, err := c.Dynamic.Resource(AgentWorkloadGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get workload %q: %w", name, err) @@ -32,12 +42,22 @@ func (c *Client) ApproveWorkload(ctx context.Context, ns, name, approvedBy strin patchObj := map[string]interface{}{ "metadata": map[string]interface{}{ - "annotations": map[string]string{ + "annotations": map[string]interface{}{ "agentworkload.clawdlinux.io/approved-at": time.Now().UTC().Format(time.RFC3339), "agentworkload.clawdlinux.io/approved-by": approvedBy, }, }, } + decided := PendingActionID(wl.Object) != "" + if decided { + ann, err := decisionAnnotations(approval.Approve, reason, "") + if err != nil { + return nil, err + } + for k, v := range ann { + patchObj["metadata"].(map[string]interface{})["annotations"].(map[string]interface{})[k] = v + } + } patchBytes, jsonErr := json.Marshal(patchObj) if jsonErr != nil { return nil, fmt.Errorf("marshal patch: %w", jsonErr) @@ -51,9 +71,10 @@ func (c *Client) ApproveWorkload(ctx context.Context, ns, name, approvedBy strin } result := &ApproveResult{ - Name: name, - Namespace: ns, - PreviousPhase: phase, + Name: name, + Namespace: ns, + PreviousPhase: phase, + DecisionRecorded: decided, } // Try to resume Argo workflow diff --git a/pkg/agentctl/decide.go b/pkg/agentctl/decide.go new file mode 100644 index 0000000..3b075bf --- /dev/null +++ b/pkg/agentctl/decide.go @@ -0,0 +1,108 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package agentctl + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + + "github.com/Clawdlinux/agentic-operator-core/pkg/approval" +) + +// PendingActionID returns the id of the direct-path action waiting for a +// human decision, or "" when there is none. +func PendingActionID(obj map[string]interface{}) string { + if NestedString(obj, "status", "phase") != "PendingApproval" { + return "" + } + id, _, _ := unstructured.NestedString(obj, "status", "pendingApproval", "id") + return id +} + +// decisionAnnotations renders the approval protocol annotations. Stamps are +// cleared so the admission webhook sets them from the caller identity. +func decisionAnnotations(label, reason, edit string) (map[string]interface{}, error) { + ann := map[string]interface{}{ + approval.AnnotationDecision: label, + approval.AnnotationReason: nil, + approval.AnnotationEdit: nil, + approval.AnnotationBy: nil, + approval.AnnotationFor: nil, + } + if reason != "" { + ann[approval.AnnotationReason] = reason + } + if edit != "" { + ann[approval.AnnotationEdit] = edit + } + strs := map[string]string{approval.AnnotationDecision: label} + if reason != "" { + strs[approval.AnnotationReason] = reason + } + if edit != "" { + strs[approval.AnnotationEdit] = edit + } + // Same field rules the webhook applies, checked early for a clear error. + if err := approval.ValidateDecision(strs); err != nil { + return nil, err + } + return ann, nil +} + +// EditApproveWorkload approves the pending action with a replacement action. +// editJSON is {"name","description","params"}. The operator re-runs +// invariants, packs, and the threshold on it before execution. +func (c *Client) EditApproveWorkload(ctx context.Context, ns, name, editJSON, reason string) (*ApproveResult, error) { + if _, err := approval.ParseEdit(editJSON); err != nil { + return nil, err + } + compact, err := compactJSON(editJSON) + if err != nil { + return nil, err + } + wl, err := c.Dynamic.Resource(AgentWorkloadGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("get workload %q: %w", name, err) + } + phase := NestedString(wl.Object, "status", "phase") + if PendingActionID(wl.Object) == "" { + return &ApproveResult{Name: name, Namespace: ns, PreviousPhase: phase}, + fmt.Errorf("workload %q has no pending direct-path action to edit (phase %q)", name, phase) + } + ann, err := decisionAnnotations(approval.Edit, reason, compact) + if err != nil { + return nil, err + } + if err := c.patchAnnotations(ctx, ns, name, ann); err != nil { + return nil, err + } + return &ApproveResult{Name: name, Namespace: ns, PreviousPhase: phase, DecisionRecorded: true}, nil +} + +func (c *Client) patchAnnotations(ctx context.Context, ns, name string, ann map[string]interface{}) error { + patch, err := json.Marshal(map[string]interface{}{"metadata": map[string]interface{}{"annotations": ann}}) + if err != nil { + return fmt.Errorf("marshal patch: %w", err) + } + if _, err := c.Dynamic.Resource(AgentWorkloadGVR).Namespace(ns).Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}); err != nil { + return fmt.Errorf("patch workload %q: %w", name, err) + } + return nil +} + +func compactJSON(s string) (string, error) { + var buf bytes.Buffer + if err := json.Compact(&buf, []byte(s)); err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/pkg/agentctl/decide_test.go b/pkg/agentctl/decide_test.go new file mode 100644 index 0000000..58b9c2c --- /dev/null +++ b/pkg/agentctl/decide_test.go @@ -0,0 +1,112 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package agentctl + +import ( + "context" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/Clawdlinux/agentic-operator-core/pkg/approval" +) + +func pendingObject(name string, pending bool) *unstructured.Unstructured { + obj := newAgentWorkloadObject(name, "team-a", "PendingApproval", "m", "0") + if pending { + _ = unstructured.SetNestedField(obj.Object, "p1", "status", "pendingApproval", "id") + } + // A stale stamp from an older decision must be cleared by the patch. + _ = unstructured.SetNestedField(obj.Object, "old", "metadata", "annotations", approval.AnnotationFor) + return obj +} + +func annotationsOf(t *testing.T, c *Client, name string) map[string]string { + t.Helper() + got, err := c.Dynamic.Resource(AgentWorkloadGVR).Namespace("team-a").Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + return got.GetAnnotations() +} + +func TestApprovalDecisionAnnotations(t *testing.T) { + t.Parallel() + ctx := context.Background() + tests := []struct { + name string + pending bool + run func(c *Client) (bool, error) + wantErr bool + want map[string]string + wantNone bool + }{ + {name: "approve with reason", pending: true, run: func(c *Client) (bool, error) { + r, err := c.ApproveWorkloadWithReason(ctx, "team-a", "wl", "agentctl", "checked") + return r != nil && r.DecisionRecorded, err + }, want: map[string]string{approval.AnnotationDecision: "approve", approval.AnnotationReason: "checked"}}, + {name: "reject with rule", pending: true, run: func(c *Client) (bool, error) { + r, err := c.RejectWorkload(ctx, "team-a", "wl", "budget", "too big", "agentctl") + return r != nil && r.DecisionRecorded, err + }, want: map[string]string{approval.AnnotationDecision: "reject", approval.AnnotationReason: "rule budget: too big"}}, + {name: "edit approve", pending: true, run: func(c *Client) (bool, error) { + r, err := c.EditApproveWorkload(ctx, "team-a", "wl", "{\n \"name\": \"scale\", \"params\": {\"n\": 2}\n}", "") + return r != nil && r.DecisionRecorded, err + }, want: map[string]string{approval.AnnotationDecision: "edit", approval.AnnotationEdit: `{"name":"scale","params":{"n":2}}`}}, + {name: "approve without pending action keeps legacy behavior", run: func(c *Client) (bool, error) { + r, err := c.ApproveWorkload(ctx, "team-a", "wl", "agentctl") + return r != nil && r.DecisionRecorded, err + }, wantNone: true}, + {name: "edit without pending action fails", run: func(c *Client) (bool, error) { + _, err := c.EditApproveWorkload(ctx, "team-a", "wl", `{"name":"scale"}`, "") + return false, err + }, wantErr: true}, + {name: "invalid edit fails", pending: true, run: func(c *Client) (bool, error) { + _, err := c.EditApproveWorkload(ctx, "team-a", "wl", `{"params":{}}`, "") + return false, err + }, wantErr: true}, + {name: "long reason fails", pending: true, run: func(c *Client) (bool, error) { + _, err := c.ApproveWorkloadWithReason(ctx, "team-a", "wl", "agentctl", strings.Repeat("r", approval.MaxReasonBytes+1)) + return false, err + }, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + c := &Client{Dynamic: newAgentctlDynamicClient(pendingObject("wl", tc.pending))} + recorded, err := tc.run(c) + if tc.wantErr { + if err == nil { + t.Fatal("want error") + } + return + } + if err != nil { + t.Fatal(err) + } + ann := annotationsOf(t, c, "wl") + if tc.wantNone { + if recorded || ann[approval.AnnotationDecision] != "" { + t.Fatalf("decision set without a pending action: %v", ann) + } + return + } + if !recorded { + t.Fatal("DecisionRecorded = false") + } + for k, v := range tc.want { + if ann[k] != v { + t.Fatalf("%s = %q, want %q", k, ann[k], v) + } + } + if _, ok := ann[approval.AnnotationFor]; ok { + t.Fatalf("stale stamp not cleared: %v", ann) + } + }) + } +} diff --git a/pkg/agentctl/reject.go b/pkg/agentctl/reject.go index 3be1806..d4c86e4 100644 --- a/pkg/agentctl/reject.go +++ b/pkg/agentctl/reject.go @@ -4,10 +4,13 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + + "github.com/Clawdlinux/agentic-operator-core/pkg/approval" ) // RejectWorkload rejects a PendingApproval or Suspended workload. @@ -30,7 +33,7 @@ func (c *Client) RejectWorkload(ctx context.Context, ns, name, rule, reason, rej rejectedBy = "agentctl-web" } - annotations := map[string]string{ + annotations := map[string]interface{}{ "agentworkload.clawdlinux.io/rejected-at": time.Now().UTC().Format(time.RFC3339), "agentworkload.clawdlinux.io/rejected-by": rejectedBy, } @@ -40,6 +43,20 @@ func (c *Client) RejectWorkload(ctx context.Context, ns, name, rule, reason, rej if reason != "" { annotations["agentworkload.clawdlinux.io/rejection-reason"] = reason } + decided := PendingActionID(wl.Object) != "" + if decided { + protocolReason := reason + if rule != "" { + protocolReason = strings.TrimSpace("rule " + rule + ": " + reason) + } + ann, err := decisionAnnotations(approval.Reject, protocolReason, "") + if err != nil { + return nil, err + } + for k, v := range ann { + annotations[k] = v + } + } patchObj := map[string]interface{}{ "metadata": map[string]interface{}{ @@ -59,10 +76,11 @@ func (c *Client) RejectWorkload(ctx context.Context, ns, name, rule, reason, rej } return &RejectResult{ - Name: name, - Namespace: ns, - Rule: rule, - Reason: reason, - PreviousPhase: phase, + Name: name, + Namespace: ns, + Rule: rule, + Reason: reason, + PreviousPhase: phase, + DecisionRecorded: decided, }, nil } diff --git a/pkg/agentctl/types.go b/pkg/agentctl/types.go index 279f158..67359d0 100644 --- a/pkg/agentctl/types.go +++ b/pkg/agentctl/types.go @@ -79,6 +79,9 @@ type ApproveResult struct { Namespace string `json:"namespace"` ArgoResumed bool `json:"argoResumed"` PreviousPhase string `json:"previousPhase"` + // DecisionRecorded is true when an approval-decision annotation was set + // for a pending direct-path action. + DecisionRecorded bool `json:"decisionRecorded"` } // RejectResult is the outcome of a reject operation. @@ -88,4 +91,7 @@ type RejectResult struct { Rule string `json:"rule,omitempty"` Reason string `json:"reason,omitempty"` PreviousPhase string `json:"previousPhase"` + // DecisionRecorded is true when an approval-decision annotation was set + // for a pending direct-path action. + DecisionRecorded bool `json:"decisionRecorded"` } diff --git a/pkg/approval/approval.go b/pkg/approval/approval.go new file mode 100644 index 0000000..c9886c2 --- /dev/null +++ b/pkg/approval/approval.go @@ -0,0 +1,257 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +// Package approval holds the human approval annotation protocol on the direct +// action path. It is pure: the admission webhook and the controller both call +// it. See docs/approvals.md. +package approval + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" + "unicode/utf8" +) + +// Annotation keys. Clients set Decision, Reason, and Edit. Only the admission +// webhook sets By and For. +const ( + AnnotationDecision = "clawdlinux.org/approval-decision" + AnnotationReason = "clawdlinux.org/approval-reason" + AnnotationEdit = "clawdlinux.org/approval-edit" + AnnotationBy = "clawdlinux.org/approval-by" + AnnotationFor = "clawdlinux.org/approval-for" +) + +// Decision labels. +const ( + Approve = "approve" + Reject = "reject" + Edit = "edit" +) + +// Limits on client-supplied values. +const ( + MaxReasonBytes = 1024 + MaxEditBytes = 16 << 10 + MaxActionNameBytes = 253 + MaxDescriptionBytes = 4096 +) + +// Errors the controller and webhook report. +var ( + ErrUnstamped = errors.New("approval decision has no " + AnnotationBy + " stamp; the admission webhook must be enabled to accept approvals") + ErrForged = errors.New(AnnotationBy + " and " + AnnotationFor + " are set by the admission webhook only") + ErrFinal = errors.New("a decision is already recorded for the pending action; decisions are append-once") + ErrNoPending = errors.New("no pending action to decide") +) + +// Approver is the identity the webhook stamps from the admission request. +type Approver struct { + Username string `json:"username"` + GroupsSHA256 string `json:"groups_sha256"` +} + +// Stamp renders the approval-by value for a user. Groups are hashed, sorted, +// so group membership is pinned without being listed. +func Stamp(username string, groups []string) (string, error) { + if strings.TrimSpace(username) == "" { + return "", errors.New("admission request has no username") + } + g := append([]string(nil), groups...) + sort.Strings(g) + sum := sha256.Sum256([]byte(strings.Join(g, "\n"))) + b, err := json.Marshal(Approver{Username: username, GroupsSHA256: hex.EncodeToString(sum[:])}) + return string(b), err +} + +// ParseApprover reads an approval-by value. +func ParseApprover(s string) (Approver, error) { + var a Approver + if err := strictDecode([]byte(s), &a); err != nil || a.Username == "" || len(a.GroupsSHA256) != 64 { + return Approver{}, fmt.Errorf("invalid %s value", AnnotationBy) + } + return a, nil +} + +// EditedAction is the replacement action carried by an edit decision. +type EditedAction struct { + Name string `json:"name"` + Description string `json:"description"` + Params map[string]any `json:"params,omitempty"` +} + +// ParseEdit strictly decodes an approval-edit value. +func ParseEdit(s string) (EditedAction, error) { + var e EditedAction + if len(s) > MaxEditBytes { + return e, fmt.Errorf("%s exceeds %d bytes", AnnotationEdit, MaxEditBytes) + } + if err := strictDecode([]byte(s), &e); err != nil { + return EditedAction{}, fmt.Errorf("%s is not valid JSON {name, description, params}: %w", AnnotationEdit, err) + } + switch { + case strings.TrimSpace(e.Name) == "": + return EditedAction{}, fmt.Errorf("%s: name is required", AnnotationEdit) + case len(e.Name) > MaxActionNameBytes: + return EditedAction{}, fmt.Errorf("%s: name exceeds %d bytes", AnnotationEdit, MaxActionNameBytes) + case len(e.Description) > MaxDescriptionBytes: + return EditedAction{}, fmt.Errorf("%s: description exceeds %d bytes", AnnotationEdit, MaxDescriptionBytes) + } + return e, nil +} + +// Decision is a stamped human decision for one pending action. +type Decision struct { + Label string + Reason string + Edit *EditedAction + Approver Approver + ApproverRaw string + For string +} + +// ApproverSHA256 is the hex SHA-256 of the full approval-by stamp. +func (d Decision) ApproverSHA256() string { return SHA256Hex(d.ApproverRaw) } + +// ReasonSHA256 is the hex SHA-256 of the reason, or "" when there is none. +func (d Decision) ReasonSHA256() string { + if d.Reason == "" { + return "" + } + return SHA256Hex(d.Reason) +} + +// SHA256Hex hashes s. +func SHA256Hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// validateFields checks the client-supplied decision fields. +func validateFields(ann map[string]string) (string, *EditedAction, error) { + label := ann[AnnotationDecision] + switch label { + case Approve, Reject, Edit: + default: + return "", nil, fmt.Errorf("%s must be approve, reject, or edit, got %q", AnnotationDecision, label) + } + if r := ann[AnnotationReason]; len(r) > MaxReasonBytes || !utf8.ValidString(r) { + return "", nil, fmt.Errorf("%s must be valid UTF-8 of at most %d bytes", AnnotationReason, MaxReasonBytes) + } + raw, hasEdit := ann[AnnotationEdit] + if label != Edit { + if hasEdit { + return "", nil, fmt.Errorf("%s is only allowed with decision edit", AnnotationEdit) + } + return label, nil, nil + } + if !hasEdit { + return "", nil, fmt.Errorf("decision edit needs %s", AnnotationEdit) + } + e, err := ParseEdit(raw) + if err != nil { + return "", nil, err + } + return label, &e, nil +} + +// ValidateDecision checks the client-set decision fields without stamping. +func ValidateDecision(ann map[string]string) error { + _, _, err := validateFields(ann) + return err +} + +// Read returns the decision for pendingID. ok is false when no decision is +// set or the decision targets another pending action (stale). An unstamped +// decision returns ErrUnstamped: the controller must not act on it. +func Read(ann map[string]string, pendingID string) (Decision, bool, error) { + if ann[AnnotationDecision] == "" || pendingID == "" { + return Decision{}, false, nil + } + by := ann[AnnotationBy] + if by == "" { + return Decision{}, true, ErrUnstamped + } + if ann[AnnotationFor] != pendingID { + return Decision{}, false, nil + } + approver, err := ParseApprover(by) + if err != nil { + return Decision{}, true, err + } + label, edit, err := validateFields(ann) + if err != nil { + return Decision{}, true, err + } + return Decision{Label: label, Reason: ann[AnnotationReason], Edit: edit, Approver: approver, ApproverRaw: by, For: pendingID}, true, nil +} + +var protocolKeys = []string{AnnotationDecision, AnnotationReason, AnnotationEdit, AnnotationBy, AnnotationFor} + +// Admit applies the webhook rules to an update from oldAnn to newAnn. +// pendingID is the pending action in the stored object, "" if none. On a new +// decision it stamps approval-by and approval-for into newAnn. It returns an +// error when the request must be rejected. +func Admit(oldAnn, newAnn map[string]string, pendingID, username string, groups []string) error { + changed := false + for _, k := range protocolKeys { + ov, oOK := oldAnn[k] + nv, nOK := newAnn[k] + if ov != nv || oOK != nOK { + changed = true + } + } + if !changed { + return nil + } + for _, k := range []string{AnnotationBy, AnnotationFor} { + if nv := newAnn[k]; nv != "" && nv != oldAnn[k] { + return ErrForged + } + } + if oldAnn[AnnotationDecision] != "" && pendingID != "" && oldAnn[AnnotationFor] == pendingID { + return ErrFinal + } + if newAnn[AnnotationDecision] == "" { + // Clearing a stale or absent decision. Stamps go with it. + if newAnn[AnnotationBy] != "" || newAnn[AnnotationFor] != "" { + return errors.New("clear " + AnnotationBy + " and " + AnnotationFor + " together with " + AnnotationDecision) + } + return nil + } + if pendingID == "" { + return ErrNoPending + } + if _, _, err := validateFields(newAnn); err != nil { + return err + } + stamp, err := Stamp(username, groups) + if err != nil { + return err + } + newAnn[AnnotationBy] = stamp + newAnn[AnnotationFor] = pendingID + return nil +} + +func strictDecode(data []byte, v any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + dec.UseNumber() + if err := dec.Decode(v); err != nil { + return err + } + if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("trailing data") + } + return nil +} diff --git a/pkg/approval/approval_test.go b/pkg/approval/approval_test.go new file mode 100644 index 0000000..69030ad --- /dev/null +++ b/pkg/approval/approval_test.go @@ -0,0 +1,158 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package approval + +import ( + "errors" + "strings" + "testing" +) + +func stamped(t *testing.T, ann map[string]string, pendingID string) map[string]string { + t.Helper() + s, err := Stamp("alice", []string{"sre", "dev"}) + if err != nil { + t.Fatal(err) + } + ann[AnnotationBy] = s + ann[AnnotationFor] = pendingID + return ann +} + +func TestStampIsOrderIndependent(t *testing.T) { + a, _ := Stamp("alice", []string{"b", "a"}) + b, _ := Stamp("alice", []string{"a", "b"}) + if a != b { + t.Fatalf("stamps differ: %s vs %s", a, b) + } + if strings.Contains(a, "\"a\"") { + t.Fatalf("groups leaked in stamp: %s", a) + } + if _, err := Stamp("", nil); err == nil { + t.Fatal("empty username stamped") + } + ap, err := ParseApprover(a) + if err != nil || ap.Username != "alice" { + t.Fatalf("parse = %+v, %v", ap, err) + } +} + +func TestAdmit(t *testing.T) { + stale := stamped(t, map[string]string{AnnotationDecision: Approve}, "old") + live := stamped(t, map[string]string{AnnotationDecision: Approve}, "p1") + tests := []struct { + name string + old, new map[string]string + pending string + wantErr error + wantAny bool + wantStamp bool + }{ + {name: "unrelated update passes", old: map[string]string{"x": "1"}, new: map[string]string{"x": "2"}, pending: "p1"}, + {name: "new decision is stamped", old: nil, new: map[string]string{AnnotationDecision: Approve}, pending: "p1", wantStamp: true}, + {name: "client set approval-by rejected", new: map[string]string{AnnotationDecision: Approve, AnnotationBy: `{"username":"root"}`}, pending: "p1", wantErr: ErrForged}, + {name: "client set approval-for rejected", new: map[string]string{AnnotationDecision: Approve, AnnotationFor: "p1"}, pending: "p1", wantErr: ErrForged}, + {name: "no pending action rejected", new: map[string]string{AnnotationDecision: Approve}, pending: "", wantErr: ErrNoPending}, + {name: "changing a live decision rejected", old: live, new: copyWith(live, AnnotationDecision, Reject), pending: "p1", wantErr: ErrFinal}, + {name: "removing a live decision rejected", old: live, new: map[string]string{}, pending: "p1", wantErr: ErrFinal}, + {name: "unchanged live decision passes", old: live, new: copyWith(live, "other", "x"), pending: "p1"}, + {name: "stale decision replaced", old: stale, new: copyWith(stale, AnnotationDecision, Reject), pending: "p1", wantStamp: true}, + {name: "stale decision cleared", old: stale, new: map[string]string{}, pending: "p1"}, + {name: "partial clear rejected", old: stale, new: map[string]string{AnnotationBy: stale[AnnotationBy]}, pending: "p1", wantAny: true}, + {name: "bad label rejected", new: map[string]string{AnnotationDecision: "yes"}, pending: "p1", wantAny: true}, + {name: "long reason rejected", new: map[string]string{AnnotationDecision: Reject, AnnotationReason: strings.Repeat("a", MaxReasonBytes+1)}, pending: "p1", wantAny: true}, + {name: "edit without payload rejected", new: map[string]string{AnnotationDecision: Edit}, pending: "p1", wantAny: true}, + {name: "edit payload with approve rejected", new: map[string]string{AnnotationDecision: Approve, AnnotationEdit: `{"name":"x"}`}, pending: "p1", wantAny: true}, + {name: "edit with unknown field rejected", new: map[string]string{AnnotationDecision: Edit, AnnotationEdit: `{"name":"x","extra":1}`}, pending: "p1", wantAny: true}, + {name: "valid edit stamped", new: map[string]string{AnnotationDecision: Edit, AnnotationEdit: `{"name":"scale","description":"d","params":{"n":2}}`}, pending: "p1", wantStamp: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + newAnn := copyWith(tc.new, "", "") + err := Admit(tc.old, newAnn, tc.pending, "alice", []string{"sre"}) + switch { + case tc.wantErr != nil: + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + return + case tc.wantAny: + if err == nil { + t.Fatal("want error") + } + return + case err != nil: + t.Fatalf("unexpected error: %v", err) + } + if tc.wantStamp { + if newAnn[AnnotationFor] != tc.pending || !strings.Contains(newAnn[AnnotationBy], `"username":"alice"`) { + t.Fatalf("not stamped: %v", newAnn) + } + if _, ok, err := Read(newAnn, tc.pending); !ok || err != nil { + t.Fatalf("stamped decision unreadable: %v %v", ok, err) + } + } + }) + } +} + +func TestRead(t *testing.T) { + tests := []struct { + name string + ann map[string]string + pending string + wantOK bool + wantErr error + label string + }{ + {name: "none", ann: map[string]string{}, pending: "p1"}, + {name: "unstamped fails closed", ann: map[string]string{AnnotationDecision: Approve}, pending: "p1", wantOK: true, wantErr: ErrUnstamped}, + {name: "stale ignored", ann: stamped(t, map[string]string{AnnotationDecision: Approve}, "p0"), pending: "p1"}, + {name: "approve", ann: stamped(t, map[string]string{AnnotationDecision: Approve, AnnotationReason: "ok"}, "p1"), pending: "p1", wantOK: true, label: Approve}, + {name: "edit", ann: stamped(t, map[string]string{AnnotationDecision: Edit, AnnotationEdit: `{"name":"n","description":"d"}`}, "p1"), pending: "p1", wantOK: true, label: Edit}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d, ok, err := Read(tc.ann, tc.pending) + if ok != tc.wantOK || !errors.Is(err, tc.wantErr) { + t.Fatalf("ok=%v err=%v, want %v %v", ok, err, tc.wantOK, tc.wantErr) + } + if tc.label != "" && d.Label != tc.label { + t.Fatalf("label = %q", d.Label) + } + if tc.label == Edit && (d.Edit == nil || d.Edit.Name != "n") { + t.Fatalf("edit = %+v", d.Edit) + } + if ok && err == nil && (len(d.ApproverSHA256()) != 64 || d.Reason != "" && len(d.ReasonSHA256()) != 64) { + t.Fatalf("hashes = %q %q", d.ApproverSHA256(), d.ReasonSHA256()) + } + }) + } +} + +func TestParseEditLimits(t *testing.T) { + for _, s := range []string{ + "", "[]", `{"description":"no name"}`, `{"name":"a"} trailing`, + `{"name":"` + strings.Repeat("a", MaxActionNameBytes+1) + `"}`, + `{"name":"a","description":"` + strings.Repeat("d", MaxDescriptionBytes+1) + `"}`, + `{"name":"a","params":{"x":"` + strings.Repeat("p", MaxEditBytes) + `"}}`, + } { + if _, err := ParseEdit(s); err == nil { + t.Fatalf("ParseEdit(%.40q) accepted", s) + } + } +} + +func copyWith(m map[string]string, k, v string) map[string]string { + out := map[string]string{} + for key, val := range m { + out[key] = val + } + if k != "" { + out[k] = v + } + return out +} diff --git a/pkg/dataset/dataset.go b/pkg/dataset/dataset.go new file mode 100644 index 0000000..7648e51 --- /dev/null +++ b/pkg/dataset/dataset.go @@ -0,0 +1,347 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +// Package dataset builds and stores the signed approval dataset. Each example +// is one human decision, bound to its layer "human" decision receipt by seq +// and entry hash. See docs/approvals.md. +package dataset + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataclass" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +// SchemaVersion is the Example schema version. +const SchemaVersion = 1 + +// ApprovalsFile is the dataset file in the writer data dir and in exports. +const ApprovalsFile = "approvals.jsonl" + +// MaxExampleBody caps a POST /v1/approvals body. +const MaxExampleBody = 256 << 10 + +// Capture modes, matching spec.approvalCapture.content. +const ( + CaptureNone = "none" + CaptureRedacted = "redacted" + CaptureFull = "full" +) + +// Features is the decision context copied from the original decision record. +// Numbers from agent claims stay decimal strings. +type Features struct { + OriginalSeq uint64 `json:"original_seq"` + OriginalInputHash string `json:"original_input_hash"` + DecisionType string `json:"decision_type"` + AllowedDataClasses []string `json:"allowed_data_classes"` + AllowedDestinations []string `json:"allowed_destinations"` + ObservedDestination string `json:"observed_destination"` + ObservedDataClasses []string `json:"observed_data_classes"` + Tool string `json:"tool"` + PriorActionCount int `json:"prior_action_count"` + PriorDeniedCount int `json:"prior_denied_count"` + ClaimedConfidence string `json:"claimed_confidence"` + ClaimedClusterHealth string `json:"claimed_cluster_health"` + PolicyPacks []string `json:"policy_packs"` + ThresholdMode string `json:"threshold_mode"` + EscalatedBy string `json:"escalated_by"` + ReasonCodes []string `json:"reason_codes"` +} + +// FeaturesFromRecord copies the features of the original decision record. +func FeaturesFromRecord(rec receipts.DecisionRecord, originalSeq uint64) Features { + codes := []string{} + seen := map[string]bool{} + for _, r := range rec.Reasons { + if !seen[r.RuleID] { + seen[r.RuleID] = true + codes = append(codes, r.RuleID) + } + } + sort.Strings(codes) + return Features{ + OriginalSeq: originalSeq, + OriginalInputHash: rec.InputHash, + DecisionType: rec.Declared.DecisionType, + AllowedDataClasses: nonNil(rec.Declared.AllowedDataClasses), + AllowedDestinations: nonNil(rec.Declared.AllowedDestinations), + ObservedDestination: rec.Observed.DestinationHost, + ObservedDataClasses: nonNil(rec.Observed.DataClasses), + Tool: rec.Observed.Tool, + PriorActionCount: rec.Observed.PriorActionCount, + PriorDeniedCount: rec.Observed.PriorDeniedCount, + ClaimedConfidence: rec.Claimed.Confidence, + ClaimedClusterHealth: rec.Claimed.ClusterHealth, + PolicyPacks: nonNil(rec.PolicyPacks), + ThresholdMode: rec.ThresholdMode, + EscalatedBy: rec.Layer, + ReasonCodes: codes, + } +} + +// Action is the content the human decided on. +type Action struct { + Name string + Description string + Params map[string]any +} + +// Content is what the dataset keeps about an action, per capture mode. +// ActionSHA256 and DataClasses are always set. +type Content struct { + ActionSHA256 string `json:"action_sha256"` + DataClasses []string `json:"data_classes"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Params json.RawMessage `json:"params,omitempty"` +} + +// EditDiff summarizes an edit. Edited follows the capture mode. +type EditDiff struct { + FieldsChanged []string `json:"fields_changed"` + Edited Content `json:"edited"` +} + +// Example is one labelled approval example. +type Example struct { + SchemaVersion int `json:"schema_version"` + ExampleID string `json:"example_id"` + ReceiptSeq uint64 `json:"receipt_seq"` + ReceiptEntryHash string `json:"receipt_entry_hash"` + Workload receipts.Workload `json:"workload"` + Features Features `json:"features"` + Label string `json:"label"` + ReasonSHA256 string `json:"reason_sha256"` + ApproverSHA256 string `json:"approver_sha256"` + Timestamp string `json:"timestamp"` + CaptureMode string `json:"capture_mode"` + Content Content `json:"content"` + EditDiff *EditDiff `json:"edit_diff,omitempty"` +} + +// BuildContent renders a for one capture mode. Unknown modes fall back to +// none, the most private. +func BuildContent(mode string, a Action) (Content, error) { + raw, err := json.Marshal(map[string]any{"name": a.Name, "description": a.Description, "params": a.Params}) + if err != nil { + return Content{}, err + } + sum := sha256.Sum256(raw) + classes := dataclass.Strings(dataclass.DetectValue(map[string]any{"name": a.Name, "description": a.Description, "params": a.Params})) + c := Content{ActionSHA256: hex.EncodeToString(sum[:]), DataClasses: nonNil(classes)} + switch mode { + case CaptureRedacted: + c.Name = redactString(a.Name) + c.Description = redactString(a.Description) + if c.Params, err = json.Marshal(redact(a.Params, 0)); err != nil { + return Content{}, err + } + case CaptureFull: + c.Name, c.Description = a.Name, a.Description + if c.Params, err = json.Marshal(a.Params); err != nil { + return Content{}, err + } + } + return c, nil +} + +// Diff lists which of name, description, and params changed. +func Diff(orig, edited Action) ([]string, error) { + out := []string{} + if orig.Name != edited.Name { + out = append(out, "name") + } + if orig.Description != edited.Description { + out = append(out, "description") + } + a, err := json.Marshal(orig.Params) + if err != nil { + return nil, err + } + b, err := json.Marshal(edited.Params) + if err != nil { + return nil, err + } + if string(a) != string(b) { + out = append(out, "params") + } + return out, nil +} + +// redactString replaces s with [class,...] when any data class is detected. +func redactString(s string) string { + cs := dataclass.Strings(dataclass.Detect(s)) + if len(cs) == 0 { + return s + } + return "[" + strings.Join(cs, ",") + "]" +} + +// redact replaces every leaf holding a detected class. Too deep values are +// dropped rather than kept unscanned. +func redact(v any, depth int) any { + if depth > dataclass.MaxDepth { + return "[truncated]" + } + switch t := v.(type) { + case string: + return redactString(t) + case json.Number: + if r := redactString(t.String()); r != t.String() { + return r + } + return t + case float64: + if r := redactString(fmt.Sprintf("%.0f", t)); r != fmt.Sprintf("%.0f", t) { + return r + } + return t + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = redact(e, depth+1) + } + return out + case map[string]any: + out := make(map[string]any, len(t)) + for k, e := range t { + out[k] = redact(e, depth+1) + } + return out + case nil, bool, int, int64: + return t + } + return "[unsupported]" +} + +// ExampleParams are the inputs to NewExample. +type ExampleParams struct { + // Receipt is the layer "human" decision receipt the example binds to. + Receipt receiptspec.Receipt + // Human is the record bound to Receipt. + Human receipts.DecisionRecord + // Original is the record of the decision that escalated to the human. + Original receipts.DecisionRecord + Mode string + Action Action + Edited *Action + // Timestamp is RFC 3339 UTC. + Timestamp string +} + +// NewExample builds the example for one human decision. +func NewExample(p ExampleParams) (Example, error) { + a := p.Human.Approval + if a == nil { + return Example{}, errors.New("dataset: human record has no approval block") + } + mode := p.Mode + switch mode { + case CaptureNone, CaptureRedacted, CaptureFull: + default: + mode = CaptureNone + } + content, err := BuildContent(mode, p.Action) + if err != nil { + return Example{}, err + } + ex := Example{ + SchemaVersion: SchemaVersion, + ExampleID: a.PendingID, + ReceiptSeq: p.Receipt.Seq, + ReceiptEntryHash: hex.EncodeToString(p.Receipt.EntryHash[:]), + Workload: p.Human.Workload, + Features: FeaturesFromRecord(p.Original, a.OriginalSeq), + Label: a.Label, + ReasonSHA256: a.ReasonSHA256, + ApproverSHA256: a.ApproverSHA256, + Timestamp: p.Timestamp, + CaptureMode: mode, + Content: content, + } + if a.Label == receipts.LabelEdit { + if p.Edited == nil { + return Example{}, errors.New("dataset: edit example needs the edited action") + } + fields, err := Diff(p.Action, *p.Edited) + if err != nil { + return Example{}, err + } + edited, err := BuildContent(mode, *p.Edited) + if err != nil { + return Example{}, err + } + ex.EditDiff = &EditDiff{FieldsChanged: fields, Edited: edited} + } + return ex, nil +} + +// Binding failures. +var ( + ErrUnbound = errors.New("dataset: example does not bind to its receipt") + ErrDuplicate = errors.New("dataset: example already recorded") +) + +// Validate checks that ex is well formed and binds to receipt r and its +// canonical record: same seq and entry hash, a layer "human" record whose +// outcome matches the label, and matching pending id and hashes. +func Validate(ex Example, r receiptspec.Receipt, recordJSON []byte) error { + if ex.SchemaVersion != SchemaVersion { + return fmt.Errorf("dataset: unsupported schema_version %d", ex.SchemaVersion) + } + switch ex.CaptureMode { + case CaptureNone, CaptureRedacted, CaptureFull: + default: + return fmt.Errorf("dataset: unknown capture_mode %q", ex.CaptureMode) + } + if ex.ReceiptSeq == 0 || ex.ReceiptSeq != r.Seq || ex.ReceiptEntryHash != hex.EncodeToString(r.EntryHash[:]) { + return fmt.Errorf("%w: seq or entry hash mismatch", ErrUnbound) + } + if err := receiptspec.VerifyRecordBinding(r, recordJSON); err != nil { + return fmt.Errorf("%w: %v", ErrUnbound, err) + } + var rec receipts.DecisionRecord + if err := json.Unmarshal(recordJSON, &rec); err != nil { + return fmt.Errorf("%w: record unreadable", ErrUnbound) + } + want, err := receipts.OutcomeForLabel(ex.Label) + if err != nil { + return err + } + a := rec.Approval + switch { + case rec.Layer != receipts.LayerHuman || a == nil: + return fmt.Errorf("%w: receipt %d is not a human decision", ErrUnbound, r.Seq) + case rec.Outcome != want || a.Label != ex.Label: + return fmt.Errorf("%w: label %q does not match receipt outcome %q", ErrUnbound, ex.Label, rec.Outcome) + case a.PendingID != ex.ExampleID || a.ApproverSHA256 != ex.ApproverSHA256 || a.ReasonSHA256 != ex.ReasonSHA256: + return fmt.Errorf("%w: example id, approver, or reason hash differs from the receipt", ErrUnbound) + case a.OriginalSeq != ex.Features.OriginalSeq: + return fmt.Errorf("%w: original seq differs from the receipt", ErrUnbound) + case rec.Workload != ex.Workload: + return fmt.Errorf("%w: workload differs from the receipt", ErrUnbound) + } + if (ex.Label == receipts.LabelEdit) != (ex.EditDiff != nil) { + return fmt.Errorf("dataset: edit_diff must be set for edit and only for edit") + } + return nil +} + +func nonNil(s []string) []string { + if s == nil { + return []string{} + } + return append([]string(nil), s...) +} diff --git a/pkg/dataset/dataset_test.go b/pkg/dataset/dataset_test.go new file mode 100644 index 0000000..0dea9c2 --- /dev/null +++ b/pkg/dataset/dataset_test.go @@ -0,0 +1,293 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package dataset + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +var testWorkload = receipts.Workload{Namespace: "ns", Name: "wl", UID: "uid-1"} + +var origAction = Action{ + Name: "notify", + Description: "mail a@b.io about the outage", + Params: map[string]any{"to": "a@b.io", "key": "AKIAIOSFODNN7EXAMPLE", "count": json.Number("3")}, +} + +type fixture struct { + dir string + w *receipts.LocalWriter + original receipts.DecisionRecord +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + dir := t.TempDir() + w, err := receipts.OpenLocal(dir, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{9}, 32))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = w.Close() }) + conf := 0.8 + orig, err := receipts.NewDecisionRecord(receipts.Params{ + Workload: testWorkload, + Action: "notify", + Input: input.Input{ + Observed: input.Observed{Destination: "mcp.local", DataClasses: []string{"email"}, Tool: "notify"}, + Claimed: input.AgentClaimed{Confidence: &conf}, + }, + Layers: decision.Layers{PackApproval: []string{"gdpr-eu@v0.1.0/GDPR-EU-03: needs review"}}, + Result: decision.Result{Outcome: decision.RequireApproval, Layer: decision.LayerPack}, + ThresholdMode: "strict", + }) + if err != nil { + t.Fatal(err) + } + if _, err := w.Append(context.Background(), orig); err != nil { + t.Fatal(err) + } + return &fixture{dir: dir, w: w, original: orig} +} + +// decide writes a human receipt for label and returns the matching example. +func (f *fixture) decide(t *testing.T, id, label, mode string, edited *Action) Example { + t.Helper() + human, err := receipts.NewHumanRecord(receipts.HumanParams{ + Workload: testWorkload, + Action: "notify", + Approval: receipts.Approval{ + Label: label, Approver: "alice", ApproverSHA256: strings.Repeat("a", 64), + PendingID: id, OriginalSeq: 1, OriginalInputHash: f.original.InputHash, + }, + }) + if err != nil { + t.Fatal(err) + } + r, err := f.w.Append(context.Background(), human) + if err != nil { + t.Fatal(err) + } + ex, err := NewExample(ExampleParams{Receipt: r, Human: human, Original: f.original, Mode: mode, Action: origAction, Edited: edited, Timestamp: "2026-10-02T00:00:00Z"}) + if err != nil { + t.Fatal(err) + } + return ex +} + +func TestBuildContentModes(t *testing.T) { + tests := []struct { + mode string + wantRaw bool + wantName bool + wantParams bool + }{ + {CaptureNone, false, false, false}, + {CaptureRedacted, false, true, true}, + {CaptureFull, true, true, true}, + {"bogus", false, false, false}, + } + for _, tc := range tests { + t.Run(tc.mode, func(t *testing.T) { + c, err := BuildContent(tc.mode, origAction) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(c) + s := string(raw) + if len(c.ActionSHA256) != 64 || !strings.Contains(s, `"email"`) || !strings.Contains(s, `"credential"`) { + t.Fatalf("hash or classes missing: %s", s) + } + for _, secret := range []string{"a@b.io", "AKIAIOSFODNN7EXAMPLE"} { + if strings.Contains(s, secret) != tc.wantRaw { + t.Fatalf("raw %q present = %v, want %v: %s", secret, !tc.wantRaw, tc.wantRaw, s) + } + } + if (c.Name != "") != tc.wantName || (len(c.Params) > 0) != tc.wantParams { + t.Fatalf("content = %s", s) + } + if tc.mode == CaptureRedacted && (!strings.Contains(s, `"[email]"`) || !strings.Contains(s, `"[credential]"`) || !strings.Contains(s, `"count":3`)) { + t.Fatalf("redacted content = %s", s) + } + }) + } + a, _ := BuildContent(CaptureNone, origAction) + b, _ := BuildContent(CaptureNone, origAction) + if a.ActionSHA256 != b.ActionSHA256 { + t.Fatal("action hash not deterministic") + } +} + +func TestDiff(t *testing.T) { + ed := origAction + ed.Params = map[string]any{"to": "ops"} + got, err := Diff(origAction, ed) + if err != nil || strings.Join(got, ",") != "params" { + t.Fatalf("diff = %v, %v", got, err) + } + ed.Name = "page" + if got, _ := Diff(origAction, ed); strings.Join(got, ",") != "name,params" { + t.Fatalf("diff = %v", got) + } +} + +func TestStoreAppend(t *testing.T) { + f := newFixture(t) + s, err := Open(f.dir, f.w.Lookup) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + edited := &Action{Name: "notify", Description: "page on-call", Params: map[string]any{"to": "oncall"}} + + ok := f.decide(t, "p1", receipts.LabelApprove, CaptureRedacted, nil) + if err := s.Append(ok); err != nil { + t.Fatalf("append: %v", err) + } + if err := s.Append(ok); !errors.Is(err, ErrDuplicate) { + t.Fatalf("duplicate err = %v", err) + } + edit := f.decide(t, "p2", receipts.LabelEdit, CaptureNone, edited) + if edit.EditDiff == nil || strings.Join(edit.EditDiff.FieldsChanged, ",") != "description,params" { + t.Fatalf("edit diff = %+v", edit.EditDiff) + } + if err := s.Append(edit); err != nil { + t.Fatalf("append edit: %v", err) + } + + tamper := []struct { + name string + mut func(*Example) + }{ + {"label differs from receipt", func(e *Example) { e.Label = receipts.LabelReject }}, + {"entry hash differs", func(e *Example) { e.ReceiptEntryHash = strings.Repeat("0", 64) }}, + {"unknown seq", func(e *Example) { e.ReceiptSeq = 99 }}, + {"points at a non-human receipt", func(e *Example) { e.ReceiptSeq = 1 }}, + {"approver hash differs", func(e *Example) { e.ApproverSHA256 = strings.Repeat("c", 64) }}, + {"bad capture mode", func(e *Example) { e.CaptureMode = "raw" }}, + {"edit diff on approve", func(e *Example) { e.EditDiff = &EditDiff{} }}, + } + for _, tc := range tamper { + t.Run(tc.name, func(t *testing.T) { + ex := f.decide(t, "t-"+tc.name, receipts.LabelApprove, CaptureNone, nil) + tc.mut(&ex) + if err := s.Append(ex); err == nil { + t.Fatal("tampered example accepted") + } + }) + } + + // Reopen repairs a torn tail and keeps duplicate detection. + _ = s.Close() + fh, _ := os.OpenFile(filepath.Join(f.dir, ApprovalsFile), os.O_APPEND|os.O_WRONLY, 0o600) + _, _ = fh.WriteString(`{"schema_version":1,"recei`) + _ = fh.Close() + s2, err := Open(f.dir, f.w.Lookup) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s2.Close() }() + if err := s2.Append(ok); !errors.Is(err, ErrDuplicate) { + t.Fatalf("after reopen duplicate err = %v", err) + } + out, _ := s2.Export() + if strings.Count(out, "\n") != 2 || strings.Contains(out, `"recei`+"\n") { + t.Fatalf("export = %q", out) + } +} + +func TestVerify(t *testing.T) { + f := newFixture(t) + s, err := Open(f.dir, f.w.Lookup) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + if err := s.Append(f.decide(t, "p1", receipts.LabelReject, CaptureRedacted, nil)); err != nil { + t.Fatal(err) + } + e, err := f.w.Export() + if err != nil { + t.Fatal(err) + } + good, _ := s.Export() + + rep, err := Verify(e, good, nil) + if err != nil || !rep.OK() || rep.ExamplesBound != 1 { + t.Fatalf("verify good = %+v, %v", rep, err) + } + tests := []struct { + name string + approvals string + mutE func(*receipts.Export) + }{ + {"label flipped", strings.Replace(good, `"label":"reject"`, `"label":"approve"`, 1), nil}, + {"seq not in chain", strings.Replace(good, `"receipt_seq":2`, `"receipt_seq":7`, 1), nil}, + {"duplicate example", good + good, nil}, + {"garbage line", good + "{\n", nil}, + {"record tampered", good, func(x *receipts.Export) { + x.RecordsJSONL = strings.Replace(x.RecordsJSONL, `"outcome":"rejected"`, `"outcome":"approved"`, 1) + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ex := e + if tc.mutE != nil { + tc.mutE(&ex) + } + rep, err := Verify(ex, tc.approvals, nil) + if err != nil { + t.Fatal(err) + } + if rep.OK() { + t.Fatalf("tampered dataset verified: %+v", rep) + } + }) + } +} + +func TestRemote(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/approvals": + w.WriteHeader(http.StatusConflict) + case r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(ExportResponse{Format: ExportFormat, ApprovalsJSONL: "x\n"}) + } + })) + defer srv.Close() + rm, err := NewRemote(srv.URL, func() (string, error) { return "tok", nil }, 0) + if err != nil { + t.Fatal(err) + } + if err := rm.AppendExample(context.Background(), Example{}); err != nil { + t.Fatalf("409 must count as stored: %v", err) + } + if gotAuth != "Bearer tok" { + t.Fatalf("auth = %q", gotAuth) + } + if out, err := rm.Export(context.Background()); err != nil || out != "x\n" { + t.Fatalf("export = %q, %v", out, err) + } + if _, err := NewRemote("ftp://x", func() (string, error) { return "", nil }, 0); err == nil { + t.Fatal("bad URL accepted") + } +} diff --git a/pkg/dataset/remote.go b/pkg/dataset/remote.go new file mode 100644 index 0000000..f556176 --- /dev/null +++ b/pkg/dataset/remote.go @@ -0,0 +1,113 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package dataset + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// ExportFormat names the GET /v1/approvals envelope. +const ExportFormat = "clawdlinux.approvals.export.v1" + +// ExportResponse is the GET /v1/approvals reply. +type ExportResponse struct { + Format string `json:"format"` + ApprovalsJSONL string `json:"approvals_jsonl"` +} + +// Appender stores approval examples. The controller uses it. +type Appender interface { + AppendExample(ctx context.Context, ex Example) error +} + +// Remote is the receipt-writer approvals client. +type Remote struct { + baseURL string + token func() (string, error) + client *http.Client +} + +// NewRemote returns a client for the receipt-writer at baseURL. token is read +// per request. +func NewRemote(baseURL string, token func() (string, error), timeout time.Duration) (*Remote, error) { + u, err := url.Parse(baseURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("dataset: writer URL must be http(s)://host[:port], got %q", baseURL) + } + if token == nil { + return nil, errors.New("dataset: writer token source is required") + } + if timeout <= 0 { + timeout = 5 * time.Second + } + return &Remote{baseURL: strings.TrimRight(baseURL, "/"), token: token, client: &http.Client{Timeout: timeout}}, nil +} + +// AppendExample posts ex. A 409 means it is already stored, which is success. +func (r *Remote) AppendExample(ctx context.Context, ex Example) error { + body, err := json.Marshal(ex) + if err != nil { + return err + } + status, _, err := r.do(ctx, http.MethodPost, "/v1/approvals", body) + if err != nil { + return err + } + if status != http.StatusOK && status != http.StatusConflict { + return fmt.Errorf("dataset: writer POST /v1/approvals: status %d", status) + } + return nil +} + +// Export fetches approvals.jsonl. +func (r *Remote) Export(ctx context.Context) (string, error) { + status, data, err := r.do(ctx, http.MethodGet, "/v1/approvals", nil) + if err != nil { + return "", err + } + if status != http.StatusOK { + return "", fmt.Errorf("dataset: writer GET /v1/approvals: status %d", status) + } + var e ExportResponse + if err := json.Unmarshal(data, &e); err != nil { + return "", fmt.Errorf("dataset: parse export: %w", err) + } + if e.Format != ExportFormat { + return "", fmt.Errorf("dataset: unknown export format %q", e.Format) + } + return e.ApprovalsJSONL, nil +} + +func (r *Remote) do(ctx context.Context, method, path string, body []byte) (int, []byte, error) { + tok, err := r.token() + if err != nil { + return 0, nil, fmt.Errorf("dataset: read writer token: %w", err) + } + req, err := http.NewRequestWithContext(ctx, method, r.baseURL+path, bytes.NewReader(body)) + if err != nil { + return 0, nil, err + } + req.Header.Set("Authorization", "Bearer "+tok) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := r.client.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("dataset: writer %s %s: %w", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(io.LimitReader(resp.Body, 256<<20)) + return resp.StatusCode, data, err +} diff --git a/pkg/dataset/store.go b/pkg/dataset/store.go new file mode 100644 index 0000000..de7b95d --- /dev/null +++ b/pkg/dataset/store.go @@ -0,0 +1,118 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package dataset + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +// LookupFunc returns the receipt and canonical record stored at seq. +type LookupFunc func(seq uint64) (receiptspec.Receipt, json.RawMessage, error) + +// Store appends examples to approvals.jsonl. It lives in the receipt-writer +// process, which already holds the data dir lock, so one Store per dir. +type Store struct { + mu sync.Mutex + path string + f *os.File + lookup LookupFunc + seqs map[uint64]bool + ids map[string]bool + broken bool +} + +// Open opens or creates approvals.jsonl in dir. A torn last line is cut. +func Open(dir string, lookup LookupFunc) (*Store, error) { + if lookup == nil { + return nil, errors.New("dataset: lookup is required") + } + path := filepath.Join(dir, ApprovalsFile) + data, err := os.ReadFile(path) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + keep := bytes.LastIndexByte(data, '\n') + 1 + s := &Store{path: path, lookup: lookup, seqs: map[uint64]bool{}, ids: map[string]bool{}} + for i, line := range bytes.Split(data[:keep], []byte{'\n'}) { + if len(line) == 0 { + continue + } + var ex Example + if err := json.Unmarshal(line, &ex); err != nil { + return nil, fmt.Errorf("dataset: %s line %d: %w", ApprovalsFile, i+1, err) + } + s.seqs[ex.ReceiptSeq], s.ids[ex.ExampleID] = true, true + } + if keep < len(data) { + if err := os.Truncate(path, int64(keep)); err != nil { + return nil, err + } + } + if s.f, err = os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err != nil { + return nil, err + } + return s, nil +} + +// Append validates ex against its receipt and stores it, fsynced. A second +// example for the same receipt or pending id returns ErrDuplicate. +func (s *Store) Append(ex Example) error { + r, rec, err := s.lookup(ex.ReceiptSeq) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnbound, err) + } + if err := Validate(ex, r, rec); err != nil { + return err + } + line, err := json.Marshal(ex) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.broken { + return errors.New("dataset: store broken by an earlier write failure, restart to repair") + } + if s.seqs[ex.ReceiptSeq] || s.ids[ex.ExampleID] { + return ErrDuplicate + } + if _, err = s.f.Write(append(line, '\n')); err == nil { + err = s.f.Sync() + } + if err != nil { + s.broken = true + return fmt.Errorf("dataset: store example: %w", err) + } + s.seqs[ex.ReceiptSeq], s.ids[ex.ExampleID] = true, true + return nil +} + +// Export returns the whole approvals.jsonl. +func (s *Store) Export() (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + data, err := os.ReadFile(s.path) + if errors.Is(err, fs.ErrNotExist) { + return "", nil + } + return string(data), err +} + +// Close closes the file. +func (s *Store) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + return s.f.Close() +} diff --git a/pkg/dataset/verify.go b/pkg/dataset/verify.go new file mode 100644 index 0000000..2298406 --- /dev/null +++ b/pkg/dataset/verify.go @@ -0,0 +1,115 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package dataset + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +// Failure is one example that does not bind. +type Failure struct { + Line int + Seq uint64 + Reason string +} + +// Report is the result of Verify. +type Report struct { + Receipts receipts.Report + ExamplesBound int + Failures []Failure +} + +// OK is true when the receipt export verifies and every example binds. +func (r Report) OK() bool { return r.Receipts.OK() && len(r.Failures) == 0 } + +// ReadDir reads a dataset export dir: a receipts export plus approvals.jsonl. +func ReadDir(dir string) (receipts.Export, string, error) { + e, err := receipts.ReadExport(dir) + if err != nil { + return receipts.Export{}, "", err + } + data, err := os.ReadFile(filepath.Join(dir, ApprovalsFile)) + if errors.Is(err, fs.ErrNotExist) { + return receipts.Export{}, "", fmt.Errorf("dataset: %s missing in %s", ApprovalsFile, dir) + } + return e, string(data), err +} + +// Verify checks the receipt chain and records with receipts.VerifyExport, +// then binds every example to its receipt with Validate. +func Verify(e receipts.Export, approvalsJSONL string, trusted []receiptspec.TrustedKey) (Report, error) { + rep, err := receipts.VerifyExport(e, trusted) + if err != nil { + return Report{}, err + } + out := Report{Receipts: rep} + + b, err := receiptspec.ReadJSONL(bytes.NewReader([]byte(e.ReceiptsJSONL))) + if err != nil { + return Report{}, err + } + bySeq := map[uint64]receiptspec.Receipt{} + for _, r := range b.Receipts { + bySeq[r.Seq] = r + } + records := map[uint64]json.RawMessage{} + sc := bufio.NewScanner(bytes.NewReader([]byte(e.RecordsJSONL))) + sc.Buffer(make([]byte, 1<<20), 1<<24) + for sc.Scan() { + var rl receipts.RecordLine + if json.Unmarshal(sc.Bytes(), &rl) == nil { + records[rl.Seq] = append(json.RawMessage{}, rl.Record...) + } + } + + seen := map[uint64]bool{} + sc = bufio.NewScanner(bytes.NewReader([]byte(approvalsJSONL))) + sc.Buffer(make([]byte, 1<<20), 1<<24) + for line := 1; sc.Scan(); line++ { + raw := bytes.TrimSpace(sc.Bytes()) + if len(raw) == 0 { + continue + } + var ex Example + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&ex); err != nil { + out.Failures = append(out.Failures, Failure{Line: line, Reason: "unparseable example"}) + continue + } + fail := func(reason string) { + out.Failures = append(out.Failures, Failure{Line: line, Seq: ex.ReceiptSeq, Reason: reason}) + } + if seen[ex.ReceiptSeq] { + fail("duplicate receipt_seq") + continue + } + seen[ex.ReceiptSeq] = true + r, ok := bySeq[ex.ReceiptSeq] + if !ok { + fail("receipt_seq not in chain") + continue + } + if err := Validate(ex, r, records[ex.ReceiptSeq]); err != nil { + fail(err.Error()) + continue + } + out.ExamplesBound++ + } + return out, sc.Err() +} diff --git a/pkg/receipts/human_test.go b/pkg/receipts/human_test.go new file mode 100644 index 0000000..eff91e8 --- /dev/null +++ b/pkg/receipts/human_test.go @@ -0,0 +1,95 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +func testApproval(label string) Approval { + return Approval{ + Label: label, + Approver: "alice", + ApproverSHA256: strings.Repeat("a", 64), + ReasonSHA256: strings.Repeat("b", 64), + PendingID: "p1", + OriginalSeq: 1, + EditedFields: []string{"params", "name"}, + } +} + +func TestNewHumanRecord(t *testing.T) { + tests := []struct { + label string + outcome string + pd string + }{ + {LabelApprove, OutcomeApproved, receiptspec.DecisionAllow}, + {LabelReject, OutcomeRejected, receiptspec.DecisionDeny}, + {LabelEdit, OutcomeEdited, receiptspec.DecisionAllow}, + } + for _, tc := range tests { + t.Run(tc.label, func(t *testing.T) { + p := testParams() + rec, err := NewHumanRecord(HumanParams{Workload: p.Workload, Action: p.Action, Input: p.Input, Approval: testApproval(tc.label)}) + if err != nil { + t.Fatal(err) + } + if rec.Layer != LayerHuman || rec.Outcome != tc.outcome || rec.Approval == nil { + t.Fatalf("record = %+v", rec) + } + if got := rec.Approval.EditedFields; got[0] != "name" { + t.Fatalf("edited fields not sorted: %v", got) + } + f, err := Fields(rec) + if err != nil { + t.Fatal(err) + } + if f.HumanPrincipal != "alice" || f.PolicyDecision != tc.pd { + t.Fatalf("fields = %+v", f) + } + raw, _ := json.Marshal(rec) + for _, leak := range []string{"AKIA", "a@b.io"} { + if strings.Contains(string(raw), leak) { + t.Fatalf("human record leaked %q", leak) + } + } + }) + } + if _, err := NewHumanRecord(HumanParams{Approval: testApproval("maybe")}); err == nil { + t.Fatal("unknown label accepted") + } + bad := testApproval(LabelApprove) + bad.Approver = "" + if _, err := NewHumanRecord(HumanParams{Approval: bad}); err == nil { + t.Fatal("missing approver accepted") + } + if _, err := Fields(DecisionRecord{Layer: LayerHuman, Outcome: OutcomeApproved}); err == nil { + t.Fatal("human layer without approval block accepted") + } +} + +func TestLocalWriterLookup(t *testing.T) { + w := openTest(t, t.TempDir(), 5) + rs := appendN(t, w, 3) + r, raw, err := w.Lookup(2) + if err != nil || r.EntryHash != rs[1].EntryHash { + t.Fatalf("lookup 2 = %v, %v", r.Seq, err) + } + if err := receiptspec.VerifyRecordBinding(r, raw); err != nil { + t.Fatalf("record does not bind: %v", err) + } + for _, seq := range []uint64{0, 4} { + if _, _, err := w.Lookup(seq); !errors.Is(err, ErrNoSuchReceipt) { + t.Fatalf("lookup %d err = %v", seq, err) + } + } +} diff --git a/pkg/receipts/local.go b/pkg/receipts/local.go index 89f7bb2..968b199 100644 --- a/pkg/receipts/local.go +++ b/pkg/receipts/local.go @@ -204,6 +204,46 @@ func (w *LocalWriter) Head() (uint64, [32]byte) { return w.chain.Head() } +// ErrNoSuchReceipt means Lookup found no receipt with that seq. +var ErrNoSuchReceipt = errors.New("receipts: no receipt with that seq") + +// Lookup returns the stored receipt and canonical record for seq. It scans the +// files, so it costs O(chain length). +func (w *LocalWriter) Lookup(seq uint64) (receiptspec.Receipt, json.RawMessage, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return receiptspec.Receipt{}, nil, ErrClosed + } + if head, _ := w.chain.Head(); seq == 0 || seq > head { + return receiptspec.Receipt{}, nil, ErrNoSuchReceipt + } + data, err := readOptional(filepath.Join(w.dir, ReceiptsFile)) + if err != nil { + return receiptspec.Receipt{}, nil, err + } + lines, _ := completeLines(data) + if int(seq) > len(lines) { + return receiptspec.Receipt{}, nil, ErrNoSuchReceipt + } + r, err := receiptspec.ParseJSONLReceipt(lines[seq-1]) + if err != nil || r.Seq != seq { + return receiptspec.Receipt{}, nil, fmt.Errorf("receipts: receipt seq %d unreadable", seq) + } + recData, err := readOptional(filepath.Join(w.dir, RecordsFile)) + if err != nil { + return receiptspec.Receipt{}, nil, err + } + recLines, _ := completeLines(recData) + for _, line := range recLines { + var rl RecordLine + if json.Unmarshal(line, &rl) == nil && rl.Seq == seq { + return r, rl.Record, nil + } + } + return receiptspec.Receipt{}, nil, fmt.Errorf("receipts: record for seq %d missing", seq) +} + // KID returns the signer key ID. func (w *LocalWriter) KID() string { return w.kid } diff --git a/pkg/receipts/record.go b/pkg/receipts/record.go index 029e5ba..ca2f07f 100644 --- a/pkg/receipts/record.go +++ b/pkg/receipts/record.go @@ -118,9 +118,92 @@ type DecisionRecord struct { PolicyPacks []string `json:"policy_packs"` ThresholdMode string `json:"threshold_mode"` Model *Model `json:"model,omitempty"` + Approval *Approval `json:"approval,omitempty"` ReplayHint string `json:"replay_hint"` } +// Approval is set on layer "human" records. Approver is the username the +// admission webhook stamped. The full stamp and the reason are hashed only. +type Approval struct { + Label string `json:"label"` + Approver string `json:"approver"` + ApproverSHA256 string `json:"approver_sha256"` + ReasonSHA256 string `json:"reason_sha256"` + PendingID string `json:"pending_id"` + OriginalSeq uint64 `json:"original_seq"` + OriginalInputHash string `json:"original_input_hash"` + EditedFields []string `json:"edited_fields"` +} + +// Human decision labels, as in the approval-decision annotation. +const ( + LabelApprove = "approve" + LabelReject = "reject" + LabelEdit = "edit" +) + +// OutcomeForLabel maps a human label to its record outcome. +func OutcomeForLabel(label string) (string, error) { + switch label { + case LabelApprove: + return OutcomeApproved, nil + case LabelReject: + return OutcomeRejected, nil + case LabelEdit: + return OutcomeEdited, nil + } + return "", fmt.Errorf("receipts: unknown approval label %q", label) +} + +// HumanParams are the inputs to NewHumanRecord. Input is the decision input +// of the action the human decided on: the original for approve and reject, +// the edited action for edit. +type HumanParams struct { + Workload Workload + Action string + Input input.Input + PolicyPacks []string + ThresholdMode string + Approval Approval +} + +// NewHumanRecord builds the layer "human" record for one approval decision. +func NewHumanRecord(p HumanParams) (DecisionRecord, error) { + outcome, err := OutcomeForLabel(p.Approval.Label) + if err != nil { + return DecisionRecord{}, err + } + if p.Approval.Approver == "" || len(p.Approval.ApproverSHA256) != 64 || p.Approval.PendingID == "" { + return DecisionRecord{}, fmt.Errorf("receipts: human record needs approver, approver hash, and pending id") + } + rec, err := NewDecisionRecord(Params{ + Workload: p.Workload, + Action: p.Action, + Input: p.Input, + Result: decision.Result{Outcome: decision.Allow, Layer: decision.LayerThreshold}, + PolicyPacks: p.PolicyPacks, + ThresholdMode: p.ThresholdMode, + }) + if err != nil { + return DecisionRecord{}, err + } + a := p.Approval + a.EditedFields = sortedCopy(a.EditedFields) + reason := "none" + if a.ReasonSHA256 != "" { + reason = "sha256:" + a.ReasonSHA256 + } + rec.Layer = LayerHuman + rec.Outcome = outcome + rec.Reasons = []Reason{ + {RuleID: "approver", Reason: a.Approver}, + {RuleID: "approval-reason", Reason: reason}, + {RuleID: "approval-of", Reason: a.PendingID}, + } + rec.Approval = &a + return rec, nil +} + // Params are the inputs to NewDecisionRecord. type Params struct { Workload Workload @@ -279,8 +362,15 @@ func Fields(rec DecisionRecord) (receiptspec.Fields, error) { if agent == "" { agent = rec.Workload.Namespace + "/" + rec.Workload.Name } + principal := "system:clawdlinux-operator" + if rec.Layer == LayerHuman { + if rec.Approval == nil || rec.Approval.Approver == "" { + return receiptspec.Fields{}, fmt.Errorf("receipts: human record without approver") + } + principal = clip(rec.Approval.Approver, 256, principal) + } return receiptspec.Fields{ - HumanPrincipal: "system:clawdlinux-operator", + HumanPrincipal: principal, AgentKeyID: clip(agent, 128, "unknown-workload"), Service: Service, Action: clip("decision:"+rec.Action, 128, "decision:unknown"), From a126d8ebb7bca270704b3efaae4b749df6e194c8 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:13:22 +0530 Subject: [PATCH 10/54] feat(decision): versioned feature vector, Score, Scorer, escalate-only ApplyModel Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- pkg/decision/features.go | 232 +++++++++++++++++++++++++++++++++++++ pkg/decision/model.go | 126 ++++++++++++++++++++ pkg/decision/model_test.go | 187 ++++++++++++++++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 pkg/decision/features.go create mode 100644 pkg/decision/model.go create mode 100644 pkg/decision/model_test.go diff --git a/pkg/decision/features.go b/pkg/decision/features.go new file mode 100644 index 0000000..1ac4e21 --- /dev/null +++ b/pkg/decision/features.go @@ -0,0 +1,232 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package decision + +import ( + "crypto/sha256" + "encoding/hex" + "strconv" + "strings" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataclass" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +// FeatureSpecVersion names the feature list below. Any change to a name, an +// order, a bucket edge, or a keyword list needs a new version. A model artifact +// trained on another version is refused. tools/decision-train/features.py +// mirrors this file and must change with it. +const FeatureSpecVersion = "v1" + +// FeatureNames is the v1 feature order. Every value is a small integer. +var FeatureNames = []string{ + "dc_personal_count", // distinct personal classes observed, capped at 6 + "dc_credential", // credential class observed + "dc_undeclared_count", // observed classes outside the declared list, capped at 6 + "personal_to_undeclared", // personal data and a destination outside the declared list + "dest_present", // a destination host was observed + "dest_declared", // the destination host is in the declared list + "declared_present", // decision type or an allow list is declared + "decision_automated", // declared decision type is "automated" + "action_destructive", // action name has a destructive keyword token + "prior_actions_bucket", // 0, 1-9, 10-99, 100+ as 0..3 + "prior_denied", // prior denied count, capped at 10 + "conf_absent", // no claimed confidence + "conf_low", // claimed confidence below 0.5 + "conf_mid", // claimed confidence 0.5 to below 0.8 + "health_absent", // no claimed cluster health + "health_low", // claimed cluster health below 50 + "health_mid", // claimed cluster health 50 to below 80 + "pack_any", // any policy pack set + "pack_dpdp_in", // dpdp-in pack set + "pack_gdpr_eu", // gdpr-eu pack set + "esc_invariant", // a non-allow outcome came from invariants + "esc_rules", // a non-allow outcome came from policy packs + "esc_threshold", // a non-allow outcome came from the threshold + "esc_model", // a non-allow outcome came from the model +} + +// DestructiveKeywords marks an action name as destructive when any token of +// the name, split on _ - . / and space, equals one of them. +var DestructiveKeywords = []string{"cleanup", "clear", "delete", "destroy", "drop", "purge", "remove", "reset", "truncate", "wipe"} + +// FeatureInput is what features are derived from. Live decisions and dataset +// examples both map to it, so training and serving see the same values. +type FeatureInput struct { + Input input.Input + Action string + PolicyPacks []string + // EscalatedBy is the record layer (invariant, rules, threshold, model) + // of a non-allow outcome. Empty when the outcome was allow. + EscalatedBy string +} + +// Features is one feature vector at FeatureSpecVersion. Values follows +// FeatureNames. +type Features struct { + SpecVersion string + Values []int64 +} + +// ExtractFeatures derives the feature vector. Same input, same vector. +// Declared purpose is not used: dataset examples do not carry it. +func ExtractFeatures(fi FeatureInput) Features { + d, o, c := fi.Input.Declared, fi.Input.Observed, fi.Input.Claimed + declared := d.DecisionType != "" || len(d.AllowedDataClasses) > 0 || len(d.AllowedDestinations) > 0 + + personal := map[string]bool{} + credential := false + for _, cl := range o.DataClasses { + l := strings.ToLower(strings.TrimSpace(cl)) + if dataclass.IsPersonal(l) { + personal[l] = true + } + if l == string(dataclass.Credential) { + credential = true + } + } + undeclared := 0 + if declared { + allowed := map[string]bool{} + for _, a := range d.AllowedDataClasses { + allowed[strings.ToLower(strings.TrimSpace(a))] = true + } + seen := map[string]bool{} + for _, cl := range o.DataClasses { + l := strings.ToLower(strings.TrimSpace(cl)) + if !allowed[l] && !seen[l] { + seen[l] = true + undeclared++ + } + } + } + host := input.Host(o.Destination) + destDeclared := host != "" && input.Input{ + Declared: input.Declared{AllowedDestinations: d.AllowedDestinations}, + Observed: input.Observed{Destination: host}, + }.DestinationDeclared() + + v := map[string]int64{ + "dc_personal_count": capInt(len(personal), 6), + "dc_credential": b(credential), + "dc_undeclared_count": capInt(undeclared, 6), + "personal_to_undeclared": b(len(personal) > 0 && host != "" && !destDeclared), + "dest_present": b(host != ""), + "dest_declared": b(destDeclared), + "declared_present": b(declared), + "decision_automated": b(strings.EqualFold(strings.TrimSpace(d.DecisionType), "automated")), + "action_destructive": b(IsDestructive(fi.Action)), + "prior_actions_bucket": bucketCount(o.PriorActionCount), + "prior_denied": capInt(o.PriorDeniedCount, 10), + } + switch { + case c.Confidence == nil: + v["conf_absent"] = 1 + case *c.Confidence < 0.5: + v["conf_low"] = 1 + case *c.Confidence < 0.8: + v["conf_mid"] = 1 + } + switch { + case c.ClusterHealth == nil: + v["health_absent"] = 1 + case *c.ClusterHealth < 50: + v["health_low"] = 1 + case *c.ClusterHealth < 80: + v["health_mid"] = 1 + } + for _, p := range fi.PolicyPacks { + v["pack_any"] = 1 + switch name, _, _ := strings.Cut(strings.ToLower(p), "@"); name { + case "dpdp-in": + v["pack_dpdp_in"] = 1 + case "gdpr-eu": + v["pack_gdpr_eu"] = 1 + } + } + switch fi.EscalatedBy { + case "invariant": + v["esc_invariant"] = 1 + case "rules", LayerPack: + v["esc_rules"] = 1 + case LayerThreshold: + v["esc_threshold"] = 1 + case LayerModel: + v["esc_model"] = 1 + } + out := Features{SpecVersion: FeatureSpecVersion, Values: make([]int64, len(FeatureNames))} + for i, n := range FeatureNames { + out.Values[i] = v[n] + } + return out +} + +// Hash is the SHA-256 hex of "clawdlinux-features/\n" followed by +// one "name=value\n" line per feature in order. +func (f Features) Hash() string { + var sb strings.Builder + sb.WriteString("clawdlinux-features/" + f.SpecVersion + "\n") + for i, n := range FeatureNames { + if i < len(f.Values) { + sb.WriteString(n + "=" + strconv.FormatInt(f.Values[i], 10) + "\n") + } + } + sum := sha256.Sum256([]byte(sb.String())) + return hex.EncodeToString(sum[:]) +} + +// Value returns the named feature, or 0 when unknown. +func (f Features) Value(name string) int64 { + for i, n := range FeatureNames { + if n == name && i < len(f.Values) { + return f.Values[i] + } + } + return 0 +} + +// IsDestructive reports whether a token of name is a destructive keyword. +func IsDestructive(name string) bool { + for _, tok := range strings.FieldsFunc(strings.ToLower(name), func(r rune) bool { + return r == '_' || r == '-' || r == '.' || r == '/' || r == ' ' + }) { + for _, k := range DestructiveKeywords { + if tok == k { + return true + } + } + } + return false +} + +func bucketCount(n int) int64 { + switch { + case n <= 0: + return 0 + case n < 10: + return 1 + case n < 100: + return 2 + } + return 3 +} + +func capInt(n, max int) int64 { + if n < 0 { + return 0 + } + if n > max { + return int64(max) + } + return int64(n) +} + +func b(x bool) int64 { + if x { + return 1 + } + return 0 +} diff --git a/pkg/decision/model.go b/pkg/decision/model.go new file mode 100644 index 0000000..aa57d0a --- /dev/null +++ b/pkg/decision/model.go @@ -0,0 +1,126 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package decision + +import ( + "context" + "fmt" +) + +// LayerModel is the layer name when the decision model escalated an action. +const LayerModel = "model" + +// MicroScale is 1.0 in micro-units. Scores are integers so records stay +// float-free. +const MicroScale = 1_000_000 + +// Model options, in record order. +const ( + OptionAllow = "allow" + OptionRequireApproval = "require_approval" +) + +// OptionSet is the fixed option order in scores and records. +var OptionSet = []string{OptionAllow, OptionRequireApproval} + +// ModelMode is spec.decisionModel.mode. +type ModelMode string + +// Model modes. Shadow records the score and changes nothing. Escalate may +// move allow to require_approval and nothing else. +const ( + ModelOff ModelMode = "off" + ModelShadow ModelMode = "shadow" + ModelEscalate ModelMode = "escalate" +) + +// Reason codes the model layer adds. +const ( + ReasonModelEscalate = "model:escalate" + ReasonModelError = "model:error" +) + +// Score is one model score. Every number is an integer. +type Score struct { + ModelID string + Version string + ArtifactSHA256 string + FeatureSpec string + FeatureHash string + // RiskMicro is P(a human would reject or edit) in micro-units, 0..1000000. + RiskMicro int64 + // OptionMicro holds allow and require_approval, summing to MicroScale. + OptionMicro map[string]int64 + // ReasonCodes are the top contributing features, in a fixed order. + ReasonCodes []string + // ThresholdMicro is the escalation threshold the score is compared to. + ThresholdMicro int64 + Calibration string +} + +// Scorer scores one action. It runs in process. No network calls. +type Scorer interface { + Score(ctx context.Context, f Features) (Score, error) +} + +// Options returns the option micro-units for risk, in OptionSet order. +func Options(riskMicro int64) map[string]int64 { + r := ClampMicro(riskMicro) + return map[string]int64{OptionAllow: MicroScale - r, OptionRequireApproval: r} +} + +// ClampMicro bounds v to 0..MicroScale. +func ClampMicro(v int64) int64 { + if v < 0 { + return 0 + } + if v > MicroScale { + return MicroScale + } + return v +} + +// EffectiveThreshold applies a workload override to the artifact threshold. +// The override only counts when it is stricter (lower). A looser or invalid +// override is ignored, so it cannot reduce escalation. +func EffectiveThreshold(artifactMicro int64, override *int64) int64 { + t := ClampMicro(artifactMicro) + if override != nil && *override >= 0 && *override < t { + return *override + } + return t +} + +// ApplyModel is the only place a model score can change an outcome. It is +// escalate-only: in escalate mode an allow becomes require_approval when the +// risk reaches the threshold or the scorer failed. Every other outcome, and +// every other mode, is returned unchanged. It never loosens. +func ApplyModel(base Result, s Score, scoreErr error, mode ModelMode) Result { + if mode != ModelEscalate || base.Outcome != Allow { + return base + } + out := Result{Outcome: RequireApproval, Layer: LayerModel, Reasons: append([]string{}, base.Reasons...)} + if scoreErr != nil { + out.Reasons = append(out.Reasons, ReasonModelError+": "+scoreErr.Error()) + return out + } + if ClampMicro(s.RiskMicro) >= s.ThresholdMicro { + out.Reasons = append(out.Reasons, fmt.Sprintf("%s: risk %d >= threshold %d micro", ReasonModelEscalate, ClampMicro(s.RiskMicro), s.ThresholdMicro)) + return out + } + return base +} + +// Strictness ranks outcomes: allow 0, require_approval 1, deny 2, unknown 2. +func Strictness(o Outcome) int { + switch o { + case Allow: + return 0 + case RequireApproval: + return 1 + } + return 2 +} diff --git a/pkg/decision/model_test.go b/pkg/decision/model_test.go new file mode 100644 index 0000000..678a7d1 --- /dev/null +++ b/pkg/decision/model_test.go @@ -0,0 +1,187 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package decision + +import ( + "errors" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +func fp(f float64) *float64 { return &f } + +func TestExtractFeatures(t *testing.T) { + declared := input.Declared{DecisionType: "automated", AllowedDataClasses: []string{"email"}, AllowedDestinations: []string{"*.example.com"}} + tests := []struct { + name string + fi FeatureInput + want map[string]int64 + }{ + {"empty input", FeatureInput{}, map[string]int64{"conf_absent": 1, "health_absent": 1}}, + {"clean read declared", FeatureInput{ + Input: input.Input{Declared: declared, Observed: input.Observed{Destination: "https://api.example.com/x"}, Claimed: input.AgentClaimed{Confidence: fp(0.9), ClusterHealth: fp(90)}}, + Action: "read_report", + }, map[string]int64{"dest_present": 1, "dest_declared": 1, "declared_present": 1, "decision_automated": 1}}, + {"personal to undeclared", FeatureInput{ + Input: input.Input{Declared: declared, Observed: input.Observed{Destination: "evil.io:443", DataClasses: []string{"email", "phone", "PHONE", "credential"}}, Claimed: input.AgentClaimed{Confidence: fp(0.4), ClusterHealth: fp(60)}}, + Action: "send-email", + }, map[string]int64{ + "dc_personal_count": 2, "dc_credential": 1, "dc_undeclared_count": 2, "personal_to_undeclared": 1, + "dest_present": 1, "declared_present": 1, "decision_automated": 1, "conf_low": 1, "health_mid": 1, + }}, + {"history and packs and escalation", FeatureInput{ + Input: input.Input{Observed: input.Observed{PriorActionCount: 150, PriorDeniedCount: 40}, Claimed: input.AgentClaimed{Confidence: fp(0.6), ClusterHealth: fp(10)}}, + Action: "delete_bucket", + PolicyPacks: []string{"dpdp-in@v0.1.0", "gdpr-eu@v0.1.0"}, + EscalatedBy: "rules", + }, map[string]int64{ + "action_destructive": 1, "prior_actions_bucket": 3, "prior_denied": 10, "conf_mid": 1, "health_low": 1, + "pack_any": 1, "pack_dpdp_in": 1, "pack_gdpr_eu": 1, "esc_rules": 1, + }}, + {"undeclared classes ignored without manifest", FeatureInput{ + Input: input.Input{Observed: input.Observed{DataClasses: []string{"email"}, PriorActionCount: 5}, Claimed: input.AgentClaimed{Confidence: fp(1), ClusterHealth: fp(80)}}, + EscalatedBy: LayerPack, + }, map[string]int64{"dc_personal_count": 1, "prior_actions_bucket": 1, "esc_rules": 1}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f := ExtractFeatures(tc.fi) + if f.SpecVersion != FeatureSpecVersion || len(f.Values) != len(FeatureNames) { + t.Fatalf("bad shape %+v", f) + } + for i, n := range FeatureNames { + if f.Values[i] != tc.want[n] { + t.Errorf("%s = %d, want %d", n, f.Values[i], tc.want[n]) + } + } + if f.Hash() != ExtractFeatures(tc.fi).Hash() || len(f.Hash()) != 64 { + t.Fatal("hash not deterministic") + } + }) + } +} + +func TestFeatureNamesUnique(t *testing.T) { + seen := map[string]bool{} + for _, n := range FeatureNames { + if seen[n] { + t.Fatalf("duplicate feature %s", n) + } + seen[n] = true + } +} + +func TestIsDestructive(t *testing.T) { + for name, want := range map[string]bool{ + "delete": true, "delete_and_restore": true, "users/purge": true, "Drop Table": true, + "deleted": false, "read": false, "cleanupx": false, "": false, "list-items": false, + } { + if got := IsDestructive(name); got != want { + t.Errorf("IsDestructive(%q) = %v", name, got) + } + } +} + +func TestEffectiveThreshold(t *testing.T) { + i := func(v int64) *int64 { return &v } + tests := []struct { + art int64 + override *int64 + want int64 + }{ + {500000, nil, 500000}, + {500000, i(300000), 300000}, + {500000, i(500000), 500000}, + {500000, i(900000), 500000}, + {500000, i(-1), 500000}, + {500000, i(0), 0}, + {2_000_000, nil, MicroScale}, + } + for _, tc := range tests { + if got := EffectiveThreshold(tc.art, tc.override); got != tc.want { + t.Errorf("EffectiveThreshold(%d, %v) = %d, want %d", tc.art, tc.override, got, tc.want) + } + } +} + +func TestApplyModel(t *testing.T) { + allow := Result{Outcome: Allow, Layer: LayerThreshold} + high := Score{RiskMicro: 800000, ThresholdMicro: 500000} + low := Score{RiskMicro: 100000, ThresholdMicro: 500000} + boom := errors.New("boom") + tests := []struct { + name string + base Result + s Score + err error + mode ModelMode + want Outcome + wantLayer string + }{ + {"shadow high risk no effect", allow, high, nil, ModelShadow, Allow, LayerThreshold}, + {"off high risk no effect", allow, high, nil, ModelOff, Allow, LayerThreshold}, + {"escalate high risk", allow, high, nil, ModelEscalate, RequireApproval, LayerModel}, + {"escalate at threshold", allow, Score{RiskMicro: 500000, ThresholdMicro: 500000}, nil, ModelEscalate, RequireApproval, LayerModel}, + {"escalate low risk keeps allow", allow, low, nil, ModelEscalate, Allow, LayerThreshold}, + {"escalate error fails safe", allow, low, boom, ModelEscalate, RequireApproval, LayerModel}, + {"shadow error never blocks", allow, low, boom, ModelShadow, Allow, LayerThreshold}, + {"escalate never loosens deny", Result{Outcome: Deny, Layer: LayerInvariant}, low, nil, ModelEscalate, Deny, LayerInvariant}, + {"escalate keeps approval layer", Result{Outcome: RequireApproval, Layer: LayerPack}, high, nil, ModelEscalate, RequireApproval, LayerPack}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := ApplyModel(tc.base, tc.s, tc.err, tc.mode) + if r.Outcome != tc.want || r.Layer != tc.wantLayer { + t.Fatalf("ApplyModel = %s/%s, want %s/%s", r.Outcome, r.Layer, tc.want, tc.wantLayer) + } + }) + } +} + +// TestApplyModelNeverLoosens checks every combination of outcome, mode, +// risk, threshold, and error. No combination may return a looser outcome. +func TestApplyModelNeverLoosens(t *testing.T) { + outcomes := []Outcome{Allow, RequireApproval, Deny, Outcome("bogus")} + modes := []ModelMode{ModelOff, ModelShadow, ModelEscalate, ModelMode(""), ModelMode("auto-allow")} + values := []int64{-5, 0, 1, 499999, 500000, 500001, MicroScale, MicroScale + 7} + errs := []error{nil, errors.New("x")} + n := 0 + for _, o := range outcomes { + for _, m := range modes { + for _, risk := range values { + for _, th := range values { + for _, e := range errs { + base := Result{Outcome: o, Layer: LayerThreshold} + got := ApplyModel(base, Score{RiskMicro: risk, ThresholdMicro: th}, e, m) + if Strictness(got.Outcome) < Strictness(o) { + t.Fatalf("loosened %s to %s (mode %s risk %d th %d err %v)", o, got.Outcome, m, risk, th, e) + } + if got.Outcome == Allow && o != Allow { + t.Fatalf("model produced allow from %s", o) + } + if m != ModelEscalate && got.Outcome != o { + t.Fatalf("mode %s changed outcome", m) + } + n++ + } + } + } + } + } + if n == 0 { + t.Fatal("no cases") + } +} + +func TestOptions(t *testing.T) { + for _, r := range []int64{-1, 0, 123456, MicroScale, MicroScale + 1} { + o := Options(r) + if o[OptionAllow]+o[OptionRequireApproval] != MicroScale { + t.Fatalf("options %v do not sum", o) + } + } +} From 760b9487f9f8543d71a428c749c30ae0e73ef4d3 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:13:52 +0530 Subject: [PATCH 11/54] feat(decision): hand-weighted baseline scorer as evaluation control Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- pkg/decision/baseline/baseline.go | 112 +++++++++++++++++++++++++ pkg/decision/baseline/baseline_test.go | 71 ++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 pkg/decision/baseline/baseline.go create mode 100644 pkg/decision/baseline/baseline_test.go diff --git a/pkg/decision/baseline/baseline.go b/pkg/decision/baseline/baseline.go new file mode 100644 index 0000000..218f9a8 --- /dev/null +++ b/pkg/decision/baseline/baseline.go @@ -0,0 +1,112 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +// Package baseline is a hand-weighted, non-learned scorer. It is the control +// a learned model must beat in offline evaluation. Weights are additive +// micro-units, set by hand, not fitted to any data. +package baseline + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "sort" + "strconv" + "strings" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" +) + +// ID and Version identify the baseline in scores and records. +const ( + ID = "clawdlinux-baseline-additive" + Version = "1" +) + +// ThresholdMicro escalates at 0.5. +const ThresholdMicro = 500_000 + +// Weights are micro-units added per unit of each feature. Risk is the sum, +// clamped to 0..1000000. Features not listed add nothing. +var Weights = map[string]int64{ + "dc_credential": 1_000_000, // INV-01 denies these anyway + "personal_to_undeclared": 700_000, + "dc_undeclared_count": 200_000, + "dc_personal_count": 50_000, + "decision_automated": 150_000, + "action_destructive": 300_000, + "prior_denied": 60_000, + "conf_absent": 150_000, + "conf_low": 300_000, + "conf_mid": 100_000, + "health_low": 150_000, + "health_mid": 50_000, + "declared_present": -100_000, + "dest_declared": -50_000, +} + +// Scorer is the baseline. The zero value is ready. +type Scorer struct{} + +var _ decision.Scorer = Scorer{} + +// SHA256 identifies the weight table. It stands in for an artifact hash. +func SHA256() string { + names := make([]string, 0, len(Weights)) + for n := range Weights { + names = append(names, n) + } + sort.Strings(names) + var sb strings.Builder + sb.WriteString(ID + "/" + Version + "\n") + for _, n := range names { + sb.WriteString(n + "=" + strconv.FormatInt(Weights[n], 10) + "\n") + } + sum := sha256.Sum256([]byte(sb.String())) + return hex.EncodeToString(sum[:]) +} + +// Score adds the weights of the present features. +func (Scorer) Score(_ context.Context, f decision.Features) (decision.Score, error) { + type contrib struct { + name string + v int64 + } + var risk int64 + var cs []contrib + for i, n := range decision.FeatureNames { + if i >= len(f.Values) { + break + } + c := Weights[n] * f.Values[i] + risk += c + if c > 0 { + cs = append(cs, contrib{n, c}) + } + } + sort.Slice(cs, func(i, j int) bool { + if cs[i].v != cs[j].v { + return cs[i].v > cs[j].v + } + return cs[i].name < cs[j].name + }) + codes := []string{} + for i := 0; i < len(cs) && i < 3; i++ { + codes = append(codes, "feature:"+cs[i].name) + } + risk = decision.ClampMicro(risk) + return decision.Score{ + ModelID: ID, + Version: Version, + ArtifactSHA256: SHA256(), + FeatureSpec: f.SpecVersion, + FeatureHash: f.Hash(), + RiskMicro: risk, + OptionMicro: decision.Options(risk), + ReasonCodes: codes, + ThresholdMicro: ThresholdMicro, + Calibration: "uncalibrated: hand-set additive weights, not a probability", + }, nil +} diff --git a/pkg/decision/baseline/baseline_test.go b/pkg/decision/baseline/baseline_test.go new file mode 100644 index 0000000..ccc8db6 --- /dev/null +++ b/pkg/decision/baseline/baseline_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package baseline + +import ( + "context" + "reflect" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" +) + +func vec(set map[string]int64) decision.Features { + f := decision.Features{SpecVersion: decision.FeatureSpecVersion, Values: make([]int64, len(decision.FeatureNames))} + for i, n := range decision.FeatureNames { + f.Values[i] = set[n] + } + return f +} + +func TestScore(t *testing.T) { + tests := []struct { + name string + set map[string]int64 + wantRisk int64 + wantCodes []string + }{ + {"clean declared read", map[string]int64{"declared_present": 1, "dest_declared": 1, "dest_present": 1}, 0, []string{}}, + {"credential saturates", map[string]int64{"dc_credential": 1, "conf_low": 1}, 1_000_000, []string{"feature:dc_credential", "feature:conf_low"}}, + {"personal to undeclared", map[string]int64{"personal_to_undeclared": 1, "dc_personal_count": 2}, 800_000, []string{"feature:personal_to_undeclared", "feature:dc_personal_count"}}, + {"low confidence alone", map[string]int64{"conf_low": 1}, 300_000, []string{"feature:conf_low"}}, + {"tie broken by name", map[string]int64{"conf_absent": 1, "decision_automated": 1, "health_low": 1, "action_destructive": 1}, 750_000, + []string{"feature:action_destructive", "feature:conf_absent", "feature:decision_automated"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s, err := Scorer{}.Score(context.Background(), vec(tc.set)) + if err != nil { + t.Fatal(err) + } + if s.RiskMicro != tc.wantRisk || !reflect.DeepEqual(s.ReasonCodes, tc.wantCodes) { + t.Fatalf("risk %d codes %v, want %d %v", s.RiskMicro, s.ReasonCodes, tc.wantRisk, tc.wantCodes) + } + if s.OptionMicro[decision.OptionAllow]+s.OptionMicro[decision.OptionRequireApproval] != decision.MicroScale { + t.Fatal("options do not sum") + } + if s.ThresholdMicro != ThresholdMicro || s.ModelID != ID || len(s.ArtifactSHA256) != 64 || len(s.FeatureHash) != 64 { + t.Fatalf("bad metadata %+v", s) + } + again, _ := Scorer{}.Score(context.Background(), vec(tc.set)) + if !reflect.DeepEqual(s, again) { + t.Fatal("not deterministic") + } + }) + } +} + +func TestWeightsUseKnownFeatures(t *testing.T) { + known := map[string]bool{} + for _, n := range decision.FeatureNames { + known[n] = true + } + for n := range Weights { + if !known[n] { + t.Fatalf("weight for unknown feature %s", n) + } + } +} From a3e9d32b6f2973974824e953393d364c693f8aa1 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:19:09 +0530 Subject: [PATCH 12/54] feat(decision): learned logistic model with integer inference, stdlib trainer, DRAFT synthetic seed Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- Makefile | 5 +- pkg/dataset/dataset.go | 9 + pkg/dataset/features.go | 61 ++ pkg/dataset/features_test.go | 52 ++ pkg/decision/learned/learned.go | 363 ++++++++ pkg/decision/learned/learned_test.go | 281 ++++++ tools/decision-train/README.md | 65 ++ tools/decision-train/features.py | 172 ++++ tools/decision-train/out/approvals.jsonl | 360 ++++++++ tools/decision-train/out/golden.json | 1004 ++++++++++++++++++++ tools/decision-train/out/metrics.json | 199 ++++ tools/decision-train/out/model.json | 158 ++++ tools/decision-train/run.sh | 33 + tools/decision-train/scenarios.json | 1066 ++++++++++++++++++++++ tools/decision-train/seed.py | 106 +++ tools/decision-train/train.py | 236 +++++ 16 files changed, 4169 insertions(+), 1 deletion(-) create mode 100644 pkg/dataset/features.go create mode 100644 pkg/dataset/features_test.go create mode 100644 pkg/decision/learned/learned.go create mode 100644 pkg/decision/learned/learned_test.go create mode 100644 tools/decision-train/README.md create mode 100644 tools/decision-train/features.py create mode 100644 tools/decision-train/out/approvals.jsonl create mode 100644 tools/decision-train/out/golden.json create mode 100644 tools/decision-train/out/metrics.json create mode 100644 tools/decision-train/out/model.json create mode 100755 tools/decision-train/run.sh create mode 100644 tools/decision-train/scenarios.json create mode 100644 tools/decision-train/seed.py create mode 100644 tools/decision-train/train.py diff --git a/Makefile b/Makefile index 7714196..77998d6 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 +.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 .DEFAULT_GOAL := help @@ -177,6 +177,9 @@ build: @echo "Building Go binaries..." @$(GO) build ./... +decision-train: ## Seed DRAFT synthetic data and train the decision model twice; fails unless both artifacts match + @bash tools/decision-train/run.sh + build-agentctl: @echo "Building agentctl binary..." @mkdir -p bin diff --git a/pkg/dataset/dataset.go b/pkg/dataset/dataset.go index 7648e51..75e0646 100644 --- a/pkg/dataset/dataset.go +++ b/pkg/dataset/dataset.go @@ -129,8 +129,14 @@ type Example struct { CaptureMode string `json:"capture_mode"` Content Content `json:"content"` EditDiff *EditDiff `json:"edit_diff,omitempty"` + // Source is empty for a human decision bound to a receipt. "synthetic" + // marks generated training rows, which never bind. + Source string `json:"source,omitempty"` } +// SourceSynthetic marks generated rows from tools/decision-train. +const SourceSynthetic = "synthetic" + // BuildContent renders a for one capture mode. Unknown modes fall back to // none, the most private. func BuildContent(mode string, a Action) (Content, error) { @@ -301,6 +307,9 @@ func Validate(ex Example, r receiptspec.Receipt, recordJSON []byte) error { if ex.SchemaVersion != SchemaVersion { return fmt.Errorf("dataset: unsupported schema_version %d", ex.SchemaVersion) } + if ex.Source != "" { + return fmt.Errorf("%w: %q example is not a human decision", ErrUnbound, ex.Source) + } switch ex.CaptureMode { case CaptureNone, CaptureRedacted, CaptureFull: default: diff --git a/pkg/dataset/features.go b/pkg/dataset/features.go new file mode 100644 index 0000000..ac279e0 --- /dev/null +++ b/pkg/dataset/features.go @@ -0,0 +1,61 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package dataset + +import ( + "strconv" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +// FeatureInput maps an example to the decision model feature input, so +// training and offline evaluation use the same features as live scoring. +// The action name comes from Content.Name, which capture mode none omits: +// such examples always get action_destructive 0. +func (ex Example) FeatureInput() decision.FeatureInput { + f := ex.Features + return decision.FeatureInput{ + Input: input.Input{ + Declared: input.Declared{ + DecisionType: f.DecisionType, + AllowedDataClasses: f.AllowedDataClasses, + AllowedDestinations: f.AllowedDestinations, + }, + Observed: input.Observed{ + Destination: f.ObservedDestination, + DataClasses: f.ObservedDataClasses, + Tool: f.Tool, + PriorActionCount: f.PriorActionCount, + PriorDeniedCount: f.PriorDeniedCount, + }, + Claimed: input.AgentClaimed{ + Confidence: parseDecimal(f.ClaimedConfidence), + ClusterHealth: parseDecimal(f.ClaimedClusterHealth), + }, + }, + Action: ex.Content.Name, + PolicyPacks: f.PolicyPacks, + EscalatedBy: f.EscalatedBy, + } +} + +// Rejected reports whether the human label counts as a positive for the +// escalate model: reject or edit. +func (ex Example) Rejected() bool { + return ex.Label == "reject" || ex.Label == "edit" +} + +func parseDecimal(s string) *float64 { + if s == "" { + return nil + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil + } + return &v +} diff --git a/pkg/dataset/features_test.go b/pkg/dataset/features_test.go new file mode 100644 index 0000000..f0ad91f --- /dev/null +++ b/pkg/dataset/features_test.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package dataset + +import ( + "errors" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" +) + +func TestExampleFeatureInput(t *testing.T) { + ex := Example{ + Label: "edit", + Features: Features{ + DecisionType: "automated", AllowedDataClasses: []string{"email"}, AllowedDestinations: []string{"crm.example.com"}, + ObservedDestination: "crm.example.com", ObservedDataClasses: []string{"email"}, + PriorActionCount: 12, PriorDeniedCount: 2, ClaimedConfidence: "0.45", ClaimedClusterHealth: "", + PolicyPacks: []string{"gdpr-eu@v0.1.0"}, EscalatedBy: "rules", + }, + Content: Content{Name: "delete_contact"}, + } + f := decision.ExtractFeatures(ex.FeatureInput()) + want := map[string]int64{ + "dc_personal_count": 1, "dest_present": 1, "dest_declared": 1, "declared_present": 1, "decision_automated": 1, + "action_destructive": 1, "prior_actions_bucket": 2, "prior_denied": 2, "conf_low": 1, "health_absent": 1, + "pack_any": 1, "pack_gdpr_eu": 1, "esc_rules": 1, + } + for i, n := range decision.FeatureNames { + if f.Values[i] != want[n] { + t.Errorf("%s = %d, want %d", n, f.Values[i], want[n]) + } + } + if !ex.Rejected() || (Example{Label: "approve"}).Rejected() { + t.Fatal("Rejected label mapping wrong") + } + if ex.FeatureInput().Input.Claimed.ClusterHealth != nil || parseDecimal("x") != nil { + t.Fatal("empty or bad decimals must be absent") + } +} + +func TestValidateRejectsSynthetic(t *testing.T) { + ex := Example{SchemaVersion: SchemaVersion, Source: SourceSynthetic, CaptureMode: CaptureNone} + if err := Validate(ex, receiptspec.Receipt{}, nil); !errors.Is(err, ErrUnbound) { + t.Fatalf("Validate synthetic = %v", err) + } +} diff --git a/pkg/decision/learned/learned.go b/pkg/decision/learned/learned.go new file mode 100644 index 0000000..afbfbfc --- /dev/null +++ b/pkg/decision/learned/learned.go @@ -0,0 +1,363 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +// Package learned runs a logistic regression decision model from a JSON +// artifact, in process, with integer-only inference. The same artifact and +// features give the same RiskMicro on every architecture. See +// docs/architecture/decision-model.md. +package learned + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" +) + +// SchemaVersion is the artifact schema version this package reads. +const SchemaVersion = 1 + +// ModelType is the only supported model type. +const ModelType = "logistic_regression" + +// MaxArtifactBytes caps the artifact file. +const MaxArtifactBytes = 1 << 20 + +// Load errors. Each one means "no model": callers fail closed to the +// non-model decision. +var ( + ErrSpecMismatch = errors.New("learned: artifact feature spec does not match this operator") + ErrTampered = errors.New("learned: artifact_sha256 does not match the artifact") + ErrInvalid = errors.New("learned: invalid artifact") +) + +// Bin is one calibration bin from training. Rates are micro-units. +type Bin struct { + LowerMicro int64 `json:"lower_micro"` + UpperMicro int64 `json:"upper_micro"` + Count int64 `json:"count"` + MeanPredictedMicro int64 `json:"mean_predicted_micro"` + ObservedMicro int64 `json:"observed_micro"` +} + +// Calibration is the reliability table measured on the held-out split. +type Calibration struct { + Method string `json:"method"` + Split string `json:"split"` + Bins []Bin `json:"bins"` + ECEMicro int64 `json:"ece_micro"` +} + +// TrainedOn says what the model was fitted to. DataSource is "synthetic", +// "human", or "mixed". +type TrainedOn struct { + DataSource string `json:"data_source"` + DatasetSHA256 string `json:"dataset_sha256"` + ExamplesTotal int64 `json:"examples_total"` + ExamplesTrain int64 `json:"examples_train"` + ExamplesHoldout int64 `json:"examples_holdout"` + Positives int64 `json:"positives"` + Negatives int64 `json:"negatives"` + SyntheticRows int64 `json:"synthetic_rows"` + HumanRows int64 `json:"human_rows"` +} + +// Artifact is the model file. Weights and bias are decimal strings with at +// most 6 fractional digits. No floats anywhere. +type Artifact struct { + SchemaVersion int `json:"schema_version"` + ModelID string `json:"model_id"` + Version string `json:"version"` + ModelType string `json:"model_type"` + FeatureSpecVersion string `json:"feature_spec_version"` + Features []string `json:"features"` + Weights []string `json:"weights"` + Bias string `json:"bias"` + ThresholdMicro int64 `json:"threshold_micro"` + Calibration Calibration `json:"calibration"` + TrainedOn TrainedOn `json:"trained_on"` + Training map[string]string `json:"training"` + ArtifactSHA256 string `json:"artifact_sha256"` +} + +// Model is a loaded, checked artifact. +type Model struct { + art Artifact + weights []int64 + bias int64 +} + +var _ decision.Scorer = (*Model)(nil) + +// Load reads and checks an artifact file. +func Load(path string) (*Model, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + var buf bytes.Buffer + if _, err := buf.ReadFrom(io.LimitReader(f, MaxArtifactBytes+1)); err != nil { + return nil, err + } + if buf.Len() > MaxArtifactBytes { + return nil, fmt.Errorf("%w: larger than %d bytes", ErrInvalid, MaxArtifactBytes) + } + return Parse(buf.Bytes()) +} + +// Parse checks an artifact: printable ASCII only, known schema and model +// type, the exact feature spec and order of this operator, decimal weights, +// a threshold in range, and a matching artifact_sha256. +func Parse(data []byte) (*Model, error) { + for _, c := range data { + if (c < 0x20 || c > 0x7e) && c != '\n' && c != '\r' && c != '\t' && c != ' ' { + return nil, fmt.Errorf("%w: artifact must be printable ASCII", ErrInvalid) + } + } + sum, err := CanonicalSHA256(data) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalid, err) + } + var a Artifact + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&a); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalid, err) + } + if a.ArtifactSHA256 != sum { + return nil, ErrTampered + } + switch { + case a.SchemaVersion != SchemaVersion: + return nil, fmt.Errorf("%w: schema_version %d", ErrInvalid, a.SchemaVersion) + case a.ModelType != ModelType: + return nil, fmt.Errorf("%w: model_type %q", ErrInvalid, a.ModelType) + case a.ModelID == "" || a.Version == "": + return nil, fmt.Errorf("%w: model_id and version are required", ErrInvalid) + case a.FeatureSpecVersion != decision.FeatureSpecVersion: + return nil, fmt.Errorf("%w: artifact %q, operator %q", ErrSpecMismatch, a.FeatureSpecVersion, decision.FeatureSpecVersion) + case !equal(a.Features, decision.FeatureNames): + return nil, fmt.Errorf("%w: feature names or order differ", ErrSpecMismatch) + case len(a.Weights) != len(a.Features): + return nil, fmt.Errorf("%w: %d weights for %d features", ErrInvalid, len(a.Weights), len(a.Features)) + case a.ThresholdMicro < 1 || a.ThresholdMicro > decision.MicroScale: + return nil, fmt.Errorf("%w: threshold_micro %d", ErrInvalid, a.ThresholdMicro) + } + m := &Model{art: a, weights: make([]int64, len(a.Weights))} + for i, w := range a.Weights { + if m.weights[i], err = ParseMicro(w); err != nil { + return nil, fmt.Errorf("%w: weight %s: %v", ErrInvalid, a.Features[i], err) + } + } + if m.bias, err = ParseMicro(a.Bias); err != nil { + return nil, fmt.Errorf("%w: bias: %v", ErrInvalid, err) + } + return m, nil +} + +// CanonicalSHA256 hashes the artifact with artifact_sha256 removed, as +// compact JSON with sorted keys and numbers kept as written. Python +// json.dumps(sort_keys=True, separators=(",", ":")) gives the same bytes for +// a printable ASCII artifact with integer numbers. +func CanonicalSHA256(data []byte) (string, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var obj map[string]any + if err := dec.Decode(&obj); err != nil { + return "", err + } + if dec.More() { + return "", errors.New("trailing data") + } + delete(obj, "artifact_sha256") + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(obj); err != nil { + return "", err + } + sum := sha256.Sum256(bytes.TrimSuffix(buf.Bytes(), []byte("\n"))) + return hex.EncodeToString(sum[:]), nil +} + +// Artifact returns the loaded artifact. +func (m *Model) Artifact() Artifact { return m.art } + +// ThresholdMicro is the artifact escalation threshold. +func (m *Model) ThresholdMicro() int64 { return m.art.ThresholdMicro } + +// Logit returns bias + sum(weight * value) in micro-units. Integer only. +func (m *Model) Logit(f decision.Features) (int64, error) { + if f.SpecVersion != m.art.FeatureSpecVersion || len(f.Values) != len(m.weights) { + return 0, fmt.Errorf("%w: features %s/%d, model %s/%d", ErrSpecMismatch, f.SpecVersion, len(f.Values), m.art.FeatureSpecVersion, len(m.weights)) + } + z := m.bias + for i, w := range m.weights { + z += w * f.Values[i] + } + return z, nil +} + +// Score returns the risk that a human would reject or edit the action. +func (m *Model) Score(_ context.Context, f decision.Features) (decision.Score, error) { + z, err := m.Logit(f) + if err != nil { + return decision.Score{}, err + } + risk := SigmoidMicro(z) + type contrib struct { + name string + v int64 + } + var cs []contrib + for i, w := range m.weights { + if c := w * f.Values[i]; c > 0 { + cs = append(cs, contrib{m.art.Features[i], c}) + } + } + sort.Slice(cs, func(i, j int) bool { + if cs[i].v != cs[j].v { + return cs[i].v > cs[j].v + } + return cs[i].name < cs[j].name + }) + codes := []string{} + for i := 0; i < len(cs) && i < 3; i++ { + codes = append(codes, "feature:"+cs[i].name) + } + cal := m.art.Calibration + return decision.Score{ + ModelID: m.art.ModelID, + Version: m.art.Version, + ArtifactSHA256: m.art.ArtifactSHA256, + FeatureSpec: f.SpecVersion, + FeatureHash: f.Hash(), + RiskMicro: risk, + OptionMicro: decision.Options(risk), + ReasonCodes: codes, + ThresholdMicro: m.art.ThresholdMicro, + Calibration: fmt.Sprintf("%s; %s ECE %d micro over %d bins; trained on %s data", + nonEmpty(cal.Method, "none"), nonEmpty(cal.Split, "holdout"), cal.ECEMicro, len(cal.Bins), nonEmpty(m.art.TrainedOn.DataSource, "unknown")), + }, nil +} + +// ParseMicro parses a decimal string with at most 6 fractional digits into +// micro-units. Magnitude is capped at 1000 to keep sums far from overflow. +func ParseMicro(s string) (int64, error) { + neg := strings.HasPrefix(s, "-") + body := strings.TrimPrefix(s, "-") + ip, fr, hasDot := strings.Cut(body, ".") + if ip == "" || (hasDot && fr == "") || len(fr) > 6 || len(ip) > 4 { + return 0, fmt.Errorf("bad decimal %q", s) + } + for _, c := range ip + fr { + if c < '0' || c > '9' { + return 0, fmt.Errorf("bad decimal %q", s) + } + } + whole, err := strconv.ParseInt(ip, 10, 64) + if err != nil { + return 0, err + } + fr += strings.Repeat("0", 6-len(fr)) + frac, err := strconv.ParseInt(fr, 10, 64) + if err != nil { + return 0, err + } + v := whole*decision.MicroScale + frac + if v > 1000*decision.MicroScale { + return 0, fmt.Errorf("decimal %q out of range", s) + } + if neg { + v = -v + } + return v, nil +} + +// Fixed-point constants for SigmoidMicro. expScale is 1.0. +const ( + expScale = 1_000_000_000 + // eInv is e^-1 at expScale, rounded down. + eInv = 367_879_441 + // maxLogitMicro clamps the logit. e^-30 is below one expScale unit. + maxLogitMicro = 30 * decision.MicroScale +) + +// expNeg returns e^(-x) at expScale for x >= 0 in micro-units, with integer +// arithmetic only: e^(-n) by repeated multiplication with eInv, e^(-f) for +// the fraction by a 25-term Taylor series. Every intermediate product stays +// below 1e18, inside int64. +func expNeg(xMicro int64) int64 { + n := xMicro / decision.MicroScale + f := (xMicro % decision.MicroScale) * 1000 // fraction at expScale + sum, term := int64(expScale), int64(expScale) + for k := int64(1); k <= 25; k++ { + term = term * f / (expScale * k) + if term == 0 { + break + } + if k%2 == 1 { + sum -= term + } else { + sum += term + } + } + for i := int64(0); i < n; i++ { + sum = sum * eInv / expScale + } + return sum +} + +// SigmoidMicro returns round(1e6 / (1 + e^-z)) for a logit z in micro-units. +// Integer arithmetic only, so the result does not depend on the platform +// floating point unit or math library. +func SigmoidMicro(zMicro int64) int64 { + if zMicro > maxLogitMicro { + zMicro = maxLogitMicro + } + if zMicro < -maxLogitMicro { + zMicro = -maxLogitMicro + } + neg := zMicro < 0 + if neg { + zMicro = -zMicro + } + e := expNeg(zMicro) + den := int64(expScale) + e + if neg { + return (e*decision.MicroScale + den/2) / den + } + return (int64(expScale)*decision.MicroScale + den/2) / den +} + +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func nonEmpty(s, d string) string { + if s == "" { + return d + } + return s +} diff --git a/pkg/decision/learned/learned_test.go b/pkg/decision/learned/learned_test.go new file mode 100644 index 0000000..f3371d0 --- /dev/null +++ b/pkg/decision/learned/learned_test.go @@ -0,0 +1,281 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package learned_test + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "math" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/learned" +) + +var outDir = filepath.Join("..", "..", "..", "tools", "decision-train", "out") + +func readFile(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join(outDir, name)) + if err != nil { + t.Fatal(err) + } + return b +} + +func examples(t *testing.T) []dataset.Example { + t.Helper() + var out []dataset.Example + sc := bufio.NewScanner(bytes.NewReader(readFile(t, "approvals.jsonl"))) + sc.Buffer(make([]byte, 1<<20), 1<<24) + for sc.Scan() { + var ex dataset.Example + dec := json.NewDecoder(bytes.NewReader(sc.Bytes())) + dec.DisallowUnknownFields() + if err := dec.Decode(&ex); err != nil { + t.Fatal(err) + } + out = append(out, ex) + } + return out +} + +func shipped(t *testing.T) *learned.Model { + t.Helper() + m, err := learned.Load(filepath.Join(outDir, "model.json")) + if err != nil { + t.Fatal(err) + } + return m +} + +func TestShippedArtifact(t *testing.T) { + m := shipped(t) + a := m.Artifact() + if a.TrainedOn.DataSource != "synthetic" || a.TrainedOn.HumanRows != 0 { + t.Fatalf("shipped artifact must say it is synthetic: %+v", a.TrainedOn) + } + if !strings.Contains(a.Version, "synthetic") { + t.Fatalf("version %q must name synthetic data", a.Version) + } +} + +// TestGoldenCrossLanguage checks Go against outputs the Python trainer +// computed with the same integer inference. +func TestGoldenCrossLanguage(t *testing.T) { + m := shipped(t) + byID := map[string]dataset.Example{} + for _, ex := range examples(t) { + byID[ex.ExampleID] = ex + } + var golden []struct { + ExampleID string `json:"example_id"` + Features []int64 `json:"features"` + LogitMicro int64 `json:"logit_micro"` + RiskMicro int64 `json:"risk_micro"` + } + if err := json.Unmarshal(readFile(t, "golden.json"), &golden); err != nil { + t.Fatal(err) + } + if len(golden) < 20 { + t.Fatalf("only %d golden cases", len(golden)) + } + for _, g := range golden { + if g.ExampleID == "sigmoid" { + if got := learned.SigmoidMicro(g.LogitMicro); got != g.RiskMicro { + t.Errorf("SigmoidMicro(%d) = %d, python %d", g.LogitMicro, got, g.RiskMicro) + } + continue + } + ex, ok := byID[g.ExampleID] + if !ok { + t.Fatalf("golden id %s not in dataset", g.ExampleID) + } + f := decision.ExtractFeatures(ex.FeatureInput()) + if !reflect.DeepEqual(f.Values, g.Features) { + t.Fatalf("%s features %v, python %v", g.ExampleID, f.Values, g.Features) + } + z, err := m.Logit(f) + if err != nil || z != g.LogitMicro { + t.Fatalf("%s logit %d (%v), python %d", g.ExampleID, z, err, g.LogitMicro) + } + s, err := m.Score(context.Background(), f) + if err != nil || s.RiskMicro != g.RiskMicro { + t.Fatalf("%s risk %d (%v), python %d", g.ExampleID, s.RiskMicro, err, g.RiskMicro) + } + } +} + +// TestFeatureMatrixParity proves Go and tools/decision-train/features.py +// derive the same features from every shipped example. +func TestFeatureMatrixParity(t *testing.T) { + var metrics struct { + FeatureMatrixSHA256 string `json:"feature_matrix_sha256"` + } + if err := json.Unmarshal(readFile(t, "metrics.json"), &metrics); err != nil { + t.Fatal(err) + } + h := sha256.New() + for _, ex := range examples(t) { + f := decision.ExtractFeatures(ex.FeatureInput()) + parts := make([]string, len(f.Values)) + for i, v := range f.Values { + parts[i] = strconv.FormatInt(v, 10) + } + h.Write([]byte(strings.Join(parts, ",") + "\n")) + } + if got := hex.EncodeToString(h.Sum(nil)); got != metrics.FeatureMatrixSHA256 { + t.Fatalf("feature matrix %s, python %s: features.go and features.py drifted", got, metrics.FeatureMatrixSHA256) + } +} + +func TestReplay(t *testing.T) { + m := shipped(t) + exs := examples(t) + for _, ex := range exs[:40] { + f := decision.ExtractFeatures(ex.FeatureInput()) + a, err := m.Score(context.Background(), f) + if err != nil { + t.Fatal(err) + } + b, _ := shipped(t).Score(context.Background(), decision.ExtractFeatures(ex.FeatureInput())) + if !reflect.DeepEqual(a, b) { + t.Fatalf("replay differs for %s", ex.ExampleID) + } + if a.OptionMicro[decision.OptionAllow]+a.OptionMicro[decision.OptionRequireApproval] != decision.MicroScale { + t.Fatal("options do not sum") + } + if a.ArtifactSHA256 != m.Artifact().ArtifactSHA256 || a.FeatureHash != f.Hash() { + t.Fatal("score metadata missing") + } + } +} + +// rewrite edits the shipped artifact. When reseal is set it recomputes +// artifact_sha256, like an honest retrain would. +func rewrite(t *testing.T, edit func(map[string]any), reseal bool) []byte { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(readFile(t, "model.json"))) + dec.UseNumber() + var obj map[string]any + if err := dec.Decode(&obj); err != nil { + t.Fatal(err) + } + edit(obj) + raw, err := json.Marshal(obj) + if err != nil { + t.Fatal(err) + } + if reseal { + sum, err := learned.CanonicalSHA256(raw) + if err != nil { + t.Fatal(err) + } + obj["artifact_sha256"] = sum + if raw, err = json.Marshal(obj); err != nil { + t.Fatal(err) + } + } + return raw +} + +func TestParseRejects(t *testing.T) { + tests := []struct { + name string + edit func(map[string]any) + reseal bool + want error + }{ + {"weight tampered", func(o map[string]any) { o["weights"].([]any)[0] = "9.000000" }, false, learned.ErrTampered}, + {"threshold tampered", func(o map[string]any) { o["threshold_micro"] = json.Number("900000") }, false, learned.ErrTampered}, + {"hash replaced", func(o map[string]any) { o["artifact_sha256"] = strings.Repeat("0", 64) }, false, learned.ErrTampered}, + {"spec version mismatch", func(o map[string]any) { o["feature_spec_version"] = "v0" }, true, learned.ErrSpecMismatch}, + {"feature order mismatch", func(o map[string]any) { + f := o["features"].([]any) + f[0], f[1] = f[1], f[0] + }, true, learned.ErrSpecMismatch}, + {"weights count", func(o map[string]any) { o["weights"] = o["weights"].([]any)[1:] }, true, learned.ErrInvalid}, + {"float weight", func(o map[string]any) { o["weights"].([]any)[0] = "0.1234567" }, true, learned.ErrInvalid}, + {"threshold zero", func(o map[string]any) { o["threshold_micro"] = json.Number("0") }, true, learned.ErrInvalid}, + {"schema version", func(o map[string]any) { o["schema_version"] = json.Number("2") }, true, learned.ErrInvalid}, + {"model type", func(o map[string]any) { o["model_type"] = "mlp" }, true, learned.ErrInvalid}, + {"unknown field", func(o map[string]any) { o["extra"] = "x" }, true, learned.ErrInvalid}, + {"non ascii", func(o map[string]any) { o["model_id"] = "caf\u00e9" }, true, learned.ErrInvalid}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := learned.Parse(rewrite(t, tc.edit, tc.reseal)) + if !errors.Is(err, tc.want) { + t.Fatalf("Parse err = %v, want %v", err, tc.want) + } + }) + } + if _, err := learned.Parse(rewrite(t, func(map[string]any) {}, true)); err != nil { + t.Fatalf("resealed unchanged artifact rejected: %v", err) + } +} + +func TestScoreRejectsWrongFeatures(t *testing.T) { + m := shipped(t) + for _, f := range []decision.Features{ + {SpecVersion: "v0", Values: make([]int64, len(decision.FeatureNames))}, + {SpecVersion: decision.FeatureSpecVersion, Values: []int64{1}}, + } { + if _, err := m.Score(context.Background(), f); !errors.Is(err, learned.ErrSpecMismatch) { + t.Fatalf("Score err = %v", err) + } + } +} + +func TestSigmoidMicro(t *testing.T) { + prev := int64(-1) + for z := int64(-31_000_000); z <= 31_000_000; z += 7_919 { + r := learned.SigmoidMicro(z) + if r < 0 || r > decision.MicroScale || r < prev { + t.Fatalf("SigmoidMicro(%d) = %d not monotonic in range (prev %d)", z, r, prev) + } + prev = r + want := 1e6 / (1 + math.Exp(-float64(z)/1e6)) + if math.Abs(float64(r)-want) > 2 { + t.Fatalf("SigmoidMicro(%d) = %d, float %f", z, r, want) + } + if s := r + learned.SigmoidMicro(-z); s < decision.MicroScale-1 || s > decision.MicroScale+1 { + t.Fatalf("not symmetric at %d: %d", z, s) + } + } + if learned.SigmoidMicro(0) != 500_000 { + t.Fatal("sigmoid(0) != 0.5") + } +} + +func TestParseMicro(t *testing.T) { + tests := []struct { + in string + want int64 + ok bool + }{ + {"0", 0, true}, {"1.5", 1_500_000, true}, {"-0.000001", -1, true}, {"-12.345678", -12_345_678, true}, + {"1000", 1_000_000_000, true}, {"1000.000001", 0, false}, {"1e3", 0, false}, {"", 0, false}, + {".5", 0, false}, {"5.", 0, false}, {"0.1234567", 0, false}, {"+1", 0, false}, {"--1", 0, false}, + } + for _, tc := range tests { + got, err := learned.ParseMicro(tc.in) + if (err == nil) != tc.ok || (tc.ok && got != tc.want) { + t.Errorf("ParseMicro(%q) = %d, %v", tc.in, got, err) + } + } +} diff --git a/tools/decision-train/README.md b/tools/decision-train/README.md new file mode 100644 index 0000000..324a2b1 --- /dev/null +++ b/tools/decision-train/README.md @@ -0,0 +1,65 @@ +# decision-train + +Trains the escalate-only decision model. Python 3, standard library only. +See [docs/architecture/decision-model.md](../../docs/architecture/decision-model.md). + +## Status: DRAFT, synthetic, not production-validated + +`scenarios.json` holds 30 DRAFT scenarios written by an engineer, not the +founder. They exist so shadow mode has something to run. Replace them with +founder-written scenarios, then with real human labels from approvals.jsonl. +Every number this tool prints on the shipped data measures how well a model +fits rules we wrote ourselves. It says nothing about real reviewers. + +The scenarios file is JSON, not YAML. The trainer is stdlib only and Python +has no YAML parser in the standard library. + +## Files + +| File | What it does | +| --- | --- | +| `scenarios.json` | 30 DRAFT scenarios, each with a label (approve, reject, edit) and a rationale | +| `seed.py` | Expands each scenario into 12 seeded rows in the approvals.jsonl Example schema, `source: "synthetic"`, 5% label noise | +| `features.py` | Feature spec v1. Mirrors `pkg/decision/features.go` | +| `train.py` | Logistic regression by full-batch gradient descent, fixed iterations, 1 in 5 held out by example_id hash | +| `run.sh` | Seeds and trains twice, fails unless every output is byte-identical | +| `out/` | Shipped dataset, `model.json` artifact, `metrics.json`, `golden.json` | + +## Run + +```sh +make decision-train +# or train on a real export +python3 tools/decision-train/train.py --data export/approvals.jsonl --out-dir /tmp/model --model-version 0.2.0 +``` + +Label 1 means a human rejected or edited. Approve is 0. + +## Determinism + +- Fixed seed, fixed iteration count, fixed holdout rule. No clock, no + randomness in training. +- Weights are rounded to 6 decimals and stored as strings. Inference in Go + and in `train.py` uses only integers (fixed-point exp and sigmoid), so the + metrics here match the operator bit for bit. +- `artifact_sha256` is SHA-256 over the artifact as compact JSON with sorted + keys and that field removed. The operator recomputes it and refuses a + mismatch. +- `golden.json` holds Python-computed features, logits, and risks. A Go test + checks the operator gets the same values. `metrics.json` + `feature_matrix_sha256` catches drift between `features.py` and + `features.go`. + +## Honest limits + +- Training data is synthetic. Holdout rows come from the same 30 scenarios + as training rows, so holdout scores are optimistic. +- Every synthetic row has `escalated_by: rules`. That feature is constant, + so it gets weight 0. Live, the model mostly scores actions that were + allowed, a population no human label covers yet. +- Capture mode `none` drops the action name, so those rows lose + `action_destructive`. +- No calibration step is applied. The table in the artifact only reports + how far off the raw scores are. +- Logistic regression has no feature interactions beyond the ones encoded by + hand (`personal_to_undeclared`). diff --git a/tools/decision-train/features.py b/tools/decision-train/features.py new file mode 100644 index 0000000..8bf9012 --- /dev/null +++ b/tools/decision-train/features.py @@ -0,0 +1,172 @@ +"""Feature extraction for the decision model, spec v1. + +Mirrors pkg/decision/features.go exactly. If one changes, the other must +change in the same commit and FEATURE_SPEC_VERSION must move. A Go test +recomputes feature_matrix_sha256 from the shipped dataset to catch drift. +Stdlib only. +""" + +import hashlib + +FEATURE_SPEC_VERSION = "v1" + +FEATURE_NAMES = [ + "dc_personal_count", + "dc_credential", + "dc_undeclared_count", + "personal_to_undeclared", + "dest_present", + "dest_declared", + "declared_present", + "decision_automated", + "action_destructive", + "prior_actions_bucket", + "prior_denied", + "conf_absent", + "conf_low", + "conf_mid", + "health_absent", + "health_low", + "health_mid", + "pack_any", + "pack_dpdp_in", + "pack_gdpr_eu", + "esc_invariant", + "esc_rules", + "esc_threshold", + "esc_model", +] + +PERSONAL = {"email", "phone", "aadhaar", "pan", "iban", "card"} +DESTRUCTIVE = {"cleanup", "clear", "delete", "destroy", "drop", "purge", "remove", "reset", "truncate", "wipe"} + + +def _host(dest): + # Dataset rows already hold a bare host (receipts store the host only). + return dest.strip().strip("[]").lower() + + +def _host_allowed(host, patterns): + for p in patterns: + p = p.strip().lower() + if not p: + continue + if p.startswith("*."): + suffix = p[1:] + if host.endswith(suffix) and len(host) > len(suffix): + return True + continue + if host == p: + return True + return False + + +def _tokens(name): + out, cur = [], "" + for ch in name.lower(): + if ch in "_-./ ": + if cur: + out.append(cur) + cur = "" + else: + cur += ch + if cur: + out.append(cur) + return out + + +def _bucket(n): + if n <= 0: + return 0 + if n < 10: + return 1 + if n < 100: + return 2 + return 3 + + +def _cap(n, m): + return max(0, min(n, m)) + + +def features(ex): + """Feature vector (list of ints) for one approvals.jsonl example.""" + f = ex["features"] + dt = f.get("decision_type", "") + adc = f.get("allowed_data_classes") or [] + ads = f.get("allowed_destinations") or [] + odc = f.get("observed_data_classes") or [] + declared = dt != "" or len(adc) > 0 or len(ads) > 0 + + personal = set() + credential = False + for c in odc: + c = c.strip().lower() + if c in PERSONAL: + personal.add(c) + if c == "credential": + credential = True + undeclared = 0 + if declared: + allowed = {a.strip().lower() for a in adc} + seen = set() + for c in odc: + c = c.strip().lower() + if c not in allowed and c not in seen: + seen.add(c) + undeclared += 1 + host = _host(f.get("observed_destination", "")) + dest_declared = host != "" and _host_allowed(host, ads) + + v = { + "dc_personal_count": _cap(len(personal), 6), + "dc_credential": int(credential), + "dc_undeclared_count": _cap(undeclared, 6), + "personal_to_undeclared": int(len(personal) > 0 and host != "" and not dest_declared), + "dest_present": int(host != ""), + "dest_declared": int(dest_declared), + "declared_present": int(declared), + "decision_automated": int(dt.strip().lower() == "automated"), + "action_destructive": int(any(t in DESTRUCTIVE for t in _tokens(ex.get("content", {}).get("name", "")))), + "prior_actions_bucket": _bucket(f.get("prior_action_count", 0)), + "prior_denied": _cap(f.get("prior_denied_count", 0), 10), + } + conf = f.get("claimed_confidence", "") + if conf == "": + v["conf_absent"] = 1 + elif float(conf) < 0.5: + v["conf_low"] = 1 + elif float(conf) < 0.8: + v["conf_mid"] = 1 + health = f.get("claimed_cluster_health", "") + if health == "": + v["health_absent"] = 1 + elif float(health) < 50: + v["health_low"] = 1 + elif float(health) < 80: + v["health_mid"] = 1 + for p in f.get("policy_packs") or []: + v["pack_any"] = 1 + name = p.lower().split("@", 1)[0] + if name == "dpdp-in": + v["pack_dpdp_in"] = 1 + elif name == "gdpr-eu": + v["pack_gdpr_eu"] = 1 + esc = f.get("escalated_by", "") + if esc == "invariant": + v["esc_invariant"] = 1 + elif esc in ("rules", "pack"): + v["esc_rules"] = 1 + elif esc == "threshold": + v["esc_threshold"] = 1 + elif esc == "model": + v["esc_model"] = 1 + return [v.get(n, 0) for n in FEATURE_NAMES] + + +def matrix_sha256(rows): + """SHA-256 hex over one "v1,v2,...\\n" line per row, in order.""" + h = hashlib.sha256() + for r in rows: + h.update((",".join(str(x) for x in r) + "\n").encode()) + return h.hexdigest() diff --git a/tools/decision-train/out/approvals.jsonl b/tools/decision-train/out/approvals.jsonl new file mode 100644 index 0000000..53e6582 --- /dev/null +++ b/tools/decision-train/out/approvals.jsonl @@ -0,0 +1,360 @@ +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"91","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":17,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"100","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"90","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"70","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":19,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"98","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":9,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":3,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":29,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":19,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"85","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S01-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":17,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"054913559a686f0fe4db111692dc29eca8bffa0ab22aa5b8f7c1ff7eb3230548","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s01","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":12,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":18,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"93","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":24,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"73","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":19,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"81","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":2,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":20,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"71","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"90","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"example_id":"syn-S02-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"73","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"approve","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f3a07be98e7043b2410053b102e80afe07212f21124c01e39c8a5e78298d7666","data_classes":[],"name":"list_tickets"},"edit_diff":{"edited":{"action_sha256":"11617d30435cd1c16aa64f75fbabd8a5cf798122e928dd35f25633b08f00a822","data_classes":[],"name":"list_tickets"},"fields_changed":["params"]},"example_id":"syn-S02-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":10,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"list_tickets"},"label":"edit","reason_sha256":"f0895728aa8177b2b4599fa3ebf11c3b22c0e1aec6a410d038b1867ac218238a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s02","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-000","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"83","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":3,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-001","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"90","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-002","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":15,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-003","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"75","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":5,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-004","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"70","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-005","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"97","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":9,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-006","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-007","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"71","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-008","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"95","claimed_confidence":"0.99","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":2,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-009","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":11,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-010","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.99","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a537cd1c06db232c626908ba88dfbb0d3d14c7738a8b326d0e355fd55aa18c44","data_classes":["email"],"name":"sync_contact"},"example_id":"syn-S03-011","features":{"allowed_data_classes":["email"],"allowed_destinations":["crm.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"crm.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":24,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_contact"},"label":"approve","reason_sha256":"fbaa64b7e3fd9f3a49238edcc74d76ef9ff4b0fb4f14d9a011a601c0f6be30f6","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s03","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-000","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"77","claimed_confidence":"0.85","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"eu.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-001","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"90","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"eu.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":16,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-002","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"71","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"in.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-003","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"in.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-004","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"83","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"eu.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-005","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"75","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"eu.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":23,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-006","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"79","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"eu.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":18,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-007","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"80","claimed_confidence":"0.85","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"eu.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-008","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.85","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"in.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":29,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-009","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"77","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"eu.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":10,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-010","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"eu.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":16,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6cbfebf356b0398fa2bd668cdc3cea50f30382cf054a71fb7166758fe08d96ae","data_classes":["email","phone"],"name":"open_support_case"},"example_id":"syn-S04-011","features":{"allowed_data_classes":["email","phone"],"allowed_destinations":["*.support.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","phone"],"observed_destination":"in.support.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":19,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"open_support_case"},"label":"approve","reason_sha256":"937e7917655400896925402400ee93a9347db7872aa0fc3953f720a77105995b","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s04","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-000","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"91","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":4,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"reject","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-001","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"91","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":15,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"reject","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-002","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"76","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"reject","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-003","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":8,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"reject","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-004","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"90","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":10,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"approve","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-005","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"79","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"reject","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-006","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"73","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":16,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"reject","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-007","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"76","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":11,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"reject","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-008","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"reject","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-009","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":12,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"reject","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"edit_diff":{"edited":{"action_sha256":"8534b2f88ec90c849e33cb1bbb46371ce2e3deab34844051233c1914c9eb4a43","data_classes":[],"name":"share_notes"},"fields_changed":["params"]},"example_id":"syn-S05-010","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"77","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":16,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"edit","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"236168b67e79c7185f9527cb676a3971a4bfff4a1de0c80e08bf5b4ec3daa506","data_classes":["email"],"name":"share_notes"},"example_id":"syn-S05-011","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"81","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"paste.example.net","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"share_notes"},"label":"reject","reason_sha256":"7aff0eda28bb47457767c967b6e1b43dcc8c2883b11ab137c8e2ca21d6a9f1a8","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s05","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"example_id":"syn-S06-000","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"79","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":8,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"reject","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"example_id":"syn-S06-001","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":5,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"reject","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"example_id":"syn-S06-002","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"73","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":8,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"reject","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"example_id":"syn-S06-003","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"79","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":2,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"reject","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"example_id":"syn-S06-004","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"83","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":20,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"reject","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"edit_diff":{"edited":{"action_sha256":"6abf74b0d734c87ffe8ee1572e6c17c887fc1275ecdb3add2e6b2c9932237b5d","data_classes":[],"name":"push_metrics"},"fields_changed":["params"]},"example_id":"syn-S06-005","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"71","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":14,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"edit","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"example_id":"syn-S06-006","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":25,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"reject","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"example_id":"syn-S06-007","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"75","claimed_confidence":"0.99","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":19,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"reject","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"example_id":"syn-S06-008","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"76","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"reject","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"edit_diff":{"edited":{"action_sha256":"6abf74b0d734c87ffe8ee1572e6c17c887fc1275ecdb3add2e6b2c9932237b5d","data_classes":[],"name":"push_metrics"},"fields_changed":["params"]},"example_id":"syn-S06-009","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"75","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":22,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"edit","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"example_id":"syn-S06-010","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":19,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"reject","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"982a219a3cd2c90fc70700ea526d769bc99939f8f748b3d78279fea597983b92","data_classes":["aadhaar"],"name":"push_metrics"},"example_id":"syn-S06-011","features":{"allowed_data_classes":["aadhaar"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["aadhaar"],"observed_destination":"collect.analytics.example.org","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":16,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"push_metrics"},"label":"reject","reason_sha256":"4f9a63da99b82692ddab87c611f026c93df83ebdfba8d2e4db16d312992e5774","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s06","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"edit_diff":{"edited":{"action_sha256":"7aba9fd879de195b64aea0adf64d0fb8e168381abee8dc87fecbad2b0cfc90b9","data_classes":[],"name":"charge_card"},"fields_changed":["params"]},"example_id":"syn-S07-000","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":13,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"edit","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-001","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"74","claimed_confidence":"0.99","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":19,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-002","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"72","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":10,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-003","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":18,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-004","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"91","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":2,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-005","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"83","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":3,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-006","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":3,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-007","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"85","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":6,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-008","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"91","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-009","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"87","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":3,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-010","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":4,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S07-011","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"97","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay.example.com","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":24,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"approve","reason_sha256":"110b005a494508b1c08043ccfff3c8883432b8982251938935819ac2070e69f4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s07","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-000","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"73","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-001","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.85","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-002","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"81","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":2,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-003","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"73","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":10,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-004","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":17,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-005","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":12,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-006","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":11,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-007","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"80","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-008","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"76","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":30,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-009","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"85","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-010","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"1ce9005566f5bf5aabe35d74ab446191002abf9d18d8f36e8d232d3f7631dbe4","data_classes":["card"],"name":"charge_card"},"example_id":"syn-S08-011","features":{"allowed_data_classes":["card"],"allowed_destinations":["pay.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["card"],"observed_destination":"pay-example.co","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"charge_card"},"label":"reject","reason_sha256":"a3c216cef40fc88caf5c1fb76b5f18750094d36c1a4f93a522696c3cbfafc20d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s08","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"edit_diff":{"edited":{"action_sha256":"95c90abb0ea823b0868d5dc8493e9e1d9f66356d285a1d1f0cabde00aa09940e","data_classes":[],"name":"call_api"},"fields_changed":["params"]},"example_id":"syn-S09-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"93","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"edit","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"93","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"97","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":9,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":22,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"96","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":11,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":29,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"98","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"75","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"70","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":12,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"72","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":11,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"515c7731d6367104f1431af261488829f178f87e0930fb7ca77785ba3ef19129","data_classes":["credential"],"name":"call_api"},"example_id":"syn-S09-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"98","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":20,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"call_api"},"label":"reject","reason_sha256":"8b1234b7df31953ef39cad6fa0beecfd738e4ee88f040037d82ccafc5ff637de","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s09","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"77","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":5,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"76","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":20,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":19,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":18,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"85","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":22,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"73","claimed_confidence":"0.99","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":12,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"98","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"87","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":25,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"93","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":24,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"98","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":3,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9e62202d7b668ffff84d3ff22ad66b11c04d37b0fe8bf0feb514feb263f678b9","data_classes":["credential"],"name":"store_config"},"example_id":"syn-S10-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"93","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["credential"],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"store_config"},"label":"reject","reason_sha256":"52ed0cc0fc7d22c51c25c0541b8ee56c87098cf2bfdb9b3ff87002b0cee0a611","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s10","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-000","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.93","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":18,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-001","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.92","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":20,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-002","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"97","claimed_confidence":"0.98","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":3,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-003","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"80","claimed_confidence":"0.86","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":21,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-004","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.99","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":15,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-005","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"83","claimed_confidence":"0.95","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":17,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-006","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"96","claimed_confidence":"0.96","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":5,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-007","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.95","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":27,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-008","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"79","claimed_confidence":"0.87","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":28,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-009","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"72","claimed_confidence":"0.93","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":19,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-010","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"100","claimed_confidence":"0.86","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":9,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"7482796f52ccc6dc3b9f9471c17ee3e0a85d1ad592b7a2a69ddb690ac5a89bae","data_classes":["email","pan"],"name":"decide_loan"},"edit_diff":{"edited":{"action_sha256":"bf6dceaf515d27941f86630b982bf221950c721a9d33a27c746aa42f86a93905","data_classes":[],"name":"decide_loan"},"fields_changed":["params"]},"example_id":"syn-S11-011","features":{"allowed_data_classes":["email","pan"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.87","decision_type":"automated","escalated_by":"rules","observed_data_classes":["email","pan"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":["gdpr-eu@v0.1.0"],"prior_action_count":11,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"decide_loan"},"label":"edit","reason_sha256":"e698a0ad97e7e90487b2690ae481cdda7bbafca7724f6d77d28fe060ef9f8226","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s11","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.88","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":19,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.87","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"96","claimed_confidence":"0.86","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":23,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"81","claimed_confidence":"0.97","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":4,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"74","claimed_confidence":"0.85","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.95","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":9,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"reject","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"73","claimed_confidence":"0.96","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":5,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"80","claimed_confidence":"0.89","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":29,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"95","claimed_confidence":"0.85","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"71","claimed_confidence":"0.98","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":24,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"72","claimed_confidence":"0.86","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f324acea037554f4587cdf1f149cb446cba9bead29b7e8fe136f8854a490297f","data_classes":[],"name":"issue_refund"},"example_id":"syn-S12-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"70","claimed_confidence":"0.93","decision_type":"automated","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"issue_refund"},"label":"approve","reason_sha256":"d2f8a7b9568a4e1ac41bba6453a0399df24fd18a0be885248a9d3f2ac6be193e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s12","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-000","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"75","claimed_confidence":"0.91","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":22,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-001","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"94","claimed_confidence":"0.97","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":6,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-002","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"89","claimed_confidence":"0.96","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-003","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"75","claimed_confidence":"0.97","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":28,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-004","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"79","claimed_confidence":"0.85","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":1,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-005","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"82","claimed_confidence":"0.89","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":5,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-006","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"75","claimed_confidence":"0.96","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":24,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-007","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"83","claimed_confidence":"0.92","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":23,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-008","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"88","claimed_confidence":"0.91","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":29,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-009","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"70","claimed_confidence":"0.97","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":25,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-010","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"82","claimed_confidence":"0.91","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":23,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"332944f081a44dc8fd55c0c9ec75e55833f109e7b7c51b396a976987b0877667","data_classes":["aadhaar","pan"],"name":"credit_check"},"edit_diff":{"edited":{"action_sha256":"0796ad4c2bdad1190beec1d3df59c85c469e0f1825f7bf965fcdfa89984c8343","data_classes":[],"name":"credit_check"},"fields_changed":["params"]},"example_id":"syn-S13-011","features":{"allowed_data_classes":["aadhaar","pan"],"allowed_destinations":["bureau.example.in"],"claimed_cluster_health":"97","claimed_confidence":"0.96","decision_type":"automated","escalated_by":"rules","observed_data_classes":["aadhaar","pan"],"observed_destination":"bureau.example.in","original_input_hash":"","original_seq":0,"policy_packs":["dpdp-in@v0.1.0"],"prior_action_count":21,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"credit_check"},"label":"edit","reason_sha256":"74476c915fef6eba2b7e97e247c77bda06fa548601e492f2d66ab2321b87621d","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s13","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":37,"prior_denied_count":12,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"90","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":36,"prior_denied_count":12,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"98","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":20,"prior_denied_count":8,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":37,"prior_denied_count":15,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"81","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":39,"prior_denied_count":15,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":20,"prior_denied_count":14,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"76","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":23,"prior_denied_count":11,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":35,"prior_denied_count":13,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"95","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":29,"prior_denied_count":13,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":32,"prior_denied_count":13,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"95","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":36,"prior_denied_count":8,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S14-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":30,"prior_denied_count":8,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"6255deddfdf80da5a10da59c6e793cc3977129554e3bddf66189ec51d0c557ee","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s14","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S15-000","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"97","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"approve","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-001","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"97","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":4,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-002","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":16,"prior_denied_count":4,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-003","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":23,"prior_denied_count":2,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-004","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"90","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":30,"prior_denied_count":4,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-005","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":2,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-006","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":8,"prior_denied_count":2,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-007","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"87","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":2,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-008","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":29,"prior_denied_count":4,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-009","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"98","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":24,"prior_denied_count":2,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-010","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"93","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":3,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"edit_diff":{"edited":{"action_sha256":"b98cd46d72aa7164decb5f936abb9c08844f0d8141cbbd9cd620a4bbfb43bede","data_classes":[],"name":"send_email"},"fields_changed":["params"]},"example_id":"syn-S15-011","features":{"allowed_data_classes":["email"],"allowed_destinations":["mail.example.com"],"claimed_cluster_health":"90","claimed_confidence":"0.85","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mail.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":22,"prior_denied_count":3,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"edit","reason_sha256":"7d2aafa75e7f5bcb92e8cb83e99600e9108e89d45390a267afe5692c07326c66","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s15","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"example_id":"syn-S16-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"85","claimed_confidence":"0.22","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"reject","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.23","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"90","claimed_confidence":"0.43","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":10,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"81","claimed_confidence":"0.11","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"71","claimed_confidence":"0.39","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"80","claimed_confidence":"0.3","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":29,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"75","claimed_confidence":"0.17","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":8,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.39","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":10,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"75","claimed_confidence":"0.28","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":25,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.28","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.43","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S16-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.28","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":25,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"b39844246c13493b79c57e4b2d39562cc348b883d3e473cdfc9d76041c243d8a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s16","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"74","claimed_confidence":"0.43","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":20,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"reject","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"83","claimed_confidence":"0.3","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":8,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"70","claimed_confidence":"0.26","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.41","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"79","claimed_confidence":"0.3","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"81","claimed_confidence":"0.27","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.27","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.4","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":26,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.43","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":30,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"79","claimed_confidence":"0.43","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":16,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.33","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":12,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S17-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"77","claimed_confidence":"0.45","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":3,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"b61b0fd71a4d533188b8dba1ce4bbadc68fbce790dff00e5e7a4e23ee166c549","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s17","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"100","claimed_confidence":"0.99","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"100","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":25,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":2,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"74","claimed_confidence":"0.85","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":12,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":25,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"93","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":5,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.85","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"c97772042f598135be10a9c0780e60856bbd23e0258a9351f3ad6d37cb07f147","data_classes":[],"name":"delete_records"},"edit_diff":{"edited":{"action_sha256":"533e3f6928e3299e90b1a31c390faa8b367b744c59d0861d1ca5644fabc9e63f","data_classes":[],"name":"delete_records"},"fields_changed":["params"]},"example_id":"syn-S18-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"80","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"delete_records"},"label":"edit","reason_sha256":"c706856a67cb0b8dcb560d2da2f0e6e81dcb52eae334e50b2072baf82b2b8c50","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s18","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.99","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":8,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"73","claimed_confidence":"0.85","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"95","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":25,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"99","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":23,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"77","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":17,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"74","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":2,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"91","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"edit_diff":{"edited":{"action_sha256":"4e948f25295b31313d233fe471a43a5ea534b778b572d9a94006b4ac60c1c47c","data_classes":[],"name":"purge_cache"},"fields_changed":["params"]},"example_id":"syn-S19-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"edit","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"77","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"85","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":2,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"5e55b0fc071ada2be7b08cf2848717ff34e0a59ec2295719c70192c173b8e1eb","data_classes":[],"name":"purge_cache"},"example_id":"syn-S19-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":9,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"purge_cache"},"label":"approve","reason_sha256":"68bb3c5f9ed5fb5e6ee3dbc51c792d11aa2c532d8e6eb98e68503fba89213d7e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s19","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"28","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"15","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"38","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":25,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"24","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":17,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"38","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":3,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"37","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"39","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":18,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"17","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"27","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"30","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":18,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"19","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":5,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"f44017602f9a886528ccad063ff734e7bea6270ec564116bd86fac2c4bc7d8d3","data_classes":[],"name":"drop_table"},"example_id":"syn-S20-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"15","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":9,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"drop_table"},"label":"reject","reason_sha256":"0b30fe1e1458c12d48caa42650ddae736914dcbb1d2d970d53efa89c8cea9cd5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s20","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-000","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-001","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"96","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-002","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":8,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-003","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"74","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-004","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-005","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"75","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":4,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-006","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"74","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":18,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-007","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"96","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-008","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"90","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":17,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-009","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":5,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-010","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":18,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"cfc941331dc7dbfc7bc3d075664b3565000cf38b395e69989487506be4d86476","data_classes":["email"],"name":"reset_password"},"edit_diff":{"edited":{"action_sha256":"1fd1f7be4c62deb50d898eff75c6f60bfc277dcd91e4faa1b0bf22f1a4c5290c","data_classes":[],"name":"reset_password"},"fields_changed":["params"]},"example_id":"syn-S21-011","features":{"allowed_data_classes":["email"],"allowed_destinations":["idp.example.com"],"claimed_cluster_health":"73","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"idp.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":19,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"reset_password"},"label":"edit","reason_sha256":"32cd36a27a038f6efa10d8db96950f5fc4bb45f51c302c99bacbf3c640c15798","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s21","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-000","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"93","claimed_confidence":"0.86","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":5,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-001","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"76","claimed_confidence":"0.93","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-002","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"91","claimed_confidence":"0.94","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":26,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-003","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"80","claimed_confidence":"0.92","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":10,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-004","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"89","claimed_confidence":"0.9","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":12,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-005","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"100","claimed_confidence":"0.91","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":30,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-006","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"81","claimed_confidence":"0.88","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-007","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"75","claimed_confidence":"0.96","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":15,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-008","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"99","claimed_confidence":"0.98","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":26,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-009","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"77","claimed_confidence":"0.97","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":20,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-010","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"80","claimed_confidence":"0.91","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":26,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"26d8a787f636d022f95db63ee0f572d2590cbe0ff5b63badf9f9b23d7343371c","data_classes":["email"],"name":"send_email"},"example_id":"syn-S22-011","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"99","claimed_confidence":"0.95","decision_type":"","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"mailer.example.io","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":11,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"send_email"},"label":"reject","reason_sha256":"a527d73609d06fe3b5a9f2cbd3daa00d72a0b7cba2d6606f55a43c736518589e","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s22","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-000","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"70","claimed_confidence":"0.89","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":15,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-001","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"84","claimed_confidence":"0.99","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-002","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"87","claimed_confidence":"0.9","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-003","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"83","claimed_confidence":"0.95","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":17,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-004","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"84","claimed_confidence":"0.98","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":24,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"edit_diff":{"edited":{"action_sha256":"48198e83e719637036a77b241e3342b9a252ed52001b3dcda11c135644aabe39","data_classes":[],"name":"read_report"},"fields_changed":["params"]},"example_id":"syn-S23-005","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"71","claimed_confidence":"0.95","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":20,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"edit","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-006","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"100","claimed_confidence":"0.92","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-007","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"98","claimed_confidence":"0.98","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":10,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-008","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"70","claimed_confidence":"0.87","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-009","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"77","claimed_confidence":"0.88","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":11,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-010","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"84","claimed_confidence":"0.88","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a571cb5c2dc16a6e642b58826766bcbd5a543f0ab7c7db5be8bd067c227c5f97","data_classes":[],"name":"read_report"},"example_id":"syn-S23-011","features":{"allowed_data_classes":[],"allowed_destinations":[],"claimed_cluster_health":"75","claimed_confidence":"0.88","decision_type":"","escalated_by":"rules","observed_data_classes":[],"observed_destination":"","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":8,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"read_report"},"label":"approve","reason_sha256":"9eb4a24b4f7eee097c32e54a9bb2205dbf76197759d7b523e638f3ab6130b3ae","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s23","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"edit_diff":{"edited":{"action_sha256":"6e3ff888c358b0d5c8863e0d5105a46e6ed05950d0b20848c45a08c4edac9a52","data_classes":[],"name":"sync_invoice"},"fields_changed":["params"]},"example_id":"syn-S24-000","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"edit","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"edit_diff":{"edited":{"action_sha256":"6e3ff888c358b0d5c8863e0d5105a46e6ed05950d0b20848c45a08c4edac9a52","data_classes":[],"name":"sync_invoice"},"fields_changed":["params"]},"example_id":"syn-S24-001","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"97","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"edit","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"example_id":"syn-S24-002","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"75","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":15,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"reject","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"edit_diff":{"edited":{"action_sha256":"6e3ff888c358b0d5c8863e0d5105a46e6ed05950d0b20848c45a08c4edac9a52","data_classes":[],"name":"sync_invoice"},"fields_changed":["params"]},"example_id":"syn-S24-003","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"85","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":4,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"edit","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"edit_diff":{"edited":{"action_sha256":"6e3ff888c358b0d5c8863e0d5105a46e6ed05950d0b20848c45a08c4edac9a52","data_classes":[],"name":"sync_invoice"},"fields_changed":["params"]},"example_id":"syn-S24-004","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":3,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"edit","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"edit_diff":{"edited":{"action_sha256":"6e3ff888c358b0d5c8863e0d5105a46e6ed05950d0b20848c45a08c4edac9a52","data_classes":[],"name":"sync_invoice"},"fields_changed":["params"]},"example_id":"syn-S24-005","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"edit","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"example_id":"syn-S24-006","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"70","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":20,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"approve","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"edit_diff":{"edited":{"action_sha256":"6e3ff888c358b0d5c8863e0d5105a46e6ed05950d0b20848c45a08c4edac9a52","data_classes":[],"name":"sync_invoice"},"fields_changed":["params"]},"example_id":"syn-S24-007","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"93","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"edit","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"edit_diff":{"edited":{"action_sha256":"6e3ff888c358b0d5c8863e0d5105a46e6ed05950d0b20848c45a08c4edac9a52","data_classes":[],"name":"sync_invoice"},"fields_changed":["params"]},"example_id":"syn-S24-008","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":17,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"edit","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"edit_diff":{"edited":{"action_sha256":"6e3ff888c358b0d5c8863e0d5105a46e6ed05950d0b20848c45a08c4edac9a52","data_classes":[],"name":"sync_invoice"},"fields_changed":["params"]},"example_id":"syn-S24-009","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":16,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"edit","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"edit_diff":{"edited":{"action_sha256":"6e3ff888c358b0d5c8863e0d5105a46e6ed05950d0b20848c45a08c4edac9a52","data_classes":[],"name":"sync_invoice"},"fields_changed":["params"]},"example_id":"syn-S24-010","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"70","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":24,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"edit","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"bfe7f4badf04f0a2db7b672fb0b71eda151901eaffb49df3904e54213a74c3f6","data_classes":["email","iban"],"name":"sync_invoice"},"edit_diff":{"edited":{"action_sha256":"6e3ff888c358b0d5c8863e0d5105a46e6ed05950d0b20848c45a08c4edac9a52","data_classes":[],"name":"sync_invoice"},"fields_changed":["params"]},"example_id":"syn-S24-011","features":{"allowed_data_classes":["email"],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"92","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email","iban"],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":23,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"sync_invoice"},"label":"edit","reason_sha256":"07640b82e5359b9383fd8b73eedc9714651873034245c0bafa3b6fe05732f114","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s24","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-000","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":10,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-001","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":18,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-002","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"71","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":30,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-003","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":22,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-004","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"87","claimed_confidence":"0.85","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-005","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"86","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-006","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"74","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":16,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-007","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":26,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-008","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"93","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-009","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"72","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":6,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-010","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"81","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":2,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"9fe93faa01b57c3febc93b09bb5fe89c2dab54bcbd644e51871a2bc82f801cd1","data_classes":["iban"],"name":"pay_supplier"},"example_id":"syn-S25-011","features":{"allowed_data_classes":["iban"],"allowed_destinations":["bank.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["iban"],"observed_destination":"bank.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":2,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"pay_supplier"},"label":"approve","reason_sha256":"95477ea3200fe678bcfca08331a4f67906192e20b58cafb3d39a40439a32b55a","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s25","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"71","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"97","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"95","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"84","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":26,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"83","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"97","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":8,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"98","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"82","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"79","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":5,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"73","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"95","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":29,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"e9fcd9427ab38102f0daf93279830f1bdb7873e315ce0e83f5d58e689c02eff4","data_classes":[],"name":"update_record"},"edit_diff":{"edited":{"action_sha256":"261f5711c1cb81a71c9787a590013049f6cedc78e495e39bec7214d23a4354c9","data_classes":[],"name":"update_record"},"fields_changed":["params"]},"example_id":"syn-S26-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"96","claimed_confidence":"","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":18,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"update_record"},"label":"edit","reason_sha256":"72194916ad11d8d31a975ffe02a871eef1232b4b8a3ce16f0fa03b5296efdefa","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s26","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-000","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"96","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-001","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-002","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"84","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-003","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"80","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-004","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"97","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-005","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"96","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-006","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"97","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-007","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.89","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-008","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"example_id":"syn-S27-009","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"91","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"reject","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-010","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"96","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"d0efcff37400e5e7cf15f5d06c197f935916f37c9e2d470d253bff87b305c2b8","data_classes":["email"],"name":"export_contacts"},"edit_diff":{"edited":{"action_sha256":"1a59b216562720a6c9c13c659625f89d6a7a685c6daab894685ea7cdd4bbe575","data_classes":[],"name":"export_contacts"},"fields_changed":["params"]},"example_id":"syn-S27-011","features":{"allowed_data_classes":["email"],"allowed_destinations":["s3.example.com"],"claimed_cluster_health":"79","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":["email"],"observed_destination":"s3.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":0,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"export_contacts"},"label":"edit","reason_sha256":"7cfefcf10cdf625b1c594c00cb3abd2d0b36936bdf1795ae0b8767a69dfb8cf5","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s27","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"9","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":8,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"16","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":9,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"24","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":23,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"15","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":4,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"7","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":18,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"34","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":24,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"6","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":2,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"14","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":13,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"11","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":3,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"11","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":3,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"6","claimed_confidence":"0.88","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":26,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"a9a0e167c9fcf42f7b50ada28195fe07e0403fed8379308232fcafe01672c2f8","data_classes":[],"name":"scale_deployment"},"example_id":"syn-S28-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"30","claimed_confidence":"0.94","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":1,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"scale_deployment"},"label":"reject","reason_sha256":"ab4a9fd146d7722d82630ef17c8d50b236f3e9256c3ba41bcf5569719da45bb7","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s28","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-000","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"91","claimed_confidence":"0.97","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":4,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-001","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"95","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":21,"prior_denied_count":8,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-002","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"91","claimed_confidence":"0.92","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":19,"prior_denied_count":6,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-003","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"74","claimed_confidence":"0.93","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":19,"prior_denied_count":7,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-004","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.9","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":20,"prior_denied_count":4,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-005","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"88","claimed_confidence":"0.98","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":7,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-006","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.87","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":25,"prior_denied_count":7,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-007","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":12,"prior_denied_count":7,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-008","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"85","claimed_confidence":"0.91","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":4,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-009","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"72","claimed_confidence":"0.86","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":15,"prior_denied_count":7,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-010","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"97","claimed_confidence":"0.95","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":5,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"facb2fcb11993eae423d69bf1ef059f8fb40dcb4efa145c976c045074a36cd0d","data_classes":[],"name":"wipe_logs"},"example_id":"syn-S29-011","features":{"allowed_data_classes":[],"allowed_destinations":["api.internal.example.com"],"claimed_cluster_health":"95","claimed_confidence":"0.96","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"api.internal.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":28,"prior_denied_count":4,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"wipe_logs"},"label":"reject","reason_sha256":"b21af3058eee76e10ac46a3332513ff570d9467ecd767fb2a954c8ee1987d7cd","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s29","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-000","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"94","claimed_confidence":"0.6","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":7,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-001","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"100","claimed_confidence":"0.63","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":15,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-002","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"82","claimed_confidence":"0.71","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":11,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-003","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"96","claimed_confidence":"0.62","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":8,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-004","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"81","claimed_confidence":"0.7","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-005","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.79","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":26,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-006","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"80","claimed_confidence":"0.75","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":9,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-007","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"93","claimed_confidence":"0.66","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":4,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-008","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"98","claimed_confidence":"0.78","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":4,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-009","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"78","claimed_confidence":"0.55","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":27,"prior_denied_count":1,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-010","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"89","claimed_confidence":"0.74","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":14,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} +{"approver_sha256":"b3cc0475bb78a5026098858e9889acf666d31062d513d303314eca31d36e72f2","capture_mode":"redacted","content":{"action_sha256":"6c46b44d5ef768ad1ac135e29f6ffc376976abd745bf83fd9da504c144f6d17b","data_classes":[],"name":"write_summary"},"example_id":"syn-S30-011","features":{"allowed_data_classes":[],"allowed_destinations":["wiki.example.com"],"claimed_cluster_health":"74","claimed_confidence":"0.57","decision_type":"assisted","escalated_by":"rules","observed_data_classes":[],"observed_destination":"wiki.example.com","original_input_hash":"","original_seq":0,"policy_packs":[],"prior_action_count":26,"prior_denied_count":0,"reason_codes":["synthetic"],"threshold_mode":"strict","tool":"write_summary"},"label":"approve","reason_sha256":"055bed53640420346f8c5c82a57f8de52d6b2a8617877e8afc409f26c1fcc3c4","receipt_entry_hash":"","receipt_seq":0,"schema_version":1,"source":"synthetic","timestamp":"1970-01-01T00:00:00Z","workload":{"name":"s30","namespace":"synthetic","uid":""}} diff --git a/tools/decision-train/out/golden.json b/tools/decision-train/out/golden.json new file mode 100644 index 0000000..8712966 --- /dev/null +++ b/tools/decision-train/out/golden.json @@ -0,0 +1,1004 @@ +[ + { + "example_id": "syn-S01-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 2, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": -351177, + "risk_micro": 413097 + }, + { + "example_id": "syn-S02-000", + "features": [ + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": -858625, + "risk_micro": 297627 + }, + { + "example_id": "syn-S03-000", + "features": [ + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": -181101, + "risk_micro": 454848 + }, + { + "example_id": "syn-S04-000", + "features": [ + 2, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": -200572, + "risk_micro": 450024 + }, + { + "example_id": "syn-S05-000", + "features": [ + 1, + 0, + 0, + 1, + 1, + 0, + 1, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 2559233, + "risk_micro": 928191 + }, + { + "example_id": "syn-S06-000", + "features": [ + 1, + 0, + 0, + 1, + 1, + 0, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 1989042, + "risk_micro": 879642 + }, + { + "example_id": "syn-S07-000", + "features": [ + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": -254741, + "risk_micro": 436657 + }, + { + "example_id": "syn-S08-000", + "features": [ + 1, + 0, + 0, + 1, + 1, + 0, + 1, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 1432876, + "risk_micro": 807349 + }, + { + "example_id": "syn-S09-000", + "features": [ + 0, + 1, + 1, + 0, + 1, + 1, + 1, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 1627079, + "risk_micro": 835769 + }, + { + "example_id": "syn-S10-000", + "features": [ + 0, + 1, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 1559890, + "risk_micro": 826338 + }, + { + "example_id": "syn-S11-000", + "features": [ + 2, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 2, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 1, + 0, + 1, + 0, + 0 + ], + "logit_micro": 1799710, + "risk_micro": 858114 + }, + { + "example_id": "syn-S12-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 2, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 143777, + "risk_micro": 535882 + }, + { + "example_id": "syn-S13-000", + "features": [ + 2, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 2, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 689147, + "risk_micro": 665777 + }, + { + "example_id": "syn-S14-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 2, + 10, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 3864468, + "risk_micro": 979457 + }, + { + "example_id": "syn-S15-000", + "features": [ + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 287304, + "risk_micro": 571336 + }, + { + "example_id": "syn-S16-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 531044, + "risk_micro": 629727 + }, + { + "example_id": "syn-S17-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 2, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": -595313, + "risk_micro": 355417 + }, + { + "example_id": "syn-S18-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 932140, + "risk_micro": 717509 + }, + { + "example_id": "syn-S19-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 463735, + "risk_micro": 613900 + }, + { + "example_id": "syn-S20-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 1878943, + "risk_micro": 867490 + }, + { + "example_id": "syn-S21-000", + "features": [ + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 1, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 472410, + "risk_micro": 615954 + }, + { + "example_id": "syn-S22-000", + "features": [ + 1, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 1873819, + "risk_micro": 866900 + }, + { + "example_id": "syn-S23-000", + "features": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": -950278, + "risk_micro": 278829 + }, + { + "example_id": "syn-S24-000", + "features": [ + 2, + 0, + 1, + 0, + 1, + 1, + 1, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 1167621, + "risk_micro": 762715 + }, + { + "example_id": "syn-S25-000", + "features": [ + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": -496004, + "risk_micro": 378480 + }, + { + "example_id": "syn-S26-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 2, + 1, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 376859, + "risk_micro": 593115 + }, + { + "example_id": "syn-S27-000", + "features": [ + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 133802, + "risk_micro": 533401 + }, + { + "example_id": "syn-S28-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 1378934, + "risk_micro": 798820 + }, + { + "example_id": "syn-S29-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 1, + 2, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": 2022452, + "risk_micro": 883134 + }, + { + "example_id": "syn-S30-000", + "features": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "logit_micro": -1245361, + "risk_micro": 223504 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": -40000000, + "risk_micro": 0 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": -30000000, + "risk_micro": 0 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": -5500000, + "risk_micro": 4070 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": -1, + "risk_micro": 500000 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": 0, + "risk_micro": 500000 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": 1, + "risk_micro": 500000 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": 250000, + "risk_micro": 562177 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": 999999, + "risk_micro": 731058 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": 1000000, + "risk_micro": 731059 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": 7123456, + "risk_micro": 999195 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": 30000000, + "risk_micro": 1000000 + }, + { + "example_id": "sigmoid", + "features": [], + "logit_micro": 40000000, + "risk_micro": 1000000 + } +] diff --git a/tools/decision-train/out/metrics.json b/tools/decision-train/out/metrics.json new file mode 100644 index 0000000..f3c99ac --- /dev/null +++ b/tools/decision-train/out/metrics.json @@ -0,0 +1,199 @@ +{ + "active_features": [ + "dc_personal_count", + "dc_credential", + "dc_undeclared_count", + "personal_to_undeclared", + "dest_present", + "dest_declared", + "declared_present", + "decision_automated", + "action_destructive", + "prior_actions_bucket", + "prior_denied", + "conf_absent", + "conf_low", + "conf_mid", + "health_low", + "health_mid", + "pack_any", + "pack_dpdp_in", + "pack_gdpr_eu" + ], + "artifact_sha256": "5aa47544fc3726134da1d1ffe3fa3d2f3da5e8c8e93bf76f7bf8b512f4b3d5db", + "data_source": "synthetic", + "feature_matrix_sha256": "f6734700e3e7fc3b17e01803e0a8b00bd94ff3c3ce8ce3bfe20d0449a436fb5e", + "holdout": { + "bins": [ + { + "count": 0, + "lower_micro": 0, + "mean_predicted_micro": 0, + "observed_micro": 0, + "upper_micro": 100000 + }, + { + "count": 1, + "lower_micro": 100000, + "mean_predicted_micro": 192335, + "observed_micro": 0, + "upper_micro": 200000 + }, + { + "count": 2, + "lower_micro": 200000, + "mean_predicted_micro": 260565, + "observed_micro": 0, + "upper_micro": 300000 + }, + { + "count": 9, + "lower_micro": 300000, + "mean_predicted_micro": 346333, + "observed_micro": 0, + "upper_micro": 400000 + }, + { + "count": 11, + "lower_micro": 400000, + "mean_predicted_micro": 446209, + "observed_micro": 272727, + "upper_micro": 500000 + }, + { + "count": 5, + "lower_micro": 500000, + "mean_predicted_micro": 555739, + "observed_micro": 200000, + "upper_micro": 600000 + }, + { + "count": 7, + "lower_micro": 600000, + "mean_predicted_micro": 649618, + "observed_micro": 714285, + "upper_micro": 700000 + }, + { + "count": 12, + "lower_micro": 700000, + "mean_predicted_micro": 742522, + "observed_micro": 1000000, + "upper_micro": 800000 + }, + { + "count": 18, + "lower_micro": 800000, + "mean_predicted_micro": 859844, + "observed_micro": 1000000, + "upper_micro": 900000 + }, + { + "count": 11, + "lower_micro": 900000, + "mean_predicted_micro": 942934, + "observed_micro": 909090, + "upper_micro": 1000000 + } + ], + "ece_micro": 183617, + "examples": 76, + "extra_review_rate_micro": 259259, + "false_negative": 3, + "false_positive": 7, + "negatives": 27, + "positives": 49, + "precision_micro": 867924, + "recall_micro": 938775, + "true_negative": 20, + "true_positive": 46 + }, + "model_id": "clawdlinux-escalate-logreg", + "threshold_micro": 500000, + "train": { + "bins": [ + { + "count": 0, + "lower_micro": 0, + "mean_predicted_micro": 0, + "observed_micro": 0, + "upper_micro": 100000 + }, + { + "count": 4, + "lower_micro": 100000, + "mean_predicted_micro": 172636, + "observed_micro": 0, + "upper_micro": 200000 + }, + { + "count": 14, + "lower_micro": 200000, + "mean_predicted_micro": 261065, + "observed_micro": 142857, + "upper_micro": 300000 + }, + { + "count": 21, + "lower_micro": 300000, + "mean_predicted_micro": 349582, + "observed_micro": 142857, + "upper_micro": 400000 + }, + { + "count": 45, + "lower_micro": 400000, + "mean_predicted_micro": 447538, + "observed_micro": 177777, + "upper_micro": 500000 + }, + { + "count": 34, + "lower_micro": 500000, + "mean_predicted_micro": 543194, + "observed_micro": 500000, + "upper_micro": 600000 + }, + { + "count": 45, + "lower_micro": 600000, + "mean_predicted_micro": 644590, + "observed_micro": 733333, + "upper_micro": 700000 + }, + { + "count": 35, + "lower_micro": 700000, + "mean_predicted_micro": 761121, + "observed_micro": 942857, + "upper_micro": 800000 + }, + { + "count": 60, + "lower_micro": 800000, + "mean_predicted_micro": 858985, + "observed_micro": 1000000, + "upper_micro": 900000 + }, + { + "count": 26, + "lower_micro": 900000, + "mean_predicted_micro": 943570, + "observed_micro": 1000000, + "upper_micro": 1000000 + } + ], + "ece_micro": 142876, + "examples": 284, + "extra_review_rate_micro": 303921, + "false_negative": 13, + "false_positive": 31, + "negatives": 102, + "positives": 182, + "precision_micro": 845000, + "recall_micro": 928571, + "true_negative": 71, + "true_positive": 169 + }, + "version": "0.1.0-synthetic-draft" +} diff --git a/tools/decision-train/out/model.json b/tools/decision-train/out/model.json new file mode 100644 index 0000000..2adb1bb --- /dev/null +++ b/tools/decision-train/out/model.json @@ -0,0 +1,158 @@ +{ + "artifact_sha256": "5aa47544fc3726134da1d1ffe3fa3d2f3da5e8c8e93bf76f7bf8b512f4b3d5db", + "bias": "-0.445828", + "calibration": { + "bins": [ + { + "count": 0, + "lower_micro": 0, + "mean_predicted_micro": 0, + "observed_micro": 0, + "upper_micro": 100000 + }, + { + "count": 1, + "lower_micro": 100000, + "mean_predicted_micro": 192335, + "observed_micro": 0, + "upper_micro": 200000 + }, + { + "count": 2, + "lower_micro": 200000, + "mean_predicted_micro": 260565, + "observed_micro": 0, + "upper_micro": 300000 + }, + { + "count": 9, + "lower_micro": 300000, + "mean_predicted_micro": 346333, + "observed_micro": 0, + "upper_micro": 400000 + }, + { + "count": 11, + "lower_micro": 400000, + "mean_predicted_micro": 446209, + "observed_micro": 272727, + "upper_micro": 500000 + }, + { + "count": 5, + "lower_micro": 500000, + "mean_predicted_micro": 555739, + "observed_micro": 200000, + "upper_micro": 600000 + }, + { + "count": 7, + "lower_micro": 600000, + "mean_predicted_micro": 649618, + "observed_micro": 714285, + "upper_micro": 700000 + }, + { + "count": 12, + "lower_micro": 700000, + "mean_predicted_micro": 742522, + "observed_micro": 1000000, + "upper_micro": 800000 + }, + { + "count": 18, + "lower_micro": 800000, + "mean_predicted_micro": 859844, + "observed_micro": 1000000, + "upper_micro": 900000 + }, + { + "count": 11, + "lower_micro": 900000, + "mean_predicted_micro": 942934, + "observed_micro": 909090, + "upper_micro": 1000000 + } + ], + "ece_micro": 183617, + "method": "none", + "split": "holdout" + }, + "feature_spec_version": "v1", + "features": [ + "dc_personal_count", + "dc_credential", + "dc_undeclared_count", + "personal_to_undeclared", + "dest_present", + "dest_declared", + "declared_present", + "decision_automated", + "action_destructive", + "prior_actions_bucket", + "prior_denied", + "conf_absent", + "conf_low", + "conf_mid", + "health_absent", + "health_low", + "health_mid", + "pack_any", + "pack_dpdp_in", + "pack_gdpr_eu", + "esc_invariant", + "esc_rules", + "esc_threshold", + "esc_model" + ], + "model_id": "clawdlinux-escalate-logreg", + "model_type": "logistic_regression", + "schema_version": 1, + "threshold_micro": 500000, + "trained_on": { + "data_source": "synthetic", + "dataset_sha256": "6cec36b556e9f873d3937225feaf019d0fbba82c4161201512d4b39a33e8520e", + "examples_holdout": 76, + "examples_total": 360, + "examples_train": 284, + "human_rows": 0, + "negatives": 102, + "positives": 182, + "synthetic_rows": 360 + }, + "training": { + "iterations": "2000", + "l2": "0.01", + "label": "1 when a human rejected or edited", + "learning_rate": "0.3", + "method": "full-batch gradient descent", + "split": "holdout when sha256(example_id)[:8] mod 5 == 0" + }, + "version": "0.1.0-synthetic-draft", + "weights": [ + "0.323578", + "1.106614", + "1.340047", + "1.527634", + "0.783338", + "-0.744295", + "0.217009", + "0.494954", + "0.968414", + "-0.314903", + "0.468405", + "1.071085", + "0.567318", + "-0.740682", + "0.000000", + "1.415208", + "-0.343049", + "0.416680", + "-0.175417", + "0.592097", + "0.000000", + "0.000000", + "0.000000", + "0.000000" + ] +} diff --git a/tools/decision-train/run.sh b/tools/decision-train/run.sh new file mode 100755 index 0000000..00c41ca --- /dev/null +++ b/tools/decision-train/run.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Seed the DRAFT synthetic dataset and train the model twice into separate +# directories. Fails unless both runs give byte-identical artifacts. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +out="${1:-$here/out}" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +run() { + mkdir -p "$1" + python3 "$here/seed.py" --scenarios "$here/scenarios.json" --out "$1/approvals.jsonl" + python3 "$here/train.py" --data "$1/approvals.jsonl" --out-dir "$1" +} + +run "$tmp/a" +run "$tmp/b" + +sum() { python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$1"; } +for f in approvals.jsonl model.json metrics.json golden.json; do + a="$(sum "$tmp/a/$f")" + b="$(sum "$tmp/b/$f")" + if [ "$a" != "$b" ]; then + echo "decision-train: $f differs between runs ($a vs $b)" >&2 + exit 1 + fi + echo "decision-train: $f identical across 2 runs, sha256 $a" +done + +mkdir -p "$out" +cp "$tmp/a/approvals.jsonl" "$tmp/a/model.json" "$tmp/a/metrics.json" "$tmp/a/golden.json" "$out/" +echo "decision-train: wrote $out (synthetic DRAFT data, not production-validated)" diff --git a/tools/decision-train/scenarios.json b/tools/decision-train/scenarios.json new file mode 100644 index 0000000..0c7242b --- /dev/null +++ b/tools/decision-train/scenarios.json @@ -0,0 +1,1066 @@ +{ + "draft": true, + "note": "DRAFT scenarios written by the engineer, not the founder. Replace with founder-written scenarios before trusting any number they produce.", + "seed": 20261002, + "rows_per_scenario": 12, + "label_noise_micro": 50000, + "scenarios": [ + { + "id": "S01", + "title": "Clean read of an internal report", + "label": "approve", + "rationale": "Read only, declared host, no data classes.", + "action": "read_report", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S02", + "title": "List tickets inside the cluster", + "label": "approve", + "rationale": "No egress, no personal data.", + "action": "list_tickets", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S03", + "title": "Email to the declared CRM", + "label": "approve", + "rationale": "Email is declared and the CRM host is declared.", + "action": "sync_contact", + "decision_type": "assisted", + "allowed_data_classes": [ + "email" + ], + "allowed_destinations": [ + "crm.example.com" + ], + "destinations": [ + "crm.example.com" + ], + "data_classes": [ + "email" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S04", + "title": "Phone and email to the declared support desk", + "label": "approve", + "rationale": "Both classes and the host are declared.", + "action": "open_support_case", + "decision_type": "assisted", + "allowed_data_classes": [ + "email", + "phone" + ], + "allowed_destinations": [ + "*.support.example.com" + ], + "destinations": [ + "eu.support.example.com", + "in.support.example.com" + ], + "data_classes": [ + "email", + "phone" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S05", + "title": "Email to an undeclared paste site", + "label": "reject", + "rationale": "Personal data leaving to a host nobody declared.", + "action": "share_notes", + "decision_type": "assisted", + "allowed_data_classes": [ + "email" + ], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "paste.example.net" + ], + "data_classes": [ + "email" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S06", + "title": "Aadhaar to an undeclared analytics host", + "label": "reject", + "rationale": "Government ID to an undeclared host. Never.", + "action": "push_metrics", + "decision_type": "assisted", + "allowed_data_classes": [ + "aadhaar" + ], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "collect.analytics.example.org" + ], + "data_classes": [ + "aadhaar" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [ + "dpdp-in@v0.1.0" + ] + }, + { + "id": "S07", + "title": "Card numbers to the declared payment processor", + "label": "approve", + "rationale": "Card is declared, processor host is declared.", + "action": "charge_card", + "decision_type": "assisted", + "allowed_data_classes": [ + "card" + ], + "allowed_destinations": [ + "pay.example.com" + ], + "destinations": [ + "pay.example.com" + ], + "data_classes": [ + "card" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [ + "dpdp-in@v0.1.0" + ] + }, + { + "id": "S08", + "title": "Card numbers to an undeclared host", + "label": "reject", + "rationale": "Card data to an unknown host.", + "action": "charge_card", + "decision_type": "assisted", + "allowed_data_classes": [ + "card" + ], + "allowed_destinations": [ + "pay.example.com" + ], + "destinations": [ + "pay-example.co" + ], + "data_classes": [ + "card" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S09", + "title": "Credential in params to a declared host", + "label": "reject", + "rationale": "A secret in the payload. INV-01 denies this anyway.", + "action": "call_api", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [ + "credential" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S10", + "title": "Credential in params, no destination", + "label": "reject", + "rationale": "A secret in the payload is never fine.", + "action": "store_config", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "" + ], + "data_classes": [ + "credential" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S11", + "title": "Automated loan decision on personal data", + "label": "edit", + "rationale": "Human adds a review note and lowers the amount.", + "action": "decide_loan", + "decision_type": "automated", + "allowed_data_classes": [ + "email", + "pan" + ], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [ + "email", + "pan" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [ + "gdpr-eu@v0.1.0" + ] + }, + { + "id": "S12", + "title": "Automated refund with no personal data", + "label": "approve", + "rationale": "Automated, but no personal data and a small amount.", + "action": "issue_refund", + "decision_type": "automated", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S13", + "title": "Automated credit check with Aadhaar and PAN", + "label": "edit", + "rationale": "Human narrows the fields sent to the bureau.", + "action": "credit_check", + "decision_type": "automated", + "allowed_data_classes": [ + "aadhaar", + "pan" + ], + "allowed_destinations": [ + "bureau.example.in" + ], + "destinations": [ + "bureau.example.in" + ], + "data_classes": [ + "aadhaar", + "pan" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [ + "dpdp-in@v0.1.0" + ] + }, + { + "id": "S14", + "title": "Workload with many recent denials", + "label": "reject", + "rationale": "History says this agent keeps proposing bad actions.", + "action": "read_report", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 20, + 40 + ], + "prior_denied_count": [ + 8, + 15 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S15", + "title": "Some recent denials on a declared email send", + "label": "edit", + "rationale": "Human fixes the recipient list.", + "action": "send_email", + "decision_type": "assisted", + "allowed_data_classes": [ + "email" + ], + "allowed_destinations": [ + "mail.example.com" + ], + "destinations": [ + "mail.example.com" + ], + "data_classes": [ + "email" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 2, + 4 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S16", + "title": "Low claimed confidence on an update", + "label": "edit", + "rationale": "Agent is unsure. Human corrects the change.", + "action": "update_record", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.1, + 0.45 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S17", + "title": "Low claimed confidence on a read", + "label": "approve", + "rationale": "A read cannot do much harm.", + "action": "read_report", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.2, + 0.45 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S18", + "title": "Delete records with high confidence", + "label": "edit", + "rationale": "Human narrows the delete to a date range.", + "action": "delete_records", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S19", + "title": "Purge a cache", + "label": "approve", + "rationale": "Destructive name, harmless effect.", + "action": "purge_cache", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S20", + "title": "Drop a table while the cluster is unhealthy", + "label": "reject", + "rationale": "Destructive and the platform is degraded.", + "action": "drop_table", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 10, + 45 + ], + "policy_packs": [] + }, + { + "id": "S21", + "title": "Reset a user password", + "label": "edit", + "rationale": "Human requires a notification step.", + "action": "reset_password", + "decision_type": "assisted", + "allowed_data_classes": [ + "email" + ], + "allowed_destinations": [ + "idp.example.com" + ], + "destinations": [ + "idp.example.com" + ], + "data_classes": [ + "email" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S22", + "title": "No manifest, email to an unknown host", + "label": "reject", + "rationale": "Nothing declared and personal data leaves.", + "action": "send_email", + "decision_type": "", + "allowed_data_classes": [], + "allowed_destinations": [], + "destinations": [ + "mailer.example.io" + ], + "data_classes": [ + "email" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S23", + "title": "No manifest, read only", + "label": "approve", + "rationale": "Nothing declared but nothing leaves.", + "action": "read_report", + "decision_type": "", + "allowed_data_classes": [], + "allowed_destinations": [], + "destinations": [ + "" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S24", + "title": "Undeclared IBAN to a declared host", + "label": "edit", + "rationale": "Human strips the IBAN field.", + "action": "sync_invoice", + "decision_type": "assisted", + "allowed_data_classes": [ + "email" + ], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [ + "email", + "iban" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S25", + "title": "Declared IBAN to the declared bank", + "label": "approve", + "rationale": "Class and host both declared.", + "action": "pay_supplier", + "decision_type": "assisted", + "allowed_data_classes": [ + "iban" + ], + "allowed_destinations": [ + "bank.example.com" + ], + "destinations": [ + "bank.example.com" + ], + "data_classes": [ + "iban" + ], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S26", + "title": "Write with no claimed confidence", + "label": "edit", + "rationale": "Agent did not say how sure it is. Human checks the change.", + "action": "update_record", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": null, + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S27", + "title": "Bulk email export from a brand new workload", + "label": "edit", + "rationale": "First action ever. Human limits the batch size.", + "action": "export_contacts", + "decision_type": "assisted", + "allowed_data_classes": [ + "email" + ], + "allowed_destinations": [ + "s3.example.com" + ], + "destinations": [ + "s3.example.com" + ], + "data_classes": [ + "email" + ], + "prior_action_count": [ + 0, + 0 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S28", + "title": "Scale up on an unhealthy cluster", + "label": "reject", + "rationale": "Platform health is low. Wait.", + "action": "scale_deployment", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 5, + 40 + ], + "policy_packs": [] + }, + { + "id": "S29", + "title": "Wipe logs after recent denials", + "label": "reject", + "rationale": "Destructive and the agent has a bad record.", + "action": "wipe_logs", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "api.internal.example.com" + ], + "destinations": [ + "api.internal.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 10, + 30 + ], + "prior_denied_count": [ + 3, + 8 + ], + "confidence": [ + 0.85, + 0.99 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + }, + { + "id": "S30", + "title": "Write a summary to the declared wiki", + "label": "approve", + "rationale": "No personal data, declared host, medium confidence is fine.", + "action": "write_summary", + "decision_type": "assisted", + "allowed_data_classes": [], + "allowed_destinations": [ + "wiki.example.com" + ], + "destinations": [ + "wiki.example.com" + ], + "data_classes": [], + "prior_action_count": [ + 0, + 30 + ], + "prior_denied_count": [ + 0, + 1 + ], + "confidence": [ + 0.55, + 0.79 + ], + "cluster_health": [ + 70, + 100 + ], + "policy_packs": [] + } + ] +} diff --git a/tools/decision-train/seed.py b/tools/decision-train/seed.py new file mode 100644 index 0000000..68a0768 --- /dev/null +++ b/tools/decision-train/seed.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Expand scenarios.json into a labelled synthetic approvals.jsonl. + +Every row has the approvals.jsonl Example schema plus "source": "synthetic". +Synthetic rows bind to no receipt, so `agentctl dataset verify` and +`agentctl decision eval` treat them as unverified. Stdlib only. + +Usage: seed.py --scenarios scenarios.json --out out/approvals.jsonl +""" + +import argparse +import hashlib +import json +import random + +LABELS = ["approve", "reject", "edit"] + + +def sha(s): + return hashlib.sha256(s.encode()).hexdigest() + + +def pick_int(rng, lo_hi): + lo, hi = lo_hi + return rng.randint(lo, hi) + + +def row(sc, i, rng, noise_micro): + dest = rng.choice(sc["destinations"]) + conf = "" + if sc["confidence"] is not None: + lo, hi = sc["confidence"] + conf = repr(round(rng.uniform(lo, hi), 2)) + health = "" + if sc["cluster_health"] is not None: + health = str(pick_int(rng, sc["cluster_health"])) + actions = pick_int(rng, sc["prior_action_count"]) + denied = min(pick_int(rng, sc["prior_denied_count"]), actions) if actions > 0 else 0 + label = sc["label"] + if rng.randrange(1_000_000) < noise_micro: + label = rng.choice([x for x in LABELS if x != label]) + ex_id = "syn-%s-%03d" % (sc["id"], i) + content = { + "action_sha256": sha(sc["action"]), + "data_classes": sorted(sc["data_classes"]), + "name": sc["action"], + } + ex = { + "schema_version": 1, + "example_id": ex_id, + "receipt_seq": 0, + "receipt_entry_hash": "", + "workload": {"namespace": "synthetic", "name": sc["id"].lower(), "uid": ""}, + "features": { + "original_seq": 0, + "original_input_hash": "", + "decision_type": sc["decision_type"], + "allowed_data_classes": sorted(sc["allowed_data_classes"]), + "allowed_destinations": sorted(sc["allowed_destinations"]), + "observed_destination": dest, + "observed_data_classes": sorted(sc["data_classes"]), + "tool": sc["action"], + "prior_action_count": actions, + "prior_denied_count": denied, + "claimed_confidence": conf, + "claimed_cluster_health": health, + "policy_packs": sorted(sc["policy_packs"]), + "threshold_mode": "strict", + "escalated_by": "rules", + "reason_codes": ["synthetic"], + }, + "label": label, + "reason_sha256": sha(sc["rationale"]), + "approver_sha256": sha("synthetic"), + "timestamp": "1970-01-01T00:00:00Z", + "capture_mode": "redacted", + "content": content, + "source": "synthetic", + } + if label == "edit": + ex["edit_diff"] = { + "fields_changed": ["params"], + "edited": {"action_sha256": sha(sc["action"] + ":edited"), "data_classes": [], "name": sc["action"]}, + } + return ex + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--scenarios", required=True) + ap.add_argument("--out", required=True) + args = ap.parse_args() + with open(args.scenarios) as f: + doc = json.load(f) + lines = [] + for idx, sc in enumerate(doc["scenarios"]): + rng = random.Random(doc["seed"] * 1000 + idx) + for i in range(doc["rows_per_scenario"]): + lines.append(json.dumps(row(sc, i, rng, doc["label_noise_micro"]), sort_keys=True, separators=(",", ":"))) + with open(args.out, "w") as f: + f.write("\n".join(lines) + "\n") + print("seed: wrote %d synthetic rows from %d DRAFT scenarios to %s" % (len(lines), len(doc["scenarios"]), args.out)) + + +if __name__ == "__main__": + main() diff --git a/tools/decision-train/train.py b/tools/decision-train/train.py new file mode 100644 index 0000000..751a591 --- /dev/null +++ b/tools/decision-train/train.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Train the escalate-only decision model on approvals.jsonl data. + +Logistic regression, full-batch gradient descent, fixed seed and fixed +iteration count, so the same data gives the same artifact bytes. Label 1 +means a human rejected or edited the action. Weights are rounded to 6 +decimals and written as strings. Metrics use the same integer inference as +the Go operator (pkg/decision/learned), so the numbers match exactly. +Stdlib only. + +Usage: train.py --data out/approvals.jsonl --out-dir out [--threshold-micro N] +""" + +import argparse +import hashlib +import json +import math +import os + +from features import FEATURE_NAMES, FEATURE_SPEC_VERSION, features, matrix_sha256 + +MICRO = 1_000_000 +EXP_SCALE = 1_000_000_000 +E_INV = 367_879_441 +MAX_LOGIT = 30 * MICRO +HOLDOUT_MOD = 5 # one in five examples, chosen by example_id hash + + +def exp_neg(x): + """e^(-x) at EXP_SCALE for x >= 0 in micro-units. Same steps as Go.""" + n = x // MICRO + f = (x % MICRO) * 1000 + s, term = EXP_SCALE, EXP_SCALE + for k in range(1, 26): + term = term * f // (EXP_SCALE * k) + if term == 0: + break + s = s - term if k % 2 == 1 else s + term + for _ in range(n): + s = s * E_INV // EXP_SCALE + return s + + +def sigmoid_micro(z): + z = max(-MAX_LOGIT, min(MAX_LOGIT, z)) + neg = z < 0 + e = exp_neg(-z if neg else z) + den = EXP_SCALE + e + if neg: + return (e * MICRO + den // 2) // den + return (EXP_SCALE * MICRO + den // 2) // den + + +def fmt6(w): + s = "%.6f" % w + return "0.000000" if s == "-0.000000" else s + + +def parse_micro(s): + neg = s.startswith("-") + ip, _, fr = s.lstrip("-").partition(".") + v = int(ip) * MICRO + int((fr + "000000")[:6]) + return -v if neg else v + + +def logit_micro(wm, bm, x): + return bm + sum(w * v for w, v in zip(wm, x)) + + +def fit(X, y, active, iters, lr, l2): + n, d = len(X), len(FEATURE_NAMES) + w, b = [0.0] * d, 0.0 + for _ in range(iters): + gw, gb = [0.0] * d, 0.0 + for xi, yi in zip(X, y): + z = b + sum(w[j] * xi[j] for j in active) + z = max(-30.0, min(30.0, z)) + err = 1.0 / (1.0 + math.exp(-z)) - yi + gb += err + for j in active: + gw[j] += err * xi[j] + for j in active: + w[j] -= lr * (gw[j] / n + l2 * w[j]) + b -= lr * gb / n + return w, b + + +def evaluate(risks, y, threshold): + tp = fp = tn = fn = 0 + for r, t in zip(risks, y): + esc = r >= threshold + if esc and t: + tp += 1 + elif esc: + fp += 1 + elif t: + fn += 1 + else: + tn += 1 + bins = [] + n = len(risks) + ece_num = 0 + for k in range(10): + lo, hi = k * 100_000, (k + 1) * 100_000 + idx = [i for i, r in enumerate(risks) if min(r // 100_000, 9) == k] + c = len(idx) + mean = sum(risks[i] for i in idx) // c if c else 0 + obs = sum(y[i] for i in idx) * MICRO // c if c else 0 + ece_num += c * abs(mean - obs) + bins.append({"lower_micro": lo, "upper_micro": hi, "count": c, "mean_predicted_micro": mean, "observed_micro": obs}) + return { + "examples": n, + "positives": tp + fn, + "negatives": fp + tn, + "true_positive": tp, + "false_positive": fp, + "true_negative": tn, + "false_negative": fn, + "recall_micro": tp * MICRO // (tp + fn) if tp + fn else 0, + "precision_micro": tp * MICRO // (tp + fp) if tp + fp else 0, + "extra_review_rate_micro": fp * MICRO // (fp + tn) if fp + tn else 0, + "ece_micro": ece_num // n if n else 0, + "bins": bins, + } + + +def canonical_sha(obj): + o = dict(obj) + o.pop("artifact_sha256", None) + raw = json.dumps(o, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(raw.encode()).hexdigest() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--data", required=True) + ap.add_argument("--out-dir", required=True) + ap.add_argument("--threshold-micro", type=int, default=500_000) + ap.add_argument("--iterations", type=int, default=2000) + ap.add_argument("--learning-rate", default="0.3") + ap.add_argument("--l2", default="0.01") + ap.add_argument("--model-version", default="0.1.0-synthetic-draft") + args = ap.parse_args() + + with open(args.data, "rb") as f: + raw = f.read() + rows = [json.loads(l) for l in raw.decode().splitlines() if l.strip()] + X = [features(r) for r in rows] + y = [1 if r["label"] in ("reject", "edit") else 0 for r in rows] + hold = [int(hashlib.sha256(r["example_id"].encode()).hexdigest()[:8], 16) % HOLDOUT_MOD == 0 for r in rows] + Xtr = [x for x, h in zip(X, hold) if not h] + ytr = [t for t, h in zip(y, hold) if not h] + # A feature constant in the training split keeps weight 0. Otherwise it + # would soak up the bias and skew live scores where it differs. + active = [j for j in range(len(FEATURE_NAMES)) if len({x[j] for x in Xtr}) > 1] + + w, b = fit(Xtr, ytr, active, args.iterations, float(args.learning_rate), float(args.l2)) + weights = [fmt6(v) for v in w] + bias = fmt6(b) + wm = [parse_micro(s) for s in weights] + bm = parse_micro(bias) + risks = [sigmoid_micro(logit_micro(wm, bm, x)) for x in X] + rh = [r for r, h in zip(risks, hold) if h] + yh = [t for t, h in zip(y, hold) if h] + rt = [r for r, h in zip(risks, hold) if not h] + holdout = evaluate(rh, yh, args.threshold_micro) + train = evaluate(rt, ytr, args.threshold_micro) + + sources = [r.get("source", "") for r in rows] + synth = sum(1 for s in sources if s == "synthetic") + human = sum(1 for s in sources if s == "") + data_source = "synthetic" if human == 0 else ("human" if synth == 0 else "mixed") + art = { + "schema_version": 1, + "model_id": "clawdlinux-escalate-logreg", + "version": args.model_version, + "model_type": "logistic_regression", + "feature_spec_version": FEATURE_SPEC_VERSION, + "features": FEATURE_NAMES, + "weights": weights, + "bias": bias, + "threshold_micro": args.threshold_micro, + "calibration": {"method": "none", "split": "holdout", "bins": holdout["bins"], "ece_micro": holdout["ece_micro"]}, + "trained_on": { + "data_source": data_source, + "dataset_sha256": hashlib.sha256(raw).hexdigest(), + "examples_total": len(rows), + "examples_train": len(Xtr), + "examples_holdout": len(rh), + "positives": sum(ytr), + "negatives": len(ytr) - sum(ytr), + "synthetic_rows": synth, + "human_rows": human, + }, + "training": { + "method": "full-batch gradient descent", + "iterations": str(args.iterations), + "learning_rate": args.learning_rate, + "l2": args.l2, + "split": "holdout when sha256(example_id)[:8] mod %d == 0" % HOLDOUT_MOD, + "label": "1 when a human rejected or edited", + }, + } + art["artifact_sha256"] = canonical_sha(art) + metrics = { + "model_id": art["model_id"], + "version": art["version"], + "artifact_sha256": art["artifact_sha256"], + "data_source": data_source, + "threshold_micro": args.threshold_micro, + "feature_matrix_sha256": matrix_sha256(X), + "active_features": [FEATURE_NAMES[j] for j in active], + "train": train, + "holdout": holdout, + } + golden = [] + for r, x in list(zip(rows, X))[:: max(1, len(rows) // 30)]: + golden.append({"example_id": r["example_id"], "features": x, "logit_micro": logit_micro(wm, bm, x), "risk_micro": sigmoid_micro(logit_micro(wm, bm, x))}) + for z in (-40_000_000, -30_000_000, -5_500_000, -1, 0, 1, 250_000, 999_999, 1_000_000, 7_123_456, 30_000_000, 40_000_000): + golden.append({"example_id": "sigmoid", "features": [], "logit_micro": z, "risk_micro": sigmoid_micro(z)}) + + def dump(name, obj): + with open(os.path.join(args.out_dir, name), "w") as f: + f.write(json.dumps(obj, indent=2, sort_keys=True) + "\n") + + dump("model.json", art) + dump("metrics.json", metrics) + dump("golden.json", golden) + h = holdout + print("train: %s data, %d rows (%d train, %d holdout), artifact sha256 %s" % (data_source, len(rows), len(Xtr), len(rh), art["artifact_sha256"])) + print("holdout at threshold %d micro: recall %d, precision %d, extra review %d, ECE %d (micro)" % ( + args.threshold_micro, h["recall_micro"], h["precision_micro"], h["extra_review_rate_micro"], h["ece_micro"])) + + +if __name__ == "__main__": + main() From 9f99e048f3381618ec8bc47f9a31dc41218bc3c1 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:20:16 +0530 Subject: [PATCH 13/54] feat(receipts): model block with integer option micro-units, base and final outcome, model layer Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- pkg/receipts/model_test.go | 80 ++++++++++++++++++++++++++++++++++++++ pkg/receipts/record.go | 68 +++++++++++++++++++++++++++----- 2 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 pkg/receipts/model_test.go diff --git a/pkg/receipts/model_test.go b/pkg/receipts/model_test.go new file mode 100644 index 0000000..a49b734 --- /dev/null +++ b/pkg/receipts/model_test.go @@ -0,0 +1,80 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" +) + +func TestModelBlockRecord(t *testing.T) { + s := decision.Score{ + ModelID: "m", Version: "1", ArtifactSHA256: strings.Repeat("a", 64), FeatureSpec: "v1", FeatureHash: strings.Repeat("b", 64), + RiskMicro: 700000, OptionMicro: decision.Options(700000), ReasonCodes: []string{"feature:conf_low"}, + ThresholdMicro: 500000, Calibration: "none", + } + base := decision.Result{Outcome: decision.Allow, Layer: decision.LayerThreshold} + final := decision.ApplyModel(base, s, nil, decision.ModelEscalate) + block := NewModelBlock(decision.ModelEscalate, s, nil, base, final) + if !reflect.DeepEqual(block.OptionSet, []string{"allow", "require_approval"}) || !reflect.DeepEqual(block.OptionMicro, []int64{300000, 700000}) { + t.Fatalf("options %v %v", block.OptionSet, block.OptionMicro) + } + if block.BaseOutcome != "allow" || block.Outcome != "require_approval" || block.Error != "" || block.SchemaVersion != ModelBlockVersion { + t.Fatalf("block %+v", block) + } + rec, err := NewDecisionRecord(Params{Action: "a", Result: final, Model: &block}) + if err != nil { + t.Fatal(err) + } + if rec.Layer != LayerModel || rec.Outcome != OutcomeRequireApproval { + t.Fatalf("layer %s outcome %s", rec.Layer, rec.Outcome) + } + h1, err := receiptspec.HashRecord(rec) + if err != nil { + t.Fatalf("model block must be a valid record: %v", err) + } + rec2, _ := NewDecisionRecord(Params{Action: "a", Result: final, Model: &block}) + if h2, _ := receiptspec.HashRecord(rec2); h1 != h2 { + t.Fatal("record not deterministic") + } + raw, _ := json.Marshal(rec) + if strings.Contains(string(raw), "e+") || strings.Contains(string(raw), ".5") { + t.Fatalf("float-like number in record: %s", raw) + } +} + +func TestModelBlockError(t *testing.T) { + base := decision.Result{Outcome: decision.Allow, Layer: decision.LayerThreshold} + b := NewModelBlock(decision.ModelShadow, decision.Score{}, errors.New("scorer down"), base, base) + if b.Error != "scorer down" || !reflect.DeepEqual(b.OptionMicro, []int64{0, 0}) || b.Outcome != "allow" { + t.Fatalf("block %+v", b) + } + rec, err := NewDecisionRecord(Params{Action: "a", Result: base, Model: &b}) + if err != nil { + t.Fatal(err) + } + if _, err := receiptspec.HashRecord(rec); err != nil { + t.Fatal(err) + } +} + +func TestNoModelBlockByDefault(t *testing.T) { + rec, err := NewDecisionRecord(Params{Action: "a", Result: decision.Result{Outcome: decision.Allow, Layer: decision.LayerThreshold}}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(rec) + if rec.Model != nil || strings.Contains(string(raw), `"model"`) { + t.Fatalf("model block present without a model: %s", raw) + } +} diff --git a/pkg/receipts/record.go b/pkg/receipts/record.go index ca2f07f..2a132cf 100644 --- a/pkg/receipts/record.go +++ b/pkg/receipts/record.go @@ -32,7 +32,7 @@ const Service = "clawdlinux" // ReplayHint tells a reviewer how to reproduce the outcome. const ReplayHint = "deterministic-v1: rerun invariants, policy_packs, and threshold_mode on an input matching input_hash" -// Record layers. "model" is reserved until a model decides. +// Record layers. const ( LayerInvariant = "invariant" LayerRules = "rules" @@ -91,15 +91,60 @@ type ClaimedSummary struct { ClusterHealth string `json:"cluster_health,omitempty"` } -// Model is reserved for the decision model layer. It is nil until a model -// decides. Probabilities and numbers are decimal strings. +// ModelBlockVersion is the schema version of the Model block. The record +// SchemaVersion stays 1: the block was reserved and never emitted before. +const ModelBlockVersion = 1 + +// Model is the decision model block. It is set whenever a model scored the +// action, in shadow or escalate mode, so the score can be replayed. Every +// number is an integer in micro-units (1000000 is 1.0). type Model struct { - ID string `json:"id"` - Version string `json:"version"` - OptionSet []string `json:"option_set"` - Probabilities []string `json:"probabilities"` - Threshold string `json:"threshold"` - Calibration string `json:"calibration"` + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + ID string `json:"id"` + Version string `json:"version"` + ArtifactSHA256 string `json:"artifact_sha256"` + FeatureSpec string `json:"feature_spec"` + FeatureHash string `json:"feature_hash"` + OptionSet []string `json:"option_set"` + OptionMicro []int64 `json:"option_micro"` + RiskMicro int64 `json:"risk_micro"` + ThresholdMicro int64 `json:"threshold_micro"` + Calibration string `json:"calibration"` + ReasonCodes []string `json:"reason_codes"` + // BaseOutcome is the outcome before the model. Outcome is after it. + BaseOutcome string `json:"base_outcome"` + Outcome string `json:"outcome"` + // Error is set when the scorer failed. Shadow logs it, escalate escalates. + Error string `json:"error,omitempty"` +} + +// NewModelBlock records one model evaluation. s may be zero when err is set. +func NewModelBlock(mode decision.ModelMode, s decision.Score, scoreErr error, base, final decision.Result) Model { + m := Model{ + SchemaVersion: ModelBlockVersion, + Mode: string(mode), + ID: s.ModelID, + Version: s.Version, + ArtifactSHA256: s.ArtifactSHA256, + FeatureSpec: s.FeatureSpec, + FeatureHash: s.FeatureHash, + OptionSet: append([]string{}, decision.OptionSet...), + OptionMicro: make([]int64, len(decision.OptionSet)), + RiskMicro: s.RiskMicro, + ThresholdMicro: s.ThresholdMicro, + Calibration: s.Calibration, + ReasonCodes: append([]string{}, s.ReasonCodes...), + BaseOutcome: string(base.Outcome), + Outcome: string(final.Outcome), + } + for i, o := range decision.OptionSet { + m.OptionMicro[i] = s.OptionMicro[o] + } + if scoreErr != nil { + m.Error = scoreErr.Error() + } + return m } // DecisionRecord is the record bound to a receipt by its hash. Fields are @@ -213,6 +258,8 @@ type Params struct { Result decision.Result PolicyPacks []string ThresholdMode string + // Model is the model block, or nil when no model scored the action. + Model *Model } // NewDecisionRecord builds the record for one decided action. The same params @@ -267,6 +314,7 @@ func NewDecisionRecord(p Params) (DecisionRecord, error) { }, PolicyPacks: sortedCopy(p.PolicyPacks), ThresholdMode: p.ThresholdMode, + Model: p.Model, ReplayHint: ReplayHint, }, nil } @@ -319,6 +367,8 @@ func mapResult(r decision.Result) (outcome, layer string, err error) { layer = LayerRules case decision.LayerThreshold: layer = LayerThreshold + case decision.LayerModel: + layer = LayerModel default: return "", "", fmt.Errorf("receipts: unknown layer %q", r.Layer) } From fa5275c0bd3de8c1ce021d6daa8da71da8b5ada6 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:21:26 +0530 Subject: [PATCH 14/54] feat(api): spec.decisionModel mode and stricter-only thresholdMicro, webhook check Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- api/v1alpha1/agentworkload_types.go | 40 ++++++++++++++++++ api/v1alpha1/agentworkload_webhook.go | 34 +++++++++++++++ api/v1alpha1/agentworkload_webhook_test.go | 42 +++++++++++++++++++ api/v1alpha1/zz_generated.deepcopy.go | 25 +++++++++++ ...agentic.clawdlinux.org_agentworkloads.yaml | 27 ++++++++++++ 5 files changed, 168 insertions(+) diff --git a/api/v1alpha1/agentworkload_types.go b/api/v1alpha1/agentworkload_types.go index 9e94b43..380eebd 100644 --- a/api/v1alpha1/agentworkload_types.go +++ b/api/v1alpha1/agentworkload_types.go @@ -160,6 +160,46 @@ type AgentWorkloadSpec struct { // action content of each human decision. See docs/approvals.md. // +optional ApprovalCapture *ApprovalCapture `json:"approvalCapture,omitempty"` + + // decisionModel sets how the escalate-only decision model is used on the + // direct action path. Nothing runs until the operator has a model + // artifact (DECISION_MODEL_PATH). See docs/architecture/decision-model.md. + // +optional + DecisionModel *DecisionModel `json:"decisionModel,omitempty"` +} + +// DecisionModel configures the decision model for one workload. +type DecisionModel struct { + // mode is off (never scored), shadow (scored and recorded, no effect), or + // escalate (may move allow to require_approval, never anything looser). + // +kubebuilder:validation:Enum=off;shadow;escalate + // +kubebuilder:default=shadow + // +optional + Mode string `json:"mode,omitempty"` + + // thresholdMicro overrides the artifact escalation threshold, in + // micro-units (1000000 is 1.0). It may only be stricter: at or below the + // artifact threshold. A looser value is rejected by the webhook and + // ignored by the controller. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=1000000 + // +optional + ThresholdMicro *int64 `json:"thresholdMicro,omitempty"` +} + +// Decision model modes. +const ( + DecisionModelOff = "off" + DecisionModelShadow = "shadow" + DecisionModelEscalate = "escalate" +) + +// DecisionModelMode returns the effective mode. Unset means shadow. +func (s AgentWorkloadSpec) DecisionModelMode() string { + if s.DecisionModel == nil || s.DecisionModel.Mode == "" { + return DecisionModelShadow + } + return s.DecisionModel.Mode } // ApprovalCapture sets the content capture mode for approval examples. diff --git a/api/v1alpha1/agentworkload_webhook.go b/api/v1alpha1/agentworkload_webhook.go index 5befcf0..b3a95bd 100644 --- a/api/v1alpha1/agentworkload_webhook.go +++ b/api/v1alpha1/agentworkload_webhook.go @@ -24,6 +24,7 @@ import ( "regexp" "strconv" "strings" + "sync/atomic" "time" "unicode/utf8" @@ -193,6 +194,9 @@ func (r *AgentWorkload) validate() error { } } + // 8. Validate decisionModel. The threshold may only be stricter. + allErrs = append(allErrs, validateDecisionModel(r.Spec.DecisionModel)...) + // Combine errors if len(allErrs) > 0 { errMsg := strings.Join(allErrs, "; ") @@ -208,6 +212,36 @@ func (r *AgentWorkload) validate() error { return nil } +// decisionModelThreshold is the loaded artifact threshold in micro-units, or +// 0 when no artifact is loaded. Set once by the operator at startup. +var decisionModelThreshold atomic.Int64 + +// SetDecisionModelThreshold tells the webhook the loaded artifact threshold, +// so a looser workload override is rejected at admission. +func SetDecisionModelThreshold(micro int64) { decisionModelThreshold.Store(micro) } + +func validateDecisionModel(dm *DecisionModel) []string { + if dm == nil { + return nil + } + var errs []string + switch dm.Mode { + case "", DecisionModelOff, DecisionModelShadow, DecisionModelEscalate: + default: + errs = append(errs, fmt.Sprintf("decisionModel.mode must be one of off, shadow, escalate, got %q", dm.Mode)) + } + if t := dm.ThresholdMicro; t != nil { + art := decisionModelThreshold.Load() + switch { + case *t < 0 || *t > 1000000: + errs = append(errs, fmt.Sprintf("decisionModel.thresholdMicro must be 0 to 1000000, got %d", *t)) + case art > 0 && *t > art: + errs = append(errs, fmt.Sprintf("decisionModel.thresholdMicro %d is looser than the model artifact threshold %d; only a stricter (lower) value is allowed", *t, art)) + } + } + return errs +} + // validateMCPEndpoint validates the MCP server endpoint func validateMCPEndpoint(endpoint string) error { if endpoint == "" { diff --git a/api/v1alpha1/agentworkload_webhook_test.go b/api/v1alpha1/agentworkload_webhook_test.go index f2b968c..d6891a2 100644 --- a/api/v1alpha1/agentworkload_webhook_test.go +++ b/api/v1alpha1/agentworkload_webhook_test.go @@ -339,3 +339,45 @@ func TestWebhook_PolicyPacks(t *testing.T) { }) } } + +func TestWebhook_DecisionModel(t *testing.T) { + i := func(v int64) *int64 { return &v } + tests := []struct { + name string + dm *DecisionModel + artifact int64 + wantErr bool + }{ + {"unset", nil, 0, false}, + {"shadow", &DecisionModel{Mode: "shadow"}, 0, false}, + {"escalate stricter", &DecisionModel{Mode: "escalate", ThresholdMicro: i(300000)}, 500000, false}, + {"equal to artifact", &DecisionModel{ThresholdMicro: i(500000)}, 500000, false}, + {"looser than artifact", &DecisionModel{ThresholdMicro: i(600000)}, 500000, true}, + {"no artifact loaded, range only", &DecisionModel{ThresholdMicro: i(900000)}, 0, false}, + {"out of range", &DecisionModel{ThresholdMicro: i(1000001)}, 0, true}, + {"negative", &DecisionModel{ThresholdMicro: i(-1)}, 0, true}, + {"bad mode", &DecisionModel{Mode: "auto-allow"}, 0, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + SetDecisionModelThreshold(tc.artifact) + defer SetDecisionModelThreshold(0) + w := &AgentWorkload{Spec: AgentWorkloadSpec{ + MCPServerEndpoint: stringPtr("https://localhost:8000"), + Objective: stringPtr("test objective"), + Agents: []string{"agent1"}, + DecisionModel: tc.dm, + }} + err := w.ValidateCreate() + if (err != nil) != tc.wantErr { + t.Fatalf("ValidateCreate err = %v, wantErr %v", err, tc.wantErr) + } + if err != nil && !strings.Contains(err.Error(), "decisionModel") { + t.Fatalf("error should name decisionModel: %v", err) + } + }) + } + if (AgentWorkloadSpec{}).DecisionModelMode() != DecisionModelShadow { + t.Fatal("default mode must be shadow") + } +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index a5988e2..98b43c5 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -540,6 +540,11 @@ func (in *AgentWorkloadSpec) DeepCopyInto(out *AgentWorkloadSpec) { *out = new(ApprovalCapture) **out = **in } + if in.DecisionModel != nil { + in, out := &in.DecisionModel, &out.DecisionModel + *out = new(DecisionModel) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentWorkloadSpec. @@ -671,6 +676,26 @@ func (in *ArgoWorkflowRef) DeepCopy() *ArgoWorkflowRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecisionModel) DeepCopyInto(out *DecisionModel) { + *out = *in + if in.ThresholdMicro != nil { + in, out := &in.ThresholdMicro, &out.ThresholdMicro + *out = new(int64) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecisionModel. +func (in *DecisionModel) DeepCopy() *DecisionModel { + if in == nil { + return nil + } + out := new(DecisionModel) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DeclaredIntent) DeepCopyInto(out *DeclaredIntent) { *out = *in diff --git a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml index 41b20e0..571eead 100644 --- a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml +++ b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml @@ -101,6 +101,33 @@ spec: - team - delegation type: string + decisionModel: + description: |- + decisionModel sets how the escalate-only decision model is used on the + direct action path. Nothing runs until the operator has a model + artifact (DECISION_MODEL_PATH). See docs/architecture/decision-model.md. + properties: + mode: + default: shadow + description: |- + mode is off (never scored), shadow (scored and recorded, no effect), or + escalate (may move allow to require_approval, never anything looser). + enum: + - "off" + - shadow + - escalate + type: string + thresholdMicro: + description: |- + thresholdMicro overrides the artifact escalation threshold, in + micro-units (1000000 is 1.0). It may only be stricter: at or below the + artifact threshold. A looser value is rejected by the webhook and + ignored by the controller. + format: int64 + maximum: 1000000 + minimum: 0 + type: integer + type: object declaredIntent: description: |- declaredIntent states what this workload is for and what it may touch. From 3292a50742de59e98e83cd2720d55b88697277a9 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:24:04 +0530 Subject: [PATCH 15/54] feat(controller): shadow and escalate-only decision model on the direct path, DECISION_MODEL_PATH Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- cmd/main.go | 22 +++ cmd/main_test.go | 27 +++ .../controller/agentworkload_controller.go | 13 +- .../agentworkload_controller_model_test.go | 159 ++++++++++++++++++ internal/controller/approvals.go | 2 +- internal/controller/decision_model.go | 71 ++++++++ internal/controller/receipts.go | 7 +- 7 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 internal/controller/agentworkload_controller_model_test.go create mode 100644 internal/controller/decision_model.go diff --git a/cmd/main.go b/cmd/main.go index 32125aa..55492ea 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -58,6 +58,7 @@ import ( "github.com/Clawdlinux/agentic-operator-core/internal/netpolicy" "github.com/Clawdlinux/agentic-operator-core/internal/netpolicy/netprobe" "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/learned" "github.com/Clawdlinux/agentic-operator-core/pkg/evaluation" "github.com/Clawdlinux/agentic-operator-core/pkg/finops" "github.com/Clawdlinux/agentic-operator-core/pkg/governance" @@ -292,6 +293,7 @@ func main() { os.Exit(1) } workloadReconciler.Receipts = buildReceiptsConfig(receiptsCfg, setupLog) + workloadReconciler.DecisionModel = buildDecisionModelConfig(os.Getenv("DECISION_MODEL_PATH"), setupLog) if err := workloadReconciler.SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "AgentWorkload") @@ -388,6 +390,26 @@ func buildReceiptsConfig(cfg receipts.Config, logger logr.Logger) controller.Rec return out } +// buildDecisionModelConfig loads the decision model artifact. No path means +// no model. A bad artifact is logged and also means no model: decisions then +// stay exactly as without a model. It never blocks startup. +func buildDecisionModelConfig(path string, logger logr.Logger) controller.DecisionModelConfig { + if path == "" { + logger.Info("Decision model off: DECISION_MODEL_PATH not set") + return controller.DecisionModelConfig{} + } + m, err := learned.Load(path) + if err != nil { + logger.Error(err, "Decision model artifact rejected; running without a model", "path", path) + return controller.DecisionModelConfig{} + } + a := m.Artifact() + agenticv1alpha1.SetDecisionModelThreshold(m.ThresholdMicro()) + logger.Info("Decision model loaded", "id", a.ModelID, "version", a.Version, "artifactSHA256", a.ArtifactSHA256, + "thresholdMicro", a.ThresholdMicro, "dataSource", a.TrainedOn.DataSource) + return controller.DecisionModelConfig{Scorer: m} +} + func registerFinOpsMetrics(registerer prometheus.Registerer, reporter *finops.MemoryCostReporter) error { if registerer == nil || reporter == nil { return nil diff --git a/cmd/main_test.go b/cmd/main_test.go index 946788e..beab50a 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -76,3 +77,29 @@ func TestBuildReceiptsConfig(t *testing.T) { }) } } + +func TestBuildDecisionModelConfig(t *testing.T) { + shipped := "../tools/decision-train/out/model.json" + bad := t.TempDir() + "/bad.json" + if err := os.WriteFile(bad, []byte(`{"schema_version":1}`), 0o600); err != nil { + t.Fatal(err) + } + tests := []struct { + name string + path string + wantModel bool + }{ + {"unset means no model", "", false}, + {"missing file means no model", t.TempDir() + "/none.json", false}, + {"bad artifact means no model", bad, false}, + {"shipped artifact loads", shipped, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := buildDecisionModelConfig(tc.path, logr.Discard()) + if (got.Scorer != nil) != tc.wantModel { + t.Fatalf("scorer loaded = %v, want %v", got.Scorer != nil, tc.wantModel) + } + }) + } +} diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index 27b95de..f1c61d0 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -104,6 +104,7 @@ type AgentWorkloadReconciler struct { SandboxClass string // RuntimeClass required for sandbox enforcement Recorder events.EventRecorder // Optional Kubernetes event recorder Receipts ReceiptsConfig // Decision receipts; zero value disables them + DecisionModel DecisionModelConfig // Decision model; zero value scores nothing } type quotaChecker interface { @@ -615,8 +616,10 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques // the strictest outcome, so no layer or claim can loosen another. layers := r.evaluateLayers(ctx, &workload, decisionInput, opaResult) result := decision.Decide(layers) + // The model runs last and can only tighten: escalate-only. + result, modelBlock := r.applyDecisionModel(ctx, &workload, actionName, decisionInput, result) // Write-ahead: the receipt is stored before the action can run. - result, recorded := r.recordDecision(ctx, &workload, actionName, decisionInput, layers, result, opaPolicyMode) + result, recorded := r.recordDecision(ctx, &workload, actionName, decisionInput, layers, result, opaPolicyMode, modelBlock) log.Info("decision", "outcome", result.Outcome, "layer", result.Layer, "destination", decisionInput.Observed.Destination, "dataClasses", decisionInput.Observed.DataClasses, "reasons", result.Reasons) // Step 5: Handle action execution or approval pending @@ -662,13 +665,17 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques workload.Status.ProposedActions = prunedProposed switch { case result.Outcome == decision.RequireApproval: - // A pack approval finding never auto-allows, in any mode. + // A pack or model approval finding never auto-allows, in any mode. + reason := "PolicyPackApproval" + if result.Layer == decision.LayerModel { + reason = "ModelEscalation" + } workload.Status.Phase = "PendingApproval" workload.Status.Conditions = upsertCondition(workload.Status.Conditions, metav1.Condition{ Type: "ApprovalRequired", Status: metav1.ConditionTrue, ObservedGeneration: workload.Generation, - Reason: "PolicyPackApproval", + Reason: reason, Message: fmt.Sprintf("action %q needs human approval: %s", action.Name, strings.Join(result.Reasons, "; ")), LastTransitionTime: now, }) diff --git a/internal/controller/agentworkload_controller_model_test.go b/internal/controller/agentworkload_controller_model_test.go new file mode 100644 index 0000000..c82439d --- /dev/null +++ b/internal/controller/agentworkload_controller_model_test.go @@ -0,0 +1,159 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "context" + "errors" + "slices" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +type fakeScorer struct { + risk, threshold int64 + err error + calls int +} + +func (f *fakeScorer) Score(_ context.Context, feat decision.Features) (decision.Score, error) { + f.calls++ + if f.err != nil { + return decision.Score{}, f.err + } + return decision.Score{ + ModelID: "fake", Version: "1", ArtifactSHA256: "sha", FeatureSpec: feat.SpecVersion, FeatureHash: feat.Hash(), + RiskMicro: f.risk, OptionMicro: decision.Options(f.risk), ThresholdMicro: f.threshold, Calibration: "fake", + }, nil +} + +func TestReconcile_DecisionModel(t *testing.T) { + t.Parallel() + i := func(v int64) *int64 { return &v } + tests := []struct { + name string + confidence string + scorer *fakeScorer + mode string + override *int64 + wantPhase string + wantExecute bool + wantCalls int + wantOutcome string + wantLayer string + wantBlock bool + wantError bool + }{ + {name: "no model loaded unchanged", confidence: "0.98", mode: "escalate", wantPhase: "Completed", wantExecute: true, + wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold}, + {name: "mode off never scores", confidence: "0.98", scorer: &fakeScorer{risk: 900000, threshold: 500000}, mode: "off", + wantPhase: "Completed", wantExecute: true, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold}, + {name: "default shadow no effect", confidence: "0.98", scorer: &fakeScorer{risk: 900000, threshold: 500000}, + wantPhase: "Completed", wantExecute: true, wantCalls: 1, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold, wantBlock: true}, + {name: "escalate allow to require approval", confidence: "0.98", scorer: &fakeScorer{risk: 900000, threshold: 500000}, mode: "escalate", + wantPhase: "PendingApproval", wantCalls: 1, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true}, + {name: "escalate low risk keeps allow", confidence: "0.98", scorer: &fakeScorer{risk: 100000, threshold: 500000}, mode: "escalate", + wantPhase: "Completed", wantExecute: true, wantCalls: 1, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold, wantBlock: true}, + {name: "escalate never loosens deny", confidence: "0.82", scorer: &fakeScorer{risk: 0, threshold: 500000}, mode: "escalate", + wantPhase: "PolicyDenied", wantCalls: 1, wantOutcome: receipts.OutcomeDeny, wantLayer: receipts.LayerThreshold, wantBlock: true}, + {name: "escalate scorer error fails safe", confidence: "0.98", scorer: &fakeScorer{err: errors.New("artifact read failed")}, mode: "escalate", + wantPhase: "PendingApproval", wantCalls: 1, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true, wantError: true}, + {name: "shadow scorer error never blocks", confidence: "0.98", scorer: &fakeScorer{err: errors.New("artifact read failed")}, mode: "shadow", + wantPhase: "Completed", wantExecute: true, wantCalls: 1, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold, wantBlock: true, wantError: true}, + {name: "stricter override escalates", confidence: "0.98", scorer: &fakeScorer{risk: 400000, threshold: 500000}, mode: "escalate", override: i(300000), + wantPhase: "PendingApproval", wantCalls: 1, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true}, + {name: "looser override ignored", confidence: "0.98", scorer: &fakeScorer{risk: 600000, threshold: 500000}, mode: "escalate", override: i(900000), + wantPhase: "PendingApproval", wantCalls: 1, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true}, + } + for idx, tc := range tests { + tc, idx := tc, idx + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + scheme := newControllerTestScheme(t) + log := &eventLog{} + server := newLoggingMCPServer(t, mockMCPScenario{confidence: tc.confidence, clusterHealth: 90.0}, log) + objective := "optimize resources" + policy := "strict" + name := "model-" + string(rune('a'+idx)) + workload := &agenticv1alpha1.AgentWorkload{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", UID: types.UID("uid-" + name)}, + Spec: agenticv1alpha1.AgentWorkloadSpec{ + MCPServerEndpoint: &server.URL, + Objective: &objective, + OPAPolicy: &policy, + }, + } + if tc.mode != "" || tc.override != nil { + workload.Spec.DecisionModel = &agenticv1alpha1.DecisionModel{Mode: tc.mode, ThresholdMicro: tc.override} + } + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&agenticv1alpha1.AgentWorkload{}). + WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, workload). + Build() + writer := &fakeReceiptWriter{log: log, ready: true} + reconciler := &AgentWorkloadReconciler{Client: k8sClient, Scheme: scheme, Receipts: ReceiptsConfig{Enabled: true, Writer: writer}} + if tc.scorer != nil { + reconciler.DecisionModel = DecisionModelConfig{Scorer: tc.scorer} + } + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: "default"}}); err != nil { + t.Fatalf("reconcile: %v", err) + } + if got := slices.Contains(log.list(), "execute_action"); got != tc.wantExecute { + t.Fatalf("execute_action = %v, want %v; events %v", got, tc.wantExecute, log.list()) + } + if tc.scorer != nil && tc.scorer.calls != tc.wantCalls { + t.Fatalf("scorer calls = %d, want %d", tc.scorer.calls, tc.wantCalls) + } + if len(writer.records) != 1 { + t.Fatalf("records = %d", len(writer.records)) + } + rec := writer.records[0] + if rec.Outcome != tc.wantOutcome || rec.Layer != tc.wantLayer { + t.Fatalf("record %s/%s, want %s/%s", rec.Outcome, rec.Layer, tc.wantOutcome, tc.wantLayer) + } + if (rec.Model != nil) != tc.wantBlock { + t.Fatalf("model block present = %v, want %v", rec.Model != nil, tc.wantBlock) + } + if rec.Model != nil { + m := rec.Model + if (m.Error != "") != tc.wantError || m.Outcome != tc.wantOutcome || len(m.FeatureHash) != 64 || + !slices.Equal(m.OptionSet, decision.OptionSet) || len(m.OptionMicro) != 2 { + t.Fatalf("model block %+v", m) + } + if tc.override != nil && *tc.override < tc.scorer.threshold && m.ThresholdMicro != *tc.override { + t.Fatalf("threshold %d, want stricter override %d", m.ThresholdMicro, *tc.override) + } + if tc.override != nil && *tc.override > tc.scorer.threshold && m.ThresholdMicro != tc.scorer.threshold { + t.Fatalf("looser override applied: %d", m.ThresholdMicro) + } + } + updated := &agenticv1alpha1.AgentWorkload{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, updated); err != nil { + t.Fatal(err) + } + if updated.Status.Phase != tc.wantPhase { + t.Fatalf("phase = %q, want %q", updated.Status.Phase, tc.wantPhase) + } + if tc.wantLayer == receipts.LayerModel { + c := findCondition(updated.Status.Conditions, "ApprovalRequired") + if c == nil || c.Reason != "ModelEscalation" || updated.Status.PendingApproval == nil { + t.Fatalf("condition %#v pending %v", c, updated.Status.PendingApproval) + } + } + }) + } +} diff --git a/internal/controller/approvals.go b/internal/controller/approvals.go index 50e3de8..94e00c2 100644 --- a/internal/controller/approvals.go +++ b/internal/controller/approvals.go @@ -265,7 +265,7 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age agenticv1alpha1.PhaseRejected, "Rejected", fmt.Sprintf("action %q rejected by %s", ref, d.Approver.Username), now) case blocked: // A human cannot override invariants or pack denies. Record the deny. - r.recordDecision(ctx, wl, tgt.name, in, layers, decision.Decide(layers), mode) + r.recordDecision(ctx, wl, tgt.name, in, layers, decision.Decide(layers), mode, nil) return r.finishApproval(ctx, wl, d.Label, approvalDenied, d.ApproverSHA256(), receiptSeq(humanReceipt), "PolicyDenied", "HumanDecisionBlocked", fmt.Sprintf("human %s of %q blocked: %s", d.Label, ref, strings.Join(decision.Decide(layers).Reasons, "; ")), now) } diff --git a/internal/controller/decision_model.go b/internal/controller/decision_model.go new file mode 100644 index 0000000..071db09 --- /dev/null +++ b/internal/controller/decision_model.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "context" + + logf "sigs.k8s.io/controller-runtime/pkg/log" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + decisioninput "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +// DecisionModelConfig holds the loaded decision model. A nil Scorer means no +// artifact is configured: nothing is scored and decisions are unchanged. +type DecisionModelConfig struct { + Scorer decision.Scorer +} + +// applyDecisionModel scores the action after the non-model decision. Shadow +// records the score only. Escalate may turn allow into require_approval and +// nothing else (decision.ApplyModel). It returns the block for the receipt, +// or nil when the model did not run. +func (r *AgentWorkloadReconciler) applyDecisionModel( + ctx context.Context, + wl *agenticv1alpha1.AgentWorkload, + action string, + in decisioninput.Input, + base decision.Result, +) (decision.Result, *receipts.Model) { + mode := decision.ModelMode(wl.Spec.DecisionModelMode()) + if r.DecisionModel.Scorer == nil || mode == decision.ModelOff { + return base, nil + } + log := logf.FromContext(ctx) + if mode != decision.ModelShadow && mode != decision.ModelEscalate { + log.Info("unknown decision model mode, using shadow", "mode", mode) + mode = decision.ModelShadow + } + esc := "" + if base.Outcome != decision.Allow { + esc = base.Layer + } + f := decision.ExtractFeatures(decision.FeatureInput{Input: in, Action: action, PolicyPacks: wl.Spec.PolicyPacks, EscalatedBy: esc}) + var override *int64 + if wl.Spec.DecisionModel != nil { + override = wl.Spec.DecisionModel.ThresholdMicro + } + s, err := r.DecisionModel.Scorer.Score(ctx, f) + if err != nil { + s = decision.Score{FeatureSpec: f.SpecVersion, FeatureHash: f.Hash()} + } else { + // A looser override is ignored here even if admission let it through. + s.ThresholdMicro = decision.EffectiveThreshold(s.ThresholdMicro, override) + } + final := decision.ApplyModel(base, s, err, mode) + block := receipts.NewModelBlock(mode, s, err, base, final) + if err != nil { + log.Error(err, "decision model scorer failed", "mode", mode, "baseOutcome", base.Outcome, "outcome", final.Outcome) + } else { + log.Info("decision model score", "mode", mode, "model", s.ModelID, "version", s.Version, + "riskMicro", s.RiskMicro, "thresholdMicro", s.ThresholdMicro, "reasonCodes", s.ReasonCodes, + "baseOutcome", base.Outcome, "outcome", final.Outcome) + } + return final, &block +} diff --git a/internal/controller/receipts.go b/internal/controller/receipts.go index 9a924ed..3813546 100644 --- a/internal/controller/receipts.go +++ b/internal/controller/receipts.go @@ -65,8 +65,9 @@ func (r *AgentWorkloadReconciler) recordDecision( layers decision.Layers, result decision.Result, thresholdMode string, + model *receipts.Model, ) (decision.Result, decided) { - rec, err := newRecord(wl, action, in, layers, result, thresholdMode) + rec, err := newRecord(wl, action, in, layers, result, thresholdMode, model) out := decided{record: rec} if !r.Receipts.Enabled { return result, out @@ -90,7 +91,7 @@ func (r *AgentWorkloadReconciler) recordDecision( } } result = decision.Decide(layers) - out.record, _ = newRecord(wl, action, in, layers, result, thresholdMode) + out.record, _ = newRecord(wl, action, in, layers, result, thresholdMode, model) return result, out } @@ -101,6 +102,7 @@ func newRecord( layers decision.Layers, result decision.Result, thresholdMode string, + model *receipts.Model, ) (receipts.DecisionRecord, error) { return receipts.NewDecisionRecord(receipts.Params{ Workload: workloadRef(wl), @@ -110,6 +112,7 @@ func newRecord( Result: result, PolicyPacks: wl.Spec.PolicyPacks, ThresholdMode: thresholdMode, + Model: model, }) } From ae590f24f6d3812ed71630fb3c64325d3033d3b1 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:26:47 +0530 Subject: [PATCH 16/54] feat(agentctl): decision eval against the verified approval dataset with baseline comparison Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- cmd/agentctl/decision.go | 181 ++++++++++++++++++++++++++++++++ cmd/agentctl/decision_test.go | 96 +++++++++++++++++ cmd/agentctl/root.go | 1 + pkg/decision/eval/eval.go | 185 +++++++++++++++++++++++++++++++++ pkg/decision/eval/eval_test.go | 164 +++++++++++++++++++++++++++++ 5 files changed, 627 insertions(+) create mode 100644 cmd/agentctl/decision.go create mode 100644 cmd/agentctl/decision_test.go create mode 100644 pkg/decision/eval/eval.go create mode 100644 pkg/decision/eval/eval_test.go diff --git a/cmd/agentctl/decision.go b/cmd/agentctl/decision.go new file mode 100644 index 0000000..8983ea5 --- /dev/null +++ b/cmd/agentctl/decision.go @@ -0,0 +1,181 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/baseline" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/eval" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/learned" +) + +func newDecisionCommand(opts *cliOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "decision", + Short: "Evaluate the decision model offline", + // Offline only. Do not load a kubeconfig. + PersistentPreRunE: func(*cobra.Command, []string) error { return opts.validateOutput() }, + } + cmd.AddCommand(newDecisionEvalCommand(opts)) + return cmd +} + +func newDecisionEvalCommand(opts *cliOptions) *cobra.Command { + var data, model, trustRoot string + var withBaseline, allowUnverified bool + var threshold int64 + cmd := &cobra.Command{ + Use: "eval", + Short: "Score a model artifact against the approval dataset", + Long: "Verifies the dataset like 'agentctl dataset verify', then reports how many human rejects and edits " + + "the model would have escalated (recall), how many approvals it would have sent to review, precision, " + + "a calibration table, and ECE. --dataset is an export dir or a bare approvals.jsonl. Unverified data " + + "is refused unless --allow-unverified.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + var override *int64 + if cmd.Flags().Changed("threshold-micro") { + if threshold < 0 || threshold > decision.MicroScale { + return fmt.Errorf("--threshold-micro must be 0 to %d", decision.MicroScale) + } + override = &threshold + } + exs, verified, err := loadEvalDataset(data, trustRoot) + if err != nil { + return err + } + if !verified && !allowUnverified { + return errors.New("dataset is not verified against signed receipts; rerun with --allow-unverified to evaluate anyway") + } + if len(exs) == 0 { + return errors.New("dataset has no examples") + } + m, err := learned.Load(model) + if err != nil { + return err + } + var rep eval.Report + rep.Counts(exs, verified) + if rep.Model, err = eval.Evaluate(cmd.Context(), exs, m, override); err != nil { + return err + } + if withBaseline { + b, err := eval.Evaluate(cmd.Context(), exs, baseline.Scorer{}, nil) + if err != nil { + return err + } + rep.Baseline = &b + } + if opts.Output == "json" { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(rep) + } + printEvalReport(cmd.OutOrStdout(), rep) + return nil + }, + } + cmd.Flags().StringVar(&data, "dataset", "", "export dir (receipts plus approvals.jsonl) or an approvals.jsonl file") + cmd.Flags().StringVar(&model, "model", "", "model artifact JSON") + cmd.Flags().BoolVar(&withBaseline, "baseline", false, "also score the hand-weighted baseline and compare") + cmd.Flags().Int64Var(&threshold, "threshold-micro", 0, "escalation threshold in micro-units, overrides the artifact") + cmd.Flags().BoolVar(&allowUnverified, "allow-unverified", false, "evaluate data that does not verify against receipts, such as synthetic rows") + cmd.Flags().StringVar(&trustRoot, "trust-root", "", "trust file pinned out of band (receiptspec trust file format)") + for _, f := range []string{"dataset", "model"} { + _ = cmd.MarkFlagRequired(f) + } + return cmd +} + +// loadEvalDataset reads examples. Only an export dir whose receipts and +// examples all verify counts as verified. +func loadEvalDataset(path, trustRoot string) ([]dataset.Example, bool, error) { + st, err := os.Stat(path) + if err != nil { + return nil, false, err + } + if !st.IsDir() { + data, err := os.ReadFile(path) + if err != nil { + return nil, false, err + } + exs, err := eval.ParseJSONL(data) + return exs, false, err + } + e, approvals, err := dataset.ReadDir(path) + if err != nil { + data, rerr := os.ReadFile(filepath.Join(path, dataset.ApprovalsFile)) + if rerr != nil { + return nil, false, err + } + exs, perr := eval.ParseJSONL(data) + return exs, false, perr + } + var trusted []receiptspec.TrustedKey + if trustRoot != "" { + data, err := os.ReadFile(trustRoot) + if err != nil { + return nil, false, err + } + if trusted, err = receiptspec.LoadTrustedKeys(data); err != nil { + return nil, false, err + } + } + rep, verr := dataset.Verify(e, approvals, trusted) + exs, err := eval.ParseJSONL([]byte(approvals)) + if err != nil { + return nil, false, err + } + return exs, verr == nil && rep.OK(), nil +} + +func micro(v int64) string { + return fmt.Sprintf("%d.%06d", v/decision.MicroScale, v%decision.MicroScale) +} + +func printEvalReport(w io.Writer, r eval.Report) { + if len(r.Warnings) > 0 { + _, _ = fmt.Fprintln(w, "WARNING: these numbers are not evidence of production quality.") + for _, s := range r.Warnings { + _, _ = fmt.Fprintln(w, "WARNING: "+s) + } + _, _ = fmt.Fprintln(w) + } + _, _ = fmt.Fprintf(w, "examples %d (human %d, synthetic %d, verified %v)\n\n", r.Examples, r.Human, r.Synthetic, r.Verified) + results := []eval.Result{r.Model} + if r.Baseline != nil { + results = append(results, *r.Baseline) + } + for _, res := range results { + _, _ = fmt.Fprintf(w, "%s %s threshold %s\n", res.ModelID, res.Version, micro(res.ThresholdMicro)) + _, _ = fmt.Fprintf(w, " humans rejected or edited %d, model escalated %d (recall %s)\n", res.HumanRejected, res.Caught, micro(res.RecallMicro)) + _, _ = fmt.Fprintf(w, " humans approved %d, model escalated %d (extra review %s)\n", res.HumanApproved, res.ExtraReview, micro(res.ExtraReviewMicro)) + _, _ = fmt.Fprintf(w, " precision %s, ECE %s\n", micro(res.PrecisionMicro), micro(res.ECEMicro)) + _, _ = fmt.Fprintln(w, " bin count expected observed") + for _, b := range res.Bins { + _, _ = fmt.Fprintf(w, " %s-%s %6d %s %s\n", micro(b.LowerMicro)[:4], micro(b.UpperMicro)[:4], b.Count, micro(b.MeanPredictedMicro), micro(b.ObservedMicro)) + } + _, _ = fmt.Fprintln(w) + } + if r.Baseline != nil { + m, b := r.Model, *r.Baseline + _, _ = fmt.Fprintf(w, "model vs baseline: recall %s vs %s, extra review %s vs %s, precision %s vs %s, ECE %s vs %s\n", + micro(m.RecallMicro), micro(b.RecallMicro), micro(m.ExtraReviewMicro), micro(b.ExtraReviewMicro), + micro(m.PrecisionMicro), micro(b.PrecisionMicro), micro(m.ECEMicro), micro(b.ECEMicro)) + } +} diff --git a/cmd/agentctl/decision_test.go b/cmd/agentctl/decision_test.go new file mode 100644 index 0000000..52ae4b2 --- /dev/null +++ b/cmd/agentctl/decision_test.go @@ -0,0 +1,96 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/eval" +) + +var ( + shippedData = filepath.Join("..", "..", "tools", "decision-train", "out", "approvals.jsonl") + shippedModel = filepath.Join("..", "..", "tools", "decision-train", "out", "model.json") +) + +func TestDecisionEval(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + wantOut []string + }{ + {name: "refuses unverified data", args: []string{"--dataset", shippedData, "--model", shippedModel}, wantErr: "--allow-unverified"}, + {name: "synthetic report warns", args: []string{"--dataset", shippedData, "--model", shippedModel, "--allow-unverified", "--baseline"}, + wantOut: []string{"WARNING: these numbers are not evidence", "360 of 360 examples are synthetic", "only 0 real human labels", "model vs baseline", "clawdlinux-baseline-additive", "recall"}}, + {name: "threshold out of range", args: []string{"--dataset", shippedData, "--model", shippedModel, "--allow-unverified", "--threshold-micro", "1000001"}, wantErr: "--threshold-micro"}, + {name: "missing dataset", args: []string{"--dataset", "/nonexistent", "--model", shippedModel}, wantErr: "no such file"}, + {name: "bad model", args: []string{"--dataset", shippedData, "--model", shippedData, "--allow-unverified"}, wantErr: "invalid artifact"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out, _, err := runAgentctl(t, append([]string{"decision", "eval"}, tc.args...)...) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + for _, w := range tc.wantOut { + if !strings.Contains(out, w) { + t.Fatalf("output missing %q:\n%s", w, out) + } + } + }) + } +} + +func TestDecisionEvalJSONAndThreshold(t *testing.T) { + out, _, err := runAgentctl(t, "decision", "eval", "-o", "json", "--dataset", shippedData, "--model", shippedModel, "--allow-unverified", "--threshold-micro", "0") + if err != nil { + t.Fatal(err) + } + var rep eval.Report + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatal(err) + } + if rep.Synthetic != 360 || rep.Model.ThresholdMicro != 0 || rep.Model.RecallMicro != 1_000_000 || rep.Baseline != nil { + t.Fatalf("report %+v", rep) + } +} + +// TestDecisionEvalVerifiedExport checks a verified export runs without the +// unverified flag and without the unverified warning. +func TestDecisionEvalVerifiedExport(t *testing.T) { + e, approvals := datasetExport(t) + dir := filepath.Join(t.TempDir(), "export") + if err := e.WriteDir(dir); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, dataset.ApprovalsFile), []byte(approvals), 0o600); err != nil { + t.Fatal(err) + } + out, _, err := runAgentctl(t, "decision", "eval", "--dataset", dir, "--model", shippedModel) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "verified true") || strings.Contains(out, "not verified") || !strings.Contains(out, "only 1 real human") { + t.Fatalf("output:\n%s", out) + } + // A tampered label no longer verifies and is refused. + _ = os.WriteFile(filepath.Join(dir, dataset.ApprovalsFile), []byte(strings.Replace(approvals, `"label":"approve"`, `"label":"reject"`, 1)), 0o600) + if _, _, err := runAgentctl(t, "decision", "eval", "--dataset", dir, "--model", shippedModel); err == nil || !strings.Contains(err.Error(), "not verified") { + t.Fatalf("tampered export err = %v", err) + } +} diff --git a/cmd/agentctl/root.go b/cmd/agentctl/root.go index d99c4b6..d5f2676 100644 --- a/cmd/agentctl/root.go +++ b/cmd/agentctl/root.go @@ -115,6 +115,7 @@ func newRootCommand() *cobra.Command { cmd.AddCommand(newMCPCommand(opts)) cmd.AddCommand(newReceiptsCommand(opts)) cmd.AddCommand(newDatasetCommand(opts)) + cmd.AddCommand(newDecisionCommand(opts)) return cmd } diff --git a/pkg/decision/eval/eval.go b/pkg/decision/eval/eval.go new file mode 100644 index 0000000..9f50293 --- /dev/null +++ b/pkg/decision/eval/eval.go @@ -0,0 +1,185 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +// Package eval scores a decision model offline against the approval dataset. +// A positive is a human reject or edit. The model "escalates" an example when +// its risk reaches the threshold. All rates are integer micro-units. +package eval + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" +) + +// MinHumanLabels is the count of real human labels below which a report +// carries a warning. +const MinHumanLabels = 200 + +// Bins is the calibration bin count. +const Bins = 10 + +// Bin is one calibration bin. Rates are micro-units. +type Bin struct { + LowerMicro int64 `json:"lower_micro"` + UpperMicro int64 `json:"upper_micro"` + Count int64 `json:"count"` + MeanPredictedMicro int64 `json:"mean_predicted_micro"` + ObservedMicro int64 `json:"observed_micro"` +} + +// Result is one scorer on one dataset. +type Result struct { + ModelID string `json:"model_id"` + Version string `json:"version"` + ArtifactSHA256 string `json:"artifact_sha256"` + ThresholdMicro int64 `json:"threshold_micro"` + // HumanRejected counts reject plus edit labels. + HumanRejected int64 `json:"human_rejected_or_edited"` + HumanApproved int64 `json:"human_approved"` + // Caught are rejected or edited examples the model escalates. + Caught int64 `json:"caught"` + // ExtraReview are approved examples the model escalates. + ExtraReview int64 `json:"extra_review"` + RecallMicro int64 `json:"recall_micro"` + PrecisionMicro int64 `json:"precision_micro"` + ExtraReviewMicro int64 `json:"extra_review_rate_micro"` + ECEMicro int64 `json:"ece_micro"` + Bins []Bin `json:"bins"` +} + +// Report is the full evaluation. +type Report struct { + Examples int64 `json:"examples"` + Synthetic int64 `json:"synthetic"` + Human int64 `json:"human"` + Verified bool `json:"verified"` + Warnings []string `json:"warnings"` + Model Result `json:"model"` + Baseline *Result `json:"baseline,omitempty"` +} + +// ParseJSONL reads approvals.jsonl examples. Unknown fields are an error. +func ParseJSONL(data []byte) ([]dataset.Example, error) { + var out []dataset.Example + sc := bufio.NewScanner(bytes.NewReader(data)) + sc.Buffer(make([]byte, 1<<20), 1<<24) + for line := 1; sc.Scan(); line++ { + raw := bytes.TrimSpace(sc.Bytes()) + if len(raw) == 0 { + continue + } + var ex dataset.Example + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&ex); err != nil { + return nil, fmt.Errorf("eval: line %d: %w", line, err) + } + switch ex.Label { + case "approve", "reject", "edit": + default: + return nil, fmt.Errorf("eval: line %d: unknown label %q", line, ex.Label) + } + out = append(out, ex) + } + return out, sc.Err() +} + +// Evaluate scores every example. threshold overrides the scorer threshold +// when not nil. +func Evaluate(ctx context.Context, exs []dataset.Example, s decision.Scorer, threshold *int64) (Result, error) { + var res Result + risks := make([]int64, len(exs)) + for i, ex := range exs { + sc, err := s.Score(ctx, decision.ExtractFeatures(ex.FeatureInput())) + if err != nil { + return Result{}, fmt.Errorf("eval: example %s: %w", ex.ExampleID, err) + } + if i == 0 { + res.ModelID, res.Version, res.ArtifactSHA256, res.ThresholdMicro = sc.ModelID, sc.Version, sc.ArtifactSHA256, sc.ThresholdMicro + if threshold != nil { + res.ThresholdMicro = *threshold + } + } + risks[i] = decision.ClampMicro(sc.RiskMicro) + } + var sums, pos, counts [Bins]int64 + for i, ex := range exs { + escalate := risks[i] >= res.ThresholdMicro + if ex.Rejected() { + res.HumanRejected++ + if escalate { + res.Caught++ + } + } else { + res.HumanApproved++ + if escalate { + res.ExtraReview++ + } + } + b := min(risks[i]/(decision.MicroScale/Bins), Bins-1) + counts[b]++ + sums[b] += risks[i] + if ex.Rejected() { + pos[b]++ + } + } + res.RecallMicro = ratio(res.Caught, res.HumanRejected) + res.PrecisionMicro = ratio(res.Caught, res.Caught+res.ExtraReview) + res.ExtraReviewMicro = ratio(res.ExtraReview, res.HumanApproved) + var eceNum int64 + for b := int64(0); b < Bins; b++ { + bin := Bin{LowerMicro: b * decision.MicroScale / Bins, UpperMicro: (b + 1) * decision.MicroScale / Bins, Count: counts[b]} + if counts[b] > 0 { + bin.MeanPredictedMicro = sums[b] / counts[b] + bin.ObservedMicro = pos[b] * decision.MicroScale / counts[b] + d := bin.MeanPredictedMicro - bin.ObservedMicro + if d < 0 { + d = -d + } + eceNum += counts[b] * d + } + res.Bins = append(res.Bins, bin) + } + if n := int64(len(exs)); n > 0 { + res.ECEMicro = eceNum / n + } + return res, nil +} + +// Counts fills the source counts and warnings of a report. +func (r *Report) Counts(exs []dataset.Example, verified bool) { + r.Examples = int64(len(exs)) + r.Verified = verified + for _, ex := range exs { + if ex.Source == "" { + r.Human++ + } else { + r.Synthetic++ + } + } + r.Warnings = []string{} + if r.Synthetic > 0 { + r.Warnings = append(r.Warnings, fmt.Sprintf("%d of %d examples are synthetic. They are generated from DRAFT scenarios, not human decisions. Numbers below measure fit to our own rules.", r.Synthetic, r.Examples)) + } + if r.Human < MinHumanLabels { + r.Warnings = append(r.Warnings, fmt.Sprintf("only %d real human labels; at least %d are needed before these numbers mean anything.", r.Human, MinHumanLabels)) + } + if !verified { + r.Warnings = append(r.Warnings, "dataset was not verified against signed receipts.") + } +} + +func ratio(a, b int64) int64 { + if b == 0 { + return 0 + } + return a * decision.MicroScale / b +} diff --git a/pkg/decision/eval/eval_test.go b/pkg/decision/eval/eval_test.go new file mode 100644 index 0000000..ca31cbc --- /dev/null +++ b/pkg/decision/eval/eval_test.go @@ -0,0 +1,164 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package eval + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/learned" +) + +// riskByAction maps prior_denied to a risk, for hand-checked numbers. +type riskByAction map[int64]int64 + +func (r riskByAction) Score(_ context.Context, f decision.Features) (decision.Score, error) { + risk := r[f.Value("prior_denied")] + return decision.Score{ModelID: "fake", RiskMicro: risk, ThresholdMicro: 500000}, nil +} + +func ex(label string, denied int, source string) dataset.Example { + return dataset.Example{Label: label, Source: source, Features: dataset.Features{PriorActionCount: 10, PriorDeniedCount: denied}} +} + +func TestEvaluate(t *testing.T) { + // prior_denied picks the risk: 0 -> 0.05, 1 -> 0.55, 2 -> 0.95. + s := riskByAction{0: 50000, 1: 550000, 2: 950000} + exs := []dataset.Example{ + ex("reject", 2, ""), ex("edit", 1, ""), ex("reject", 0, ""), // 2 of 3 caught + ex("approve", 0, ""), ex("approve", 0, ""), ex("approve", 1, ""), // 1 of 3 extra review + } + r, err := Evaluate(context.Background(), exs, s, nil) + if err != nil { + t.Fatal(err) + } + if r.HumanRejected != 3 || r.HumanApproved != 3 || r.Caught != 2 || r.ExtraReview != 1 { + t.Fatalf("counts %+v", r) + } + if r.RecallMicro != 666666 || r.PrecisionMicro != 666666 || r.ExtraReviewMicro != 333333 { + t.Fatalf("rates %d %d %d", r.RecallMicro, r.PrecisionMicro, r.ExtraReviewMicro) + } + // bin 0: 3 at 0.05, 1 positive -> |50000-333333|; bin 5: 2 at 0.55, 1 positive -> |550000-500000|; bin 9: 1 at 0.95, positive -> 50000. + want := (3*283333 + 2*50000 + 1*50000) / 6 + if r.ECEMicro != int64(want) || len(r.Bins) != Bins || r.Bins[5].Count != 2 { + t.Fatalf("ece %d want %d bins %+v", r.ECEMicro, want, r.Bins) + } + th := int64(0) + r0, _ := Evaluate(context.Background(), exs, s, &th) + if r0.RecallMicro != decision.MicroScale || r0.ExtraReviewMicro != decision.MicroScale || r0.ThresholdMicro != 0 { + t.Fatalf("threshold override ignored %+v", r0) + } +} + +func TestReportWarnings(t *testing.T) { + tests := []struct { + name string + exs []dataset.Example + verified bool + want []string + }{ + {"synthetic unverified", []dataset.Example{ex("approve", 0, "synthetic")}, false, []string{"synthetic", "only 0 real human", "not verified"}}, + {"few humans verified", []dataset.Example{ex("approve", 0, "")}, true, []string{"only 1 real human"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var r Report + r.Counts(tc.exs, tc.verified) + if len(r.Warnings) != len(tc.want) { + t.Fatalf("warnings %v", r.Warnings) + } + for i, w := range tc.want { + if !strings.Contains(r.Warnings[i], w) { + t.Fatalf("warning %q missing %q", r.Warnings[i], w) + } + } + }) + } + many := make([]dataset.Example, MinHumanLabels) + var r Report + r.Counts(many, true) + if len(r.Warnings) != 0 || r.Human != MinHumanLabels { + t.Fatalf("unexpected warnings %v", r.Warnings) + } +} + +func TestParseJSONL(t *testing.T) { + if _, err := ParseJSONL([]byte(`{"label":"maybe"}`)); err == nil { + t.Fatal("bad label accepted") + } + if _, err := ParseJSONL([]byte(`{"label":"approve","bogus":1}`)); err == nil { + t.Fatal("unknown field accepted") + } + exs, err := ParseJSONL([]byte("\n{\"label\":\"edit\"}\n\n")) + if err != nil || len(exs) != 1 { + t.Fatalf("%v %v", exs, err) + } +} + +// TestHoldoutMatchesTrainer checks the Go evaluator against the metrics the +// Python trainer wrote for the same holdout rows. +func TestHoldoutMatchesTrainer(t *testing.T) { + out := filepath.Join("..", "..", "..", "tools", "decision-train", "out") + data, err := os.ReadFile(filepath.Join(out, "approvals.jsonl")) + if err != nil { + t.Fatal(err) + } + all, err := ParseJSONL(data) + if err != nil { + t.Fatal(err) + } + var hold []dataset.Example + for _, e := range all { + sum := sha256.Sum256([]byte(e.ExampleID)) + v, _ := strconv.ParseUint(hex.EncodeToString(sum[:4]), 16, 64) + if v%5 == 0 { + hold = append(hold, e) + } + } + m, err := learned.Load(filepath.Join(out, "model.json")) + if err != nil { + t.Fatal(err) + } + r, err := Evaluate(context.Background(), hold, m, nil) + if err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(out, "metrics.json")) + var metrics struct { + Holdout struct { + Examples int64 `json:"examples"` + Recall int64 `json:"recall_micro"` + Precision int64 `json:"precision_micro"` + ExtraReview int64 `json:"extra_review_rate_micro"` + ECE int64 `json:"ece_micro"` + TruePositive int64 `json:"true_positive"` + FalsePositive int64 `json:"false_positive"` + Bins []Bin `json:"bins"` + } `json:"holdout"` + } + if err := json.Unmarshal(raw, &metrics); err != nil { + t.Fatal(err) + } + h := metrics.Holdout + if int64(len(hold)) != h.Examples || r.RecallMicro != h.Recall || r.PrecisionMicro != h.Precision || + r.ExtraReviewMicro != h.ExtraReview || r.ECEMicro != h.ECE || r.Caught != h.TruePositive || r.ExtraReview != h.FalsePositive { + t.Fatalf("go %+v\npython %+v", r, h) + } + for i := range h.Bins { + if h.Bins[i] != r.Bins[i] { + t.Fatalf("bin %d go %+v python %+v", i, r.Bins[i], h.Bins[i]) + } + } +} From 4f9c609fdf5cd499f2453f7ad7ad233734e886f1 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:27:45 +0530 Subject: [PATCH 17/54] feat(chart): optional decision model config map mount and DECISION_MODEL_PATH Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- .../templates/deployment.yaml | 21 +++++++ charts/tests/decision_model_test.yaml | 62 +++++++++++++++++++ charts/values.schema.json | 11 +++- charts/values.yaml | 10 +++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 charts/tests/decision_model_test.yaml diff --git a/charts/charts/agentic-operator/templates/deployment.yaml b/charts/charts/agentic-operator/templates/deployment.yaml index 636b5e8..5e5a516 100644 --- a/charts/charts/agentic-operator/templates/deployment.yaml +++ b/charts/charts/agentic-operator/templates/deployment.yaml @@ -77,6 +77,12 @@ spec: value: {{ .timeout | default "5s" | quote }} {{- end }} {{- end }} + {{- with (.Values.global).decisionModel }} + {{- if .existingConfigMap }} + - name: DECISION_MODEL_PATH + value: /etc/decision-model/{{ .key | default "model.json" }} + {{- end }} + {{- end }} {{- range $key, $val := .Values.env }} - name: {{ $key }} value: {{ $val | quote }} @@ -127,6 +133,11 @@ spec: mountPath: /etc/receipts/token readOnly: true {{- end }} + {{- if ((.Values.global).decisionModel).existingConfigMap }} + - name: decision-model + mountPath: /etc/decision-model + readOnly: true + {{- end }} volumes: - name: webhook-certs secret: @@ -140,6 +151,16 @@ spec: defaultMode: 0444 {{- end }} {{- end }} + {{- with (.Values.global).decisionModel }} + {{- if .existingConfigMap }} + - name: decision-model + configMap: + name: {{ .existingConfigMap }} + items: + - key: {{ .key | default "model.json" }} + path: {{ .key | default "model.json" }} + {{- end }} + {{- end }} securityContext: runAsNonRoot: true runAsUser: 65532 diff --git a/charts/tests/decision_model_test.yaml b/charts/tests/decision_model_test.yaml new file mode 100644 index 0000000..b523897 --- /dev/null +++ b/charts/tests/decision_model_test.yaml @@ -0,0 +1,62 @@ +# helm-unittest spec for the decision model artifact mount +# (docs/architecture/decision-model.md). Run with: helm unittest charts +suite: decision model +templates: + - charts/agentic-operator/templates/deployment.yaml +tests: + - it: loads no model by default + set: + license: + key: "test" + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: DECISION_MODEL_PATH + value: /etc/decision-model/model.json + - notContains: + path: spec.template.spec.volumes + content: + name: decision-model + any: true + + - it: mounts the artifact config map and sets DECISION_MODEL_PATH + set: + license: + key: "test" + global: + decisionModel: + existingConfigMap: decision-model + key: model-v2.json + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DECISION_MODEL_PATH + value: /etc/decision-model/model-v2.json + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: decision-model + mountPath: /etc/decision-model + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: decision-model + configMap: + name: decision-model + items: + - key: model-v2.json + path: model-v2.json + + - it: rejects an unsafe key + set: + license: + key: "test" + global: + decisionModel: + existingConfigMap: decision-model + key: "../etc/passwd" + asserts: + - failedTemplate: {} diff --git a/charts/values.schema.json b/charts/values.schema.json index 2df0f4a..50e9ee4 100644 --- a/charts/values.schema.json +++ b/charts/values.schema.json @@ -28,7 +28,16 @@ "storageClass": { "type": "string" }, "namespace": { "type": "string" }, "runtimeSandbox": { "$ref": "#/$defs/runtimeSandbox" }, - "receipts": { "$ref": "#/$defs/receipts" } + "receipts": { "$ref": "#/$defs/receipts" }, + "decisionModel": { + "type": "object", + "description": "Escalate-only decision model artifact. See docs/architecture/decision-model.md.", + "properties": { + "existingConfigMap": { "type": "string" }, + "key": { "type": "string", "pattern": "^[-._a-zA-Z0-9]+$" } + }, + "additionalProperties": false + } } }, "license": { diff --git a/charts/values.yaml b/charts/values.yaml index c6909ea..daa218a 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -87,6 +87,16 @@ global: cpu: "500m" memory: "256Mi" + # Escalate-only decision model. See docs/architecture/decision-model.md. + # Empty existingConfigMap: no model is loaded and decisions are unchanged. + # The shipped artifact is trained on synthetic DRAFT data: + # kubectl create configmap decision-model \ + # --from-file=model.json=tools/decision-train/out/model.json + # Workloads default to spec.decisionModel.mode: shadow (score, no effect). + decisionModel: + existingConfigMap: "" + key: model.json + # --------------------------------------------------------------------------- # License (REQUIRED) # --------------------------------------------------------------------------- From 23273237c54b6fdaffa034578e69103838786eac Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:29:59 +0530 Subject: [PATCH 18/54] docs(decision): decision model, receipt model block, escalate gate, honest status Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- CHANGELOG.md | 1 + README.md | 9 +- ROADMAP.md | 3 +- docs/approvals.md | 1 + docs/architecture/decision-architecture.md | 21 ++- docs/architecture/decision-model.md | 198 +++++++++++++++++++++ docs/policy-input.md | 8 + docs/receipts.md | 34 +++- 8 files changed, 264 insertions(+), 11 deletions(-) create mode 100644 docs/architecture/decision-model.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 715d1cd..5245b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added invariants (`pkg/invariants`, INV-01 to INV-05), an embedded OPA rule engine (`pkg/rules/engine`, OPA v1.19.1), and opt-in policy packs `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` via `spec.policyPacks`. Packs only add deny or require approval. Packs are engineering controls, not legal advice. The `credential` data class is now detected and always denied (INV-01), even without `declaredIntent`. Direct action path only. See `docs/policy-packs.md`. - Added opt-in signed decision receipts (`pkg/receipts`, `cmd/receipt-writer`). Each direct-path decision gets an AgentGate-format Ed25519 receipt before the action runs. `RECEIPTS_REQUIRED=true` makes INV-05 deny when no receipt can be written. Added `agentctl receipts export` and `agentctl receipts verify`, and a `global.receipts` chart block. Records hold hashes and class names, not raw params or matched data. Execution outcome and the runtime-adapter path are not receipted yet. Uses a local `replace` for `github.com/Clawdlinux/agentgate` until v0.1.4 is tagged (see `BLOCKERS.md`). See `docs/receipts.md`. - Added human approvals on the direct action path (`pkg/approval`, `pkg/dataset`). A held action is stored in `status.pendingApproval`. Humans decide with `clawdlinux.org/approval-decision` (approve, reject, edit). The webhook stamps the approver; the controller refuses unstamped decisions. Each decision gets a layer `human` receipt and an example in `approvals.jsonl` on the receipt-writer (`POST|GET /v1/approvals`). Edited and approved actions re-run invariants and packs first. Added `spec.approvalCapture.content` (none, redacted, full; default redacted), phase `Rejected`, `agentctl edit-approve`, `agentctl approve --reason`, and `agentctl dataset export|verify`. See `docs/approvals.md`. +- Added the decision model, phase A (`pkg/decision` features and `ApplyModel`, `pkg/decision/learned`, `pkg/decision/baseline`, `pkg/decision/eval`). Escalate-only: it can move allow to require_approval and never loosens. `spec.decisionModel.mode` (off, shadow, escalate; default shadow) and a stricter-only `thresholdMicro`. The operator loads an artifact from `DECISION_MODEL_PATH`; without one nothing is scored. Scored decisions carry a receipt `model` block with integer micro-unit probabilities. Added `agentctl decision eval`, `make decision-train` (stdlib Python, byte-identical reruns), and `global.decisionModel` in the chart. The shipped artifact is trained on 30 DRAFT synthetic scenarios and is not production-validated. Approval examples gain an optional `source` field; `synthetic` rows never verify. See `docs/architecture/decision-model.md`. ### Changed - Moved the threshold evaluator from `pkg/opa` to `pkg/rules/threshold`. `pkg/opa` remains as a deprecated alias package. diff --git a/README.md b/README.md index 530f2da..92b41d7 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,14 @@ The target contract connects caller identity, declared access, action policy, ap How an action is decided, as a target design (see [decision architecture](docs/architecture/decision-architecture.md)): 1. Invariants in code. Credentials never reach the agent. Egress only to declared destinations. -2. A decision model scores each action. It can only escalate to a human at first. +2. A decision model scores each action. It can only escalate to a human. 3. Humans approve or reject. Every decision is a signed, labelled example. -Today the repo ships a threshold evaluator, not this model. The ADR lists what is built and what is not. +Today the repo ships invariants, policy packs, a threshold evaluator, and +human approvals. The decision model exists but is escalate-only, runs in +shadow mode by default, and only once an artifact is mounted. The shipped +artifact is trained on synthetic DRAFT scenarios and is not validated on real +decisions. The ADR lists what is built and what is not. | Capability | Current repository state | |---|---| @@ -296,6 +300,7 @@ assets/ Branding assets (logo, etc.) | [Policy packs](docs/policy-packs.md) | Invariants and opt-in Rego packs (`dpdp-in`, `gdpr-eu`) | | [Receipts](docs/receipts.md) | Opt-in signed decision receipts, receipt-writer, offline verify | | [Approvals](docs/approvals.md) | Human approve, reject, edit protocol and the signed approval dataset | +| [Decision model](docs/architecture/decision-model.md) | Escalate-only scoring, shadow by default, offline eval, not production-validated | | [Troubleshooting](docs/10-troubleshooting.md) | Common issues and fixes | --- diff --git a/ROADMAP.md b/ROADMAP.md index 8ed5ecc..d62b748 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,6 +55,7 @@ Strong CNCF base runtimes for agents on Kubernetes exist and are improving. Claw - [x] Rule engine (OPA Go library) with invariants and policy packs. 5 invariants, `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` packs. Direct action path only; orchestrated runtimes reject packs. See [policy packs](docs/policy-packs.md) - [x] Signed decision receipts, opt-in. AgentGate receipt format, `receipt-writer` service, write-ahead, fail closed with `RECEIPTS_REQUIRED`, `agentctl receipts export|verify`. Direct action path only. See [receipts](docs/receipts.md) - [x] Human approval protocol and signed approval dataset. Webhook-stamped approver, append-once decisions, edit re-validated against invariants, `spec.approvalCapture`, `approvals.jsonl` on the receipt-writer, `agentctl dataset export|verify`. Direct action path only; Argo approval gates not covered. See [approvals](docs/approvals.md) +- [x] Decision model phase A: logistic regression with integer-only inference, escalate-only, `spec.decisionModel.mode` shadow by default, `DECISION_MODEL_PATH`, receipt `model` block, `agentctl decision eval` with a hand-weighted baseline, stdlib trainer with byte-identical reruns. Shipped artifact trained on synthetic DRAFT scenarios, not production-validated. Phase B not built. See [decision model](docs/architecture/decision-model.md) ## Now @@ -72,7 +73,7 @@ Priority order. Validation before features. - [x] Observed decision inputs on the direct action path: destination host, deterministic data-class detection, action history. Caller identity is not observed yet. Agent self-report only tightens. See [policy input](docs/policy-input.md) - [ ] Invariants and policy packs on orchestrated runtimes (`spec.orchestration`) - [ ] Receipt gaps: runtime-adapter path, execution outcome, approval receipts, key rotation, retention, writer TLS, published writer image -- [ ] Decision model: escalate-only scoring, shadow mode first. See [decision architecture](docs/architecture/decision-architecture.md) +- [ ] Decision model on real labels: founder-written scenarios, retrain on human rows, pass the escalate gate. Runtime-adapter path not scored. LLM judge not started. See [decision model](docs/architecture/decision-model.md) ## Later (all validation-gated) diff --git a/docs/approvals.md b/docs/approvals.md index 20fbd02..f320450 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -125,6 +125,7 @@ One example per line: | `timestamp` | RFC 3339 UTC | | `capture_mode`, `content` | Per the capture mode | | `edit_diff` | For edit: fields changed (`name`, `description`, `params`) and the edited content per mode | +| `source` | Absent for a human decision. `synthetic` marks generated training rows from `tools/decision-train`. Synthetic rows never bind to a receipt, so verify rejects them | A failed dataset write is logged and shown in condition `ApprovalDataset`. It does not block the decision. diff --git a/docs/architecture/decision-architecture.md b/docs/architecture/decision-architecture.md index 7b1b08e..9f4cf2a 100644 --- a/docs/architecture/decision-architecture.md +++ b/docs/architecture/decision-architecture.md @@ -34,8 +34,8 @@ model id and version, input hash, option set and order, per-option probabilities `RECEIPTS_REQUIRED=true` and no receipt can be written. - Receipts (pkg/receipts, cmd/receipt-writer) are opt-in. With receipts on, every direct-path decision gets a signed AgentGate-format receipt before the - action runs. See [receipts](../receipts.md). No model block yet, because no - model decides. Execution outcome and the runtime-adapter path are not + action runs. See [receipts](../receipts.md). A scored action carries a + `model` block. Execution outcome and the runtime-adapter path are not receipted. - Layers combine in pkg/decision.Decide. Strictest wins. - Human approval (layer 3) works on the direct path. Decisions are @@ -49,7 +49,13 @@ model id and version, input hash, option set and order, per-option probabilities - "Destructive" = string match on action name. - pkg/evaluation/scorer.go: keyword heuristic, post hoc, blocks nothing. - AgentGate: deterministic, fine as invariant layer example. -- No learned gate exists. Layer 2 is new work. +- Layer 2, phase A exists: a logistic regression decision model + (pkg/decision/learned) with integer-only inference, escalate-only via + pkg/decision.ApplyModel, shadow by default, scored after Decide on the + direct path. Nothing runs until DECISION_MODEL_PATH points at an artifact. + The shipped artifact is trained on 30 DRAFT synthetic scenarios and is not + production-validated. Offline evaluation: `agentctl decision eval`. See + [decision model](decision-model.md). Phase B (bounded autonomy) is not built. ## Build order 1. Measure, stop trusting agent self-report (observed inputs). See @@ -60,12 +66,17 @@ model id and version, input hash, option set and order, per-option probabilities 4. Approval dataset (already in goal prompt phase 4). Shipped on the direct path: signed approve, reject, edit receipts plus approvals.jsonl. See [approvals](../approvals.md). -5. Decision model phase A (escalate-only), evaluated offline on approval dataset first, shadow mode before it can affect anything. +5. Decision model phase A (escalate-only), evaluated offline on approval + dataset first, shadow mode before it can affect anything. Shipped: + classifier, shadow and escalate modes, model block in receipts, offline + eval with a baseline, deterministic stdlib trainer. Not done: training on + real labels, the gate in [decision model](decision-model.md), the LLM + judge as a second signal. See [decision model](decision-model.md). 6. Phase B only after shadow results and calibration are documented. ## Candidate model types (decide by spike, not now) Small fine-tuned classifier on structured features (cheap, fast, auditable features). Small local LLM judge with typed options (Jev-style, AgentJev-0.6B is Apache-2.0). Start with the classifier. LLM judge as second signal. Both escalate-only at first. ## Open -- Feature set and labels: seeded from approval dataset, but dataset is empty until first PoC. Need synthetic seed from founder-written scenarios (50 to 100) to bootstrap shadow mode. +- Feature set and labels: seeded from approval dataset, but dataset is empty until first PoC. Need synthetic seed from founder-written scenarios (50 to 100) to bootstrap shadow mode. Today: feature spec v1 and 30 engineer-written DRAFT scenarios in tools/decision-train/scenarios.json, to be replaced. - Who owns threshold changes (customer security team) and how they are versioned. diff --git a/docs/architecture/decision-model.md b/docs/architecture/decision-model.md new file mode 100644 index 0000000..c79fc70 --- /dev/null +++ b/docs/architecture/decision-model.md @@ -0,0 +1,198 @@ +# Decision model (layer 2, phase A) + +Status: shipped 2026-10-02, escalate-only, shadow by default, NOT +production-validated. The shipped artifact is trained on synthetic DRAFT +scenarios. Read [Honest status](#honest-status) before quoting any number. + +See the [decision architecture ADR](decision-architecture.md) for why the +model sits between invariants and humans. + +## What it is + +A logistic regression over a small, fixed feature vector. It returns the +risk that a human would reject or edit the action. Not an LLM. It runs in the +operator process. No network call, no hosted scoring API. + +| Package | Role | +|---|---| +| `pkg/decision` | `Features`, `Score`, `Scorer`, `ApplyModel`, `EffectiveThreshold` | +| `pkg/decision/learned` | Loads the JSON artifact, integer-only inference | +| `pkg/decision/baseline` | Hand-weighted control scorer for evaluation | +| `pkg/decision/eval` | Offline evaluation against approvals.jsonl | +| `tools/decision-train` | Stdlib Python seed and trainer | + +## Features (spec v1) + +`decision.FeatureSpecVersion = "v1"`. All values are small integers. The +same `FeatureInput` maps from a live decision and from a dataset example, so +training and serving see the same values. `tools/decision-train/features.py` +mirrors `pkg/decision/features.go`. A test recomputes the feature matrix hash +of the shipped dataset to catch drift. + +| Feature | Value | +|---|---| +| `dc_personal_count` | distinct personal classes observed (email, phone, aadhaar, pan, iban, card), cap 6 | +| `dc_credential` | credential class observed | +| `dc_undeclared_count` | observed classes outside the declared list, cap 6, only with a manifest | +| `personal_to_undeclared` | personal data and a destination outside the declared list | +| `dest_present`, `dest_declared` | destination host observed, and in the declared list | +| `declared_present` | decision type or an allow list is declared | +| `decision_automated` | declared decision type is `automated` | +| `action_destructive` | an action name token is delete, remove, purge, drop, reset, cleanup, clear, destroy, truncate, wipe | +| `prior_actions_bucket` | prior actions 0, 1-9, 10-99, 100+ as 0..3 | +| `prior_denied` | prior denied count, cap 10 | +| `conf_absent`, `conf_low`, `conf_mid` | claimed confidence absent, below 0.5, 0.5 to below 0.8 | +| `health_absent`, `health_low`, `health_mid` | claimed cluster health absent, below 50, 50 to below 80 | +| `pack_any`, `pack_dpdp_in`, `pack_gdpr_eu` | policy packs set | +| `esc_invariant`, `esc_rules`, `esc_threshold`, `esc_model` | layer of a non-allow outcome | + +Declared purpose and agent intent are not features. Dataset examples do not +carry them. Any change to names, order, buckets, or keywords needs a new spec +version. An artifact with another spec version or feature order is refused. + +## Artifact + +`model.json`, printable ASCII, no floats: + +- `schema_version`, `model_id`, `version`, `model_type: logistic_regression` +- `feature_spec_version`, `features` (names in order) +- `weights`, `bias`: decimal strings, at most 6 fractional digits +- `threshold_micro`: escalation threshold, 1 to 1000000 +- `calibration`: holdout reliability bins and ECE, integers +- `trained_on`: `data_source` (synthetic, human, mixed), dataset sha256, counts +- `training`: method and hyperparameters as strings +- `artifact_sha256`: SHA-256 of the artifact as compact JSON, sorted keys, + this field removed + +A hash mismatch, spec mismatch, bad decimal, or unknown field means no model. +The operator logs the error and decides exactly as without a model. + +## Determinism and replay + +- Training: fixed seed, fixed iterations, holdout chosen by + `sha256(example_id)` mod 5. `make decision-train` trains twice and fails + unless every output file is byte-identical. +- Inference: weights become int64 micro-units. The logit is an integer sum. + `SigmoidMicro` computes `exp` with a fixed-point Taylor series and repeated + multiplication, integers only. No float, no `math.Exp`, so the result does + not depend on CPU or libm. `golden.json` holds Python-computed features, + logits, and risks. A Go test checks the operator matches them exactly. +- Replay: same input, same action name, same packs, same artifact give the + same features, the same `feature_hash`, and the same `risk_micro`. + +## Receipt model block + +Whenever a model scores an action (shadow or escalate), the decision record +gets a `model` block. See [receipts](../receipts.md#model-block). It holds the +model id and version, artifact sha256, feature spec and feature hash, option +set and order, per-option micro probabilities, risk, threshold used, +calibration note, top reason codes, the outcome before and after the model, +and the scorer error if any. + +## Escalate-only guarantee + +A model can never turn a deny or a require_approval into an allow. Enforced at: + +1. `decision.ApplyModel` is the only function that lets a score change an + outcome. It returns the input unchanged unless mode is `escalate` and the + input outcome is `allow`. Then the only output is `require_approval` + (risk at or above threshold, or a scorer error) or the input. + `TestApplyModelNeverLoosens` runs every outcome, mode, risk, threshold, and + error combination and fails on any looser result. +2. The controller calls the model after `decision.Decide`, so invariants, + packs, and the threshold have already decided. The model sees their + outcome and cannot replace it. +3. `decision.EffectiveThreshold` only accepts an override that is lower than + the artifact threshold. The webhook rejects a looser `thresholdMicro` when + an artifact is loaded. The controller ignores it either way. +4. Scorer error: escalate mode escalates, shadow mode records the error. + Neither path allows. +5. A model-escalated action goes to the normal approval flow. Human approve + and edit re-run invariants and packs, not the model. + +## Configuration + +| Where | Setting | +|---|---| +| Operator env | `DECISION_MODEL_PATH`, path to `model.json`. Unset: no model | +| Workload | `spec.decisionModel.mode`: `off`, `shadow` (default), `escalate` | +| Workload | `spec.decisionModel.thresholdMicro`: stricter override only | +| Chart | `global.decisionModel.existingConfigMap` and `key` mount the artifact | + +Shadow is the default mode, but nothing runs until an artifact is mounted. +Without `DECISION_MODEL_PATH` the operator behaves exactly as before. + +```sh +kubectl -n agentic-system create configmap decision-model \ + --from-file=model.json=tools/decision-train/out/model.json +helm upgrade clawdlinux charts --reuse-values \ + --set global.decisionModel.existingConfigMap=decision-model +``` + +## Training and evaluation + +```sh +make decision-train +agentctl decision eval --dataset export/ --model model.json --baseline +agentctl decision eval --dataset tools/decision-train/out/approvals.jsonl \ + --model tools/decision-train/out/model.json --allow-unverified --baseline +``` + +`eval` verifies an export dir like `agentctl dataset verify` and refuses +unverified data unless `--allow-unverified`. It prints a WARNING header when +any row is synthetic or there are fewer than 200 human labels. Positive means +a human rejected or edited. Recall is the share of those the model escalates. +Extra review is the share of human approvals the model escalates. + +### Shipped artifact on its own synthetic data + +All 360 rows synthetic, 0 human labels, unverified. These numbers measure fit +to rules an engineer wrote. They say nothing about real reviewers. + +| Scorer, threshold 0.5 | Recall | Extra review | Precision | ECE | +|---|---|---|---|---| +| `clawdlinux-escalate-logreg` 0.1.0-synthetic-draft | 0.931 | 0.295 | 0.850 | 0.149 | +| `clawdlinux-baseline-additive` 1 | 0.342 | 0.008 | 0.988 | 0.376 | + +Trainer holdout (76 rows from the same scenarios, optimistic): recall 0.939, +precision 0.868, extra review 0.259, ECE 0.184. + +## Shadow-first procedure + +1. Mount the artifact. Leave workloads on `shadow`. +2. Collect human decisions. Every escalation still comes from invariants, + packs, or the threshold. +3. Export and verify the dataset. Run `agentctl decision eval` against the + human rows with `--baseline`. +4. Retrain on human rows only. Check the new artifact `data_source` is + `human`. +5. Only then consider `escalate`, per workload, after the checklist below. + +## Gate before `escalate` in production + +All must hold on a verified export with no synthetic rows: + +- [ ] At least 200 human labels, and at least 50 reject or edit labels. +- [ ] Artifact `trained_on.data_source` is `human`. +- [ ] Recall at the chosen threshold beats the baseline and meets the target + the customer security team signs off. +- [ ] Extra review rate is under the review-load ceiling the reviewing team + agrees to carry. +- [ ] ECE under 0.05, and no bin with 20+ rows off by more than 0.10. +- [ ] Threshold owner and change process recorded (open item in the ADR). +- [ ] Shadow scores checked against the same period's human decisions. + +## Honest status + +- The shipped artifact is trained on 30 DRAFT scenarios written by an + engineer. Founder-written scenarios and real labels must replace them. +- It is not production-validated. Do not enable `escalate` on it. +- Every synthetic row has `escalated_by: rules`, so the `esc_*` features get + weight 0. Live, the model mostly scores allowed actions, a population no + human label covers yet. +- Capture mode `none` drops the action name, so `action_destructive` is 0 for + those examples. +- No calibration step is applied. On the synthetic data, raw scores + over-predict risk in the low bins and under-predict it in the high bins. +- Runtime-adapter path (`spec.orchestration`) is not scored. +- Phase B bounded autonomy is NOT built. Nothing lets a model auto-allow. diff --git a/docs/policy-input.md b/docs/policy-input.md index f090d54..9989f47 100644 --- a/docs/policy-input.md +++ b/docs/policy-input.md @@ -92,6 +92,7 @@ confidence cannot turn an invariant or pack finding into an allow. | Any pack deny or pack error | `PolicyDenied` | `PendingApproval` | | Threshold deny | `PolicyDenied` | `PendingApproval` | | Pack require approval, nothing denies | `PendingApproval` | `PendingApproval` | +| Nothing objects, model in escalate mode scores at or above threshold | `PendingApproval` | `PendingApproval` | | Nothing objects | action executes | action executes | Under strict, reasons with rule IDs (for example `INV-02: ...` or @@ -99,3 +100,10 @@ Under strict, reasons with rule IDs (for example `INV-02: ...` or condition message. A pack approval sets condition `ApprovalRequired`. In permissive mode a deny sets no condition, as before. See [policy packs](policy-packs.md). + +The decision model runs after `Decide` and is escalate-only. Its features +come from the same declared, observed, and agent-claimed input. In shadow +mode (the default) it records a score and changes nothing. In escalate mode +it can only move an allow to require_approval, with condition +`ApprovalRequired` reason `ModelEscalation`. It never loosens a deny or an +approval. See [decision model](architecture/decision-model.md). diff --git a/docs/receipts.md b/docs/receipts.md index e005939..99bdd72 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -38,10 +38,11 @@ Each receipt is a v1 AgentGate receipt: The decision record sits beside the receipt in `records.jsonl`. Fields: `schema_version`, `workload` (namespace, name, uid), `action`, `layer` -(`invariant`, `rules`, `threshold`, `human`; `model` reserved), `outcome`, +(`invariant`, `rules`, `threshold`, `human`, `model`), `outcome`, `reasons` (rule ID plus reason), `input_hash`, `declared`, `observed`, `claimed`, `policy_packs`, `threshold_mode`, `replay_hint`. A `model` block is -reserved and absent until a model decides. Human decisions add an `approval` +added whenever the decision model scored the action. See +[model block](#model-block). Human decisions add an `approval` block and set `human_principal` to the approver. See [approvals](approvals.md). `input_hash` is the SHA-256 of the canonical JSON of the full decision input: @@ -51,6 +52,32 @@ the raw values without storing them. Numbers are integers or decimal strings. Never floats. Canonical JSON follows receiptspec `DECISION.md` (RFC 8785 subset). +## Model block + +Present only when `DECISION_MODEL_PATH` loaded an artifact and the workload +mode is `shadow` or `escalate`. Absent otherwise, so records without a model +are byte-identical to before. The record `schema_version` stays 1; the block +has its own `schema_version` (1). See +[decision model](architecture/decision-model.md). + +| Field | Meaning | +|---|---| +| `schema_version` | model block version, 1 | +| `mode` | `shadow` or `escalate` | +| `id`, `version`, `artifact_sha256` | the artifact that scored | +| `feature_spec`, `feature_hash` | feature spec version and SHA-256 of the feature vector | +| `option_set` | `["allow", "require_approval"]`, fixed order | +| `option_micro` | per-option probability in micro-units, same order, sums to 1000000 | +| `risk_micro` | P(human rejects or edits), micro-units | +| `threshold_micro` | effective threshold, after a stricter workload override | +| `calibration` | calibration note from the artifact | +| `reason_codes` | top 3 positive feature contributions | +| `base_outcome`, `outcome` | outcome before and after the model | +| `error` | scorer error, if any | + +In shadow mode `outcome` equals `base_outcome`. In escalate mode the record +`layer` is `model` when the model moved allow to require_approval. + ## What is not recorded - Raw action params and the proposal payload. @@ -167,7 +194,8 @@ That checks receipts only, not decision records. ## Gaps -- Runtime-adapter path (`spec.orchestration`) writes no receipts. +- Runtime-adapter path (`spec.orchestration`) writes no receipts and is not + scored by the decision model. - Execution outcome is not receipted. Only the pre-execution decision. - Human approve, reject, and edit receipts are written on the direct path. See [approvals](approvals.md). Argo approval gates are not receipted. From 6f7df200a080f7432e0e40c3113c97f467282393 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:33:48 +0530 Subject: [PATCH 19/54] feat(tracing): add decision span helpers Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- pkg/tracing/decisiontrace/decisiontrace.go | 128 ++++++++++++++++++ .../decisiontrace/decisiontrace_test.go | 109 +++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 pkg/tracing/decisiontrace/decisiontrace.go create mode 100644 pkg/tracing/decisiontrace/decisiontrace_test.go diff --git a/pkg/tracing/decisiontrace/decisiontrace.go b/pkg/tracing/decisiontrace/decisiontrace.go new file mode 100644 index 0000000..9f83d82 --- /dev/null +++ b/pkg/tracing/decisiontrace/decisiontrace.go @@ -0,0 +1,128 @@ +package decisiontrace + +import ( + "context" + "encoding/hex" + "errors" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +const ( + TracerName = "agentic.operator/decision" + SpanEvaluate = "decision.evaluate" + SpanInvariants = "decision.invariants" + SpanPacks = "decision.packs" + SpanThreshold = "decision.threshold" + SpanModel = "decision.model" + SpanReceiptAppend = "decision.receipt.append" + SpanHuman = "decision.human" + EventReceiptWriteFailed = "receipt.write.failed" + + KeyNamespace attribute.Key = "clawd.workload.namespace" + KeyName attribute.Key = "clawd.workload.name" + KeyLayer attribute.Key = "clawd.decision.layer" + KeyOutcome attribute.Key = "clawd.decision.outcome" + KeyAuditSeq attribute.Key = "clawd.audit.seq" + KeyEntryHash attribute.Key = "clawd.audit.entry_hash" + KeyRuleIDs attribute.Key = "clawd.decision.rule_ids" + KeyPackIDs attribute.Key = "clawd.decision.pack_ids" + KeyModelID attribute.Key = "clawd.decision.model.id" + KeyModelVersion attribute.Key = "clawd.decision.model.version" + KeyModelRisk attribute.Key = "clawd.decision.model.risk_micro" + KeyMode attribute.Key = "clawd.decision.mode" + + ErrorReceipt = "receipt_write_failed" + ErrorPacks = "policy_pack_failed" + ErrorModel = "model_score_failed" + ErrorHuman = "human_decision_failed" +) + +type evaluationKey struct{} + +type evaluation struct { + span trace.Span + namespace, name, mode string +} + +func StartEvaluation(ctx context.Context, namespace, name, mode string) (context.Context, trace.Span) { + ctx, span := otel.Tracer(TracerName).Start(ctx, SpanEvaluate) + if span.IsRecording() { + setWorkload(span, namespace, name, mode) + ctx = context.WithValue(ctx, evaluationKey{}, evaluation{span, namespace, name, mode}) + } + return ctx, span +} + +func Start(ctx context.Context, name string) (context.Context, trace.Span) { + ctx, span := otel.Tracer(TracerName).Start(ctx, name) + if span.IsRecording() { + if root, ok := ctx.Value(evaluationKey{}).(evaluation); ok { + setWorkload(span, root.namespace, root.name, root.mode) + } + } + return ctx, span +} + +func setWorkload(span trace.Span, namespace, name, mode string) { + if mode != "off" && mode != "escalate" { + mode = "shadow" + } + span.SetAttributes(KeyNamespace.String(namespace), KeyName.String(name), KeyMode.String(mode)) +} + +func SetResult(span trace.Span, layer, outcome string) { + if !span.IsRecording() { + return + } + if layer == "pack" { + layer = "rules" + } + span.SetAttributes(KeyLayer.String(layer), KeyOutcome.String(outcome)) +} + +func SetRules(span trace.Span, ruleIDs, packIDs []string) { + if span.IsRecording() { + span.SetAttributes(KeyRuleIDs.StringSlice(ruleIDs), KeyPackIDs.StringSlice(packIDs)) + } +} + +func SetModel(span trace.Span, id, version string, riskMicro int64, mode string) { + if span.IsRecording() { + span.SetAttributes(KeyModelID.String(id), KeyModelVersion.String(version), KeyModelRisk.Int64(riskMicro), KeyMode.String(mode)) + } +} + +func BindReceipt(ctx context.Context, span trace.Span, seq uint64, entryHash [32]byte) { + if !span.IsRecording() { + return + } + attrs := []attribute.KeyValue{KeyAuditSeq.Int64(int64(seq)), KeyEntryHash.String(hex.EncodeToString(entryHash[:]))} + span.SetAttributes(attrs...) + trace.SpanFromContext(ctx).SetAttributes(attrs...) + if root, ok := ctx.Value(evaluationKey{}).(evaluation); ok { + root.span.SetAttributes(attrs...) + } +} + +func Failed(span trace.Span, err error, class string) { + if err != nil && span.IsRecording() { + span.RecordError(errors.New(class)) + span.SetStatus(codes.Error, class) + } +} + +func ReceiptFailed(ctx context.Context, span trace.Span, err error) { + if err == nil || !span.IsRecording() { + return + } + Failed(span, err, ErrorReceipt) + span.AddEvent(EventReceiptWriteFailed) + Failed(trace.SpanFromContext(ctx), err, ErrorReceipt) + if root, ok := ctx.Value(evaluationKey{}).(evaluation); ok { + Failed(root.span, err, ErrorReceipt) + } +} diff --git a/pkg/tracing/decisiontrace/decisiontrace_test.go b/pkg/tracing/decisiontrace/decisiontrace_test.go new file mode 100644 index 0000000..9967b76 --- /dev/null +++ b/pkg/tracing/decisiontrace/decisiontrace_test.go @@ -0,0 +1,109 @@ +package decisiontrace + +import ( + "context" + "errors" + "strings" + "testing" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestDecisionSpans(t *testing.T) { + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + previous := otel.GetTracerProvider() + otel.SetTracerProvider(provider) + t.Cleanup(func() { + otel.SetTracerProvider(previous) + _ = provider.Shutdown(context.Background()) + }) + tests := []struct { + name, layer, outcome string + child string + rules []string + }{ + {"allow", "threshold", "allow", SpanThreshold, nil}, + {"invariant deny", "invariant", "deny", SpanInvariants, []string{"INV-01"}}, + {"pack approval", "pack", "require_approval", SpanPacks, []string{"GDPR-01"}}, + {"model escalate", "model", "require_approval", SpanModel, nil}, + {"human approve", "human", "approved", SpanHuman, nil}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + before := len(recorder.Ended()) + ctx, root := StartEvaluation(context.Background(), "default", "workload", "shadow") + childCtx, child := Start(ctx, test.child) + SetResult(child, test.layer, test.outcome) + SetRules(child, test.rules, []string{"gdpr-eu@v0.1.0"}) + if test.child == SpanModel { + SetModel(child, "risk-model", "1", 900000, "escalate") + } + appendCtx, appendSpan := Start(childCtx, SpanReceiptAppend) + BindReceipt(appendCtx, appendSpan, 37, [32]byte{0xab}) + appendSpan.End() + child.End() + SetResult(root, test.layer, test.outcome) + root.End() + spans := recorder.Ended()[before:] + if len(spans) != 3 || spans[0].Name() != SpanReceiptAppend || spans[1].Name() != test.child || spans[2].Name() != SpanEvaluate { + t.Fatalf("span names: %v", spans) + } + if spans[0].Parent().SpanID() != spans[1].SpanContext().SpanID() || spans[1].Parent().SpanID() != spans[2].SpanContext().SpanID() || spans[2].Parent().IsValid() { + t.Fatal("incorrect parent structure") + } + attributes := attribute.NewSet(spans[2].Attributes()...) + wantLayer := test.layer + if wantLayer == "pack" { + wantLayer = "rules" + } + for key, want := range map[attribute.Key]attribute.Value{ + KeyNamespace: attribute.StringValue("default"), KeyName: attribute.StringValue("workload"), + KeyLayer: attribute.StringValue(wantLayer), KeyOutcome: attribute.StringValue(test.outcome), + KeyMode: attribute.StringValue("shadow"), KeyAuditSeq: attribute.Int64Value(37), + KeyEntryHash: attribute.StringValue("ab" + strings.Repeat("00", 31)), + } { + got, ok := attributes.Value(key) + if !ok || got != want { + t.Errorf("%s = %v, want %v", key, got, want) + } + } + }) + } +} + +func TestFailurePrivacy(t *testing.T) { + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + previous := otel.GetTracerProvider() + otel.SetTracerProvider(provider) + t.Cleanup(func() { + otel.SetTracerProvider(previous) + _ = provider.Shutdown(context.Background()) + }) + ctx, root := StartEvaluation(context.Background(), "default", "workload", "off") + _, span := Start(ctx, SpanReceiptAppend) + canary := "raw action AKIAIOSFODNN7EXAMPLE auditor@example.test" + ReceiptFailed(ctx, span, errors.New(canary)) + span.End() + root.End() + for _, recorded := range recorder.Ended() { + if recorded.Status().Code != codes.Error { + t.Error("missing error status") + } + for _, event := range recorded.Events() { + for _, attr := range event.Attributes { + if strings.Contains(attr.Value.Emit(), canary) || strings.Contains(attr.Value.Emit(), "AKIA") || strings.Contains(attr.Value.Emit(), "@") { + t.Fatalf("leaked error: %v", attr) + } + } + } + } + if len(recorder.Ended()[0].Events()) != 2 || recorder.Ended()[0].Events()[1].Name != EventReceiptWriteFailed { + t.Fatal("missing receipt failure event") + } +} From 396e90e2a37ef44c130b1de7f7efaf6ba695088b Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:45:35 +0530 Subject: [PATCH 20/54] feat(tracing): cover direct and human decisions Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- .../controller/agentworkload_controller.go | 7 +- internal/controller/approvals.go | 87 ++++- internal/controller/decision_model.go | 8 + internal/controller/decision_tracing_test.go | 310 ++++++++++++++++++ internal/controller/receipts.go | 13 +- pkg/tracing/decisiontrace/decisiontrace.go | 35 +- 6 files changed, 442 insertions(+), 18 deletions(-) create mode 100644 internal/controller/decision_tracing_test.go diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index f1c61d0..9fbd14a 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -53,6 +53,7 @@ import ( "github.com/Clawdlinux/agentic-operator-core/pkg/routing" "github.com/Clawdlinux/agentic-operator-core/pkg/rules/packs" runtimeadapter "github.com/Clawdlinux/agentic-operator-core/pkg/runtime" + "github.com/Clawdlinux/agentic-operator-core/pkg/tracing/decisiontrace" ) // Maximum number of actions to keep in status to prevent unbounded growth @@ -592,7 +593,9 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques if workload.Spec.OPAPolicy != nil { opaPolicyMode = *workload.Spec.OPAPolicy } - opaResult := evaluateThreshold(opaPolicyMode, actionName, confidence, clusterHealth) + ctx, decisionSpan := decisiontrace.StartEvaluation(ctx, workload.Namespace, workload.Name, workload.Spec.DecisionModelMode()) + decisiontrace.SetRules(decisionSpan, nil, workload.Spec.PolicyPacks) + opaResult := traceThreshold(ctx, opaPolicyMode, actionName, confidence, clusterHealth) log.Info("OPA evaluation result", "allowed", opaResult.Allowed, "confidence", opaResult.Confidence, "reasons", opaResult.Reasons) @@ -620,6 +623,8 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques result, modelBlock := r.applyDecisionModel(ctx, &workload, actionName, decisionInput, result) // Write-ahead: the receipt is stored before the action can run. result, recorded := r.recordDecision(ctx, &workload, actionName, decisionInput, layers, result, opaPolicyMode, modelBlock) + decisiontrace.SetResult(decisionSpan, result.Layer, string(result.Outcome)) + decisionSpan.End() log.Info("decision", "outcome", result.Outcome, "layer", result.Layer, "destination", decisionInput.Observed.Destination, "dataClasses", decisionInput.Observed.DataClasses, "reasons", result.Reasons) // Step 5: Handle action execution or approval pending diff --git a/internal/controller/approvals.go b/internal/controller/approvals.go index 94e00c2..e13a704 100644 --- a/internal/controller/approvals.go +++ b/internal/controller/approvals.go @@ -35,6 +35,7 @@ import ( "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" "github.com/Clawdlinux/agentic-operator-core/pkg/rules/engine" "github.com/Clawdlinux/agentic-operator-core/pkg/rules/threshold" + "github.com/Clawdlinux/agentic-operator-core/pkg/tracing/decisiontrace" ) // Approval condition types. @@ -65,26 +66,70 @@ func evaluateThreshold(mode, action string, confidence, health float64) *thresho // threshold result. func (r *AgentWorkloadReconciler) evaluateLayers(ctx context.Context, wl *agenticv1alpha1.AgentWorkload, in decisioninput.Input, th *threshold.EvaluationResult) decision.Layers { layers := decision.Layers{ThresholdAllowed: th.Allowed, ThresholdReasons: th.Reasons} - for _, res := range invariants.Check(in, r.receiptContext(ctx)) { + invariantCtx, invariantSpan := decisiontrace.Start(ctx, decisiontrace.SpanInvariants) + var invariantIDs []string + for _, res := range invariants.Check(in, r.receiptContext(invariantCtx)) { layers.Invariants = append(layers.Invariants, res.String()) + if invariantSpan.IsRecording() { + invariantIDs = append(invariantIDs, res.ID) + } + } + invariantOutcome := decision.Allow + if len(layers.Invariants) > 0 { + invariantOutcome = decision.Deny } + decisiontrace.SetResult(invariantSpan, decision.LayerInvariant, string(invariantOutcome)) + decisiontrace.AddRuleIDs(ctx, invariantSpan, invariantIDs) + invariantSpan.End() + packCtx, packSpan := decisiontrace.Start(ctx, decisiontrace.SpanPacks) + var ruleIDs []string if len(wl.Spec.PolicyPacks) > 0 { var verdict engine.Verdict - eng, err := engine.LoadPacks(ctx, wl.Spec.PolicyPacks...) + eng, err := engine.LoadPacks(packCtx, wl.Spec.PolicyPacks...) if err == nil { - verdict, err = eng.Eval(ctx, engine.Doc(in)) + verdict, err = eng.Eval(packCtx, engine.Doc(in)) } layers.PackErr = err - for _, f := range verdict.Deny { - layers.PackDeny = append(layers.PackDeny, f.String()) + decisiontrace.Failed(packSpan, err, decisiontrace.ErrorPacks) + decisiontrace.FailedEvaluation(ctx, err, decisiontrace.ErrorPacks) + for _, finding := range verdict.Deny { + layers.PackDeny = append(layers.PackDeny, finding.String()) + if packSpan.IsRecording() { + ruleIDs = append(ruleIDs, finding.RuleID) + } } - for _, f := range verdict.RequireApproval { - layers.PackApproval = append(layers.PackApproval, f.String()) + for _, finding := range verdict.RequireApproval { + layers.PackApproval = append(layers.PackApproval, finding.String()) + if packSpan.IsRecording() { + ruleIDs = append(ruleIDs, finding.RuleID) + } } } + packOutcome := decision.Allow + if layers.PackErr != nil || len(layers.PackDeny) > 0 { + packOutcome = decision.Deny + } else if len(layers.PackApproval) > 0 { + packOutcome = decision.RequireApproval + } + decisiontrace.SetResult(packSpan, decision.LayerPack, string(packOutcome)) + decisiontrace.SetRules(packSpan, ruleIDs, wl.Spec.PolicyPacks) + decisiontrace.AddRuleIDs(ctx, packSpan, ruleIDs) + packSpan.End() return layers } +func traceThreshold(ctx context.Context, mode, action string, confidence, health float64) *threshold.EvaluationResult { + _, span := decisiontrace.Start(ctx, decisiontrace.SpanThreshold) + defer span.End() + result := evaluateThreshold(mode, action, confidence, health) + outcome := decision.Allow + if !result.Allowed { + outcome = decision.Deny + } + decisiontrace.SetResult(span, decision.LayerThreshold, string(outcome)) + return result +} + // humanBlocked reports whether a human approval cannot execute the action. // A human resolves threshold and pack approval findings. Never an invariant, // a pack deny, or a pack error. @@ -163,11 +208,28 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age if !ok { return ctrl.Result{RequeueAfter: time.Hour}, nil } + parentCtx := ctx + ctx, evaluationSpan := decisiontrace.StartEvaluation(ctx, wl.Namespace, wl.Name, wl.Spec.DecisionModelMode()) + defer evaluationSpan.End() + decisiontrace.SetRules(evaluationSpan, nil, wl.Spec.PolicyPacks) + ctx, humanSpan := decisiontrace.Start(ctx, decisiontrace.SpanHuman) + defer humanSpan.End() + humanOutcome := receipts.OutcomeApproved + switch d.Label { + case approval.Reject: + humanOutcome = receipts.OutcomeRejected + case approval.Edit: + humanOutcome = receipts.OutcomeEdited + } + decisiontrace.SetResult(humanSpan, receipts.LayerHuman, humanOutcome) + decisiontrace.SetResult(evaluationSpan, receipts.LayerHuman, humanOutcome) var proposal map[string]interface{} dec := json.NewDecoder(bytes.NewReader([]byte(p.Proposal))) dec.UseNumber() if err := dec.Decode(&proposal); err != nil { + decisiontrace.Failed(humanSpan, err, decisiontrace.ErrorHuman) + decisiontrace.FailedEvaluation(ctx, err, decisiontrace.ErrorHuman) return r.holdApproval(ctx, wl, "PendingActionUnreadable", "stored proposal is unreadable: "+err.Error(), time.Hour) } orig := dataset.Action{Name: p.Action, Description: p.Description, Params: proposalParams(proposal)} @@ -205,13 +267,15 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age var layers decision.Layers blocked := false if d.Label != approval.Reject { - layers = r.evaluateLayers(ctx, wl, in, evaluateThreshold(mode, tgt.name, confidence, health)) + layers = r.evaluateLayers(ctx, wl, in, traceThreshold(ctx, mode, tgt.name, confidence, health)) blocked = humanBlocked(layers) } // Persist intent first. A stale read fails here, before any side effect. p.State, p.Decision = agenticv1alpha1.ApprovalStateRecording, d.Label if err := r.Status().Update(ctx, wl); err != nil { + decisiontrace.Failed(humanSpan, err, decisiontrace.ErrorHuman) + decisiontrace.FailedEvaluation(ctx, err, decisiontrace.ErrorHuman) return ctrl.Result{}, err } @@ -257,6 +321,8 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age if humanReceipt != nil { r.storeExample(ctx, wl, humanReceipt, human, original, orig, edited, now) } + humanSpan.End() + evaluationSpan.End() ref := tgt.name switch { @@ -265,7 +331,10 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age agenticv1alpha1.PhaseRejected, "Rejected", fmt.Sprintf("action %q rejected by %s", ref, d.Approver.Username), now) case blocked: // A human cannot override invariants or pack denies. Record the deny. - r.recordDecision(ctx, wl, tgt.name, in, layers, decision.Decide(layers), mode, nil) + denyCtx, denySpan := decisiontrace.StartEvaluation(parentCtx, wl.Namespace, wl.Name, wl.Spec.DecisionModelMode()) + denied, _ := r.recordDecision(denyCtx, wl, tgt.name, in, layers, decision.Decide(layers), mode, nil) + decisiontrace.SetResult(denySpan, denied.Layer, string(denied.Outcome)) + denySpan.End() return r.finishApproval(ctx, wl, d.Label, approvalDenied, d.ApproverSHA256(), receiptSeq(humanReceipt), "PolicyDenied", "HumanDecisionBlocked", fmt.Sprintf("human %s of %q blocked: %s", d.Label, ref, strings.Join(decision.Decide(layers).Reasons, "; ")), now) } diff --git a/internal/controller/decision_model.go b/internal/controller/decision_model.go index 071db09..470d39b 100644 --- a/internal/controller/decision_model.go +++ b/internal/controller/decision_model.go @@ -14,6 +14,7 @@ import ( "github.com/Clawdlinux/agentic-operator-core/pkg/decision" decisioninput "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" + "github.com/Clawdlinux/agentic-operator-core/pkg/tracing/decisiontrace" ) // DecisionModelConfig holds the loaded decision model. A nil Scorer means no @@ -37,6 +38,9 @@ func (r *AgentWorkloadReconciler) applyDecisionModel( if r.DecisionModel.Scorer == nil || mode == decision.ModelOff { return base, nil } + parentCtx := ctx + ctx, span := decisiontrace.Start(ctx, decisiontrace.SpanModel) + defer span.End() log := logf.FromContext(ctx) if mode != decision.ModelShadow && mode != decision.ModelEscalate { log.Info("unknown decision model mode, using shadow", "mode", mode) @@ -60,6 +64,10 @@ func (r *AgentWorkloadReconciler) applyDecisionModel( } final := decision.ApplyModel(base, s, err, mode) block := receipts.NewModelBlock(mode, s, err, base, final) + decisiontrace.SetResult(span, decision.LayerModel, string(final.Outcome)) + decisiontrace.SetModel(span, s.ModelID, s.Version, s.RiskMicro, string(mode)) + decisiontrace.Failed(span, err, decisiontrace.ErrorModel) + decisiontrace.FailedEvaluation(parentCtx, err, decisiontrace.ErrorModel) if err != nil { log.Error(err, "decision model scorer failed", "mode", mode, "baseOutcome", base.Outcome, "outcome", final.Outcome) } else { diff --git a/internal/controller/decision_tracing_test.go b/internal/controller/decision_tracing_test.go new file mode 100644 index 0000000..6c51e3b --- /dev/null +++ b/internal/controller/decision_tracing_test.go @@ -0,0 +1,310 @@ +package controller + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "slices" + "strings" + "testing" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace/noop" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/approval" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" + "github.com/Clawdlinux/agentic-operator-core/pkg/tracing/decisiontrace" +) + +type decisionSpanRecorder struct { + *tracetest.SpanRecorder + events *eventLog +} + +func (r *decisionSpanRecorder) OnEnd(span sdktrace.ReadOnlySpan) { + r.SpanRecorder.OnEnd(span) + r.events.add(span.Name() + ".end") +} + +func recordDecisionSpans(t *testing.T, events *eventLog) *tracetest.SpanRecorder { + t.Helper() + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(&decisionSpanRecorder{recorder, events})) + previous := otel.GetTracerProvider() + otel.SetTracerProvider(provider) + t.Cleanup(func() { + otel.SetTracerProvider(previous) + _ = provider.Shutdown(context.Background()) + }) + return recorder +} + +type traceReceiptWriter struct { + *fakeReceiptWriter + returned []receiptspec.Receipt +} + +func (w *traceReceiptWriter) Append(ctx context.Context, record receipts.DecisionRecord) (receiptspec.Receipt, error) { + receipt, err := w.fakeReceiptWriter.Append(ctx, record) + if err == nil { + receipt.Seq += 40 + receipt.EntryHash = [32]byte{0xab, byte(receipt.Seq)} + w.returned = append(w.returned, receipt) + } + return receipt, err +} + +func spanAttribute(span sdktrace.ReadOnlySpan, key attribute.Key) attribute.Value { + attributes := attribute.NewSet(span.Attributes()...) + value, _ := attributes.Value(key) + return value +} + +func assertDecisionPrivacy(t *testing.T, spans []sdktrace.ReadOnlySpan, canaries ...string) { + t.Helper() + for _, span := range spans { + values := []string{span.Name(), span.Status().Description} + for _, attr := range span.Attributes() { + values = append(values, string(attr.Key), attr.Value.Emit()) + } + for _, event := range span.Events() { + values = append(values, event.Name) + for _, attr := range event.Attributes { + values = append(values, string(attr.Key), attr.Value.Emit()) + } + } + for _, value := range values { + for _, canary := range canaries { + if canary != "" && strings.Contains(value, canary) { + t.Fatalf("span %s leaked %q: %q", span.Name(), canary, value) + } + } + } + } +} + +func assertDecisionTree(t *testing.T, spans []sdktrace.ReadOnlySpan, layer, outcome string, receipt *receiptspec.Receipt) sdktrace.ReadOnlySpan { + t.Helper() + var root sdktrace.ReadOnlySpan + byID := map[string]sdktrace.ReadOnlySpan{} + for _, span := range spans { + byID[span.SpanContext().SpanID().String()] = span + if span.Name() == decisiontrace.SpanEvaluate { + if root != nil { + t.Fatal("multiple evaluation roots") + } + root = span + } + } + if root == nil { + t.Fatal("missing decision.evaluate root") + } + if root.Parent().IsValid() || spanAttribute(root, decisiontrace.KeyLayer).AsString() != layer || spanAttribute(root, decisiontrace.KeyOutcome).AsString() != outcome { + t.Fatalf("root = %v, want %s/%s", root.Attributes(), layer, outcome) + } + for _, span := range spans { + if span == root { + continue + } + parent := byID[span.Parent().SpanID().String()] + if parent == nil || span.SpanContext().TraceID() != root.SpanContext().TraceID() || span.EndTime().After(parent.EndTime()) { + t.Fatalf("span %s outside decision tree", span.Name()) + } + if receipt != nil && span.Name() == decisiontrace.SpanReceiptAppend { + if spanAttribute(span, decisiontrace.KeyAuditSeq).AsInt64() != int64(receipt.Seq) { + t.Fatal("append span receipt sequence mismatch") + } + } + } + if receipt == nil { + attributes := attribute.NewSet(root.Attributes()...) + if attributes.HasValue(decisiontrace.KeyAuditSeq) || attributes.HasValue(decisiontrace.KeyEntryHash) { + t.Fatal("receipt identity without successful append") + } + } else if spanAttribute(root, decisiontrace.KeyAuditSeq).AsInt64() != int64(receipt.Seq) || spanAttribute(root, decisiontrace.KeyEntryHash).AsString() != hex.EncodeToString(receipt.EntryHash[:]) { + t.Fatal("root receipt identity differs from Writer return") + } + return root +} + +func TestDecisionTraceCoverage(t *testing.T) { + const credential = "AKIAIOSFODNN7EXAMPLE" + const email = "auditor@example.test" + const description = "private decision description" + pack := []string{"gdpr-eu@v0.1.0"} + intent := func(purpose, decisionType string) *agenticv1alpha1.DeclaredIntent { + return &agenticv1alpha1.DeclaredIntent{Purpose: purpose, DecisionType: decisionType, AllowedDataClasses: []string{"email"}, AllowedDestinations: []string{"127.0.0.1"}} + } + rows := []struct { + name, layer, outcome, description, confidence, rule string + packs []string + intent *agenticv1alpha1.DeclaredIntent + model *fakeScorer + mode, human string + disabled, required, fail bool + }{ + {name: "allow", layer: "threshold", outcome: "allow"}, + {name: "deny by invariant", layer: "invariant", outcome: "deny", description: description + " " + credential, rule: "INV-01"}, + {name: "deny by pack", layer: "rules", outcome: "deny", description: description + " " + email, packs: pack, intent: intent("", ""), rule: "GDPR-EU-01"}, + {name: "deny by threshold", layer: "threshold", outcome: "deny", confidence: "0.82"}, + {name: "require_approval by pack", layer: "rules", outcome: "require_approval", description: description + " " + email, packs: pack, intent: intent("support", "automated"), rule: "GDPR-EU-03"}, + {name: "model escalate", layer: "model", outcome: "require_approval", mode: "escalate", model: &fakeScorer{risk: 900000, threshold: 500000}}, + {name: "human approve", layer: "human", outcome: "approved", human: approval.Approve}, + {name: "human reject", layer: "human", outcome: "rejected", human: approval.Reject}, + {name: "human edit", layer: "human", outcome: "edited", human: approval.Edit}, + {name: "receipts disabled", layer: "threshold", outcome: "allow", disabled: true}, + {name: "receipts required failure", layer: "invariant", outcome: "deny", required: true, fail: true, rule: "INV-05"}, + } + fmt.Println("Decision trace coverage | layer | outcome | root") + for index, row := range rows { + t.Run(row.name, func(t *testing.T) { + events := &eventLog{} + var recorder *tracetest.SpanRecorder + var spans []sdktrace.ReadOnlySpan + var receipt *receiptspec.Receipt + if row.human != "" { + env := newApprovalEnv(t, fmt.Sprintf("trace-human-%d", index), "", description, nil) + events = env.events + recorder = recordDecisionSpans(t, events) + env.r.Receipts.Writer = &traceReceiptWriter{fakeReceiptWriter: env.writer} + ann := map[string]string{approval.AnnotationDecision: row.human, approval.AnnotationReason: description + email + credential} + if row.human == approval.Edit { + ann[approval.AnnotationEdit] = `{"name":"scale","description":"private edited description","params":{"replicas":2}}` + } + env.decide(t, ann, true, env.pendingI) + workload := env.get(t) + stamp, err := approval.Stamp(email, []string{"sre"}) + if err != nil { + t.Fatal(err) + } + workload.Annotations[approval.AnnotationBy] = stamp + if err := env.k8s.Update(env.ctx, workload); err != nil { + t.Fatal(err) + } + before := len(recorder.Ended()) + env.reconcile(t) + spans = recorder.Ended()[before:] + writer := env.r.Receipts.Writer.(*traceReceiptWriter) + receipt = &writer.returned[0] + if row.human != approval.Reject && len(env.get(t).Status.ExecutedActions) != 1 { + t.Fatal("human action did not execute") + } + } else { + recorder = recordDecisionSpans(t, events) + confidence := row.confidence + if confidence == "" { + confidence = "0.98" + } + private := row.description + if private == "" { + private = description + } + server := newLoggingMCPServer(t, mockMCPScenario{confidence: confidence, clusterHealth: 90.0, description: private}, events) + scheme := newControllerTestScheme(t) + policy := "strict" + workload := &agenticv1alpha1.AgentWorkload{ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("trace-%d", index), Namespace: "default", UID: types.UID("trace-uid")}, Spec: agenticv1alpha1.AgentWorkloadSpec{MCPServerEndpoint: &server.URL, OPAPolicy: &policy, PolicyPacks: row.packs, DeclaredIntent: row.intent}} + if row.mode != "" { + workload.Spec.DecisionModel = &agenticv1alpha1.DecisionModel{Mode: row.mode} + } + k8s := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(workload).WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, workload).Build() + writer := &traceReceiptWriter{fakeReceiptWriter: &fakeReceiptWriter{log: events, ready: true}} + if row.fail { + writer.appendErr = errors.New(description + " " + credential + " " + email) + } + reconciler := &AgentWorkloadReconciler{Client: k8s, Scheme: scheme, Receipts: ReceiptsConfig{Enabled: !row.disabled, Required: row.required, Writer: writer}} + if row.model != nil { + reconciler.DecisionModel = DecisionModelConfig{Scorer: row.model} + } + if _, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: workload.Name, Namespace: workload.Namespace}}); err != nil { + t.Fatal(err) + } + spans = recorder.Ended() + if len(writer.returned) > 0 { + receipt = &writer.returned[0] + } + wantExecute := row.outcome == "allow" + if slices.Contains(events.list(), "execute_action") != wantExecute { + t.Fatal("execution behavior changed") + } + } + root := assertDecisionTree(t, spans, row.layer, row.outcome, receipt) + assertDecisionPrivacy(t, spans, description, "private edited description", credential, email) + if spanAttribute(root, decisiontrace.KeyNamespace).AsString() != "default" || spanAttribute(root, decisiontrace.KeyName).AsString() == "" { + t.Fatal("missing workload identity") + } + var names []string + for _, span := range spans { + names = append(names, span.Name()) + if row.rule != "" && (span.Name() == decisiontrace.SpanInvariants || span.Name() == decisiontrace.SpanPacks) { + for _, ruleID := range spanAttribute(span, decisiontrace.KeyRuleIDs).AsStringSlice() { + if strings.ContainsAny(ruleID, ":/ ") { + t.Fatalf("rule ID contains reason or pack: %q", ruleID) + } + } + } + } + if row.human == "" { + for _, name := range []string{decisiontrace.SpanInvariants, decisiontrace.SpanPacks, decisiontrace.SpanThreshold} { + if !slices.Contains(names, name) { + t.Errorf("missing %s", name) + } + } + } else if !slices.Contains(names, decisiontrace.SpanHuman) { + t.Fatal("missing human span") + } + if slices.Contains(names, decisiontrace.SpanModel) != (row.model != nil) || slices.Contains(names, decisiontrace.SpanReceiptAppend) == row.disabled { + t.Fatalf("unexpected conditional span: %v", names) + } + if row.fail { + if root.Status().Code != codes.Error { + t.Fatal("append failure missing error status") + } + found := false + for _, span := range spans { + for _, event := range span.Events() { + found = found || event.Name == decisiontrace.EventReceiptWriteFailed + } + } + if !found { + t.Fatal("missing receipt.write.failed") + } + } + order := events.list() + if executeAt := slices.Index(order, "execute_action"); executeAt >= 0 { + if rootAt := slices.Index(order, decisiontrace.SpanEvaluate+".end"); rootAt < 0 || rootAt > executeAt { + t.Fatalf("decision trace extends into execution: %v", order) + } + if !row.disabled { + if appendAt := slices.Index(order, decisiontrace.SpanReceiptAppend+".end"); appendAt < 0 || appendAt > executeAt { + t.Fatalf("receipt span must end before execute: %v", order) + } + } + } + fmt.Printf("%s | %s | %s | PASS\n", row.name, row.layer, row.outcome) + }) + } +} + +func TestDecisionTracingDisabled(t *testing.T) { + previous := otel.GetTracerProvider() + otel.SetTracerProvider(noop.NewTracerProvider()) + t.Cleanup(func() { otel.SetTracerProvider(previous) }) + env := newApprovalEnv(t, "trace-disabled", "", "private decision description", nil) + env.decide(t, map[string]string{approval.AnnotationDecision: approval.Approve}, true, env.pendingI) + env.reconcile(t) + if len(env.writer.records) != 2 || env.get(t).Status.Phase != "Completed" { + t.Fatal("disabled tracing changed decision or receipts") + } +} diff --git a/internal/controller/receipts.go b/internal/controller/receipts.go index 3813546..1092964 100644 --- a/internal/controller/receipts.go +++ b/internal/controller/receipts.go @@ -10,6 +10,7 @@ import ( "errors" "slices" + "go.opentelemetry.io/otel/trace" logf "sigs.k8s.io/controller-runtime/pkg/log" "github.com/Clawdlinux/agentgate/pkg/receiptspec" @@ -20,6 +21,7 @@ import ( decisioninput "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" "github.com/Clawdlinux/agentic-operator-core/pkg/invariants" "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" + "github.com/Clawdlinux/agentic-operator-core/pkg/tracing/decisiontrace" ) // ReceiptsConfig controls decision receipts on the direct action path. The @@ -91,6 +93,7 @@ func (r *AgentWorkloadReconciler) recordDecision( } } result = decision.Decide(layers) + decisiontrace.AddRuleIDs(ctx, trace.SpanFromContext(ctx), []string{invariants.ReceiptsOrFailClosed}) out.record, _ = newRecord(wl, action, in, layers, result, thresholdMode, model) return result, out } @@ -122,13 +125,21 @@ func workloadRef(wl *agenticv1alpha1.AgentWorkload) receipts.Workload { // appendRecord stores rec through the writer and returns its receipt. func (r *AgentWorkloadReconciler) appendRecord(ctx context.Context, rec receipts.DecisionRecord) (*receiptspec.Receipt, error) { + parentCtx := ctx + ctx, span := decisiontrace.Start(ctx, decisiontrace.SpanReceiptAppend) + defer span.End() + decisiontrace.SetResult(span, rec.Layer, rec.Outcome) if r.Receipts.Writer == nil { - return nil, errors.New("no receipt writer configured") + err := errors.New("no receipt writer configured") + decisiontrace.ReceiptFailed(parentCtx, span, err) + return nil, err } rc, err := r.Receipts.Writer.Append(ctx, rec) if err != nil { + decisiontrace.ReceiptFailed(parentCtx, span, err) return nil, err } + decisiontrace.BindReceipt(parentCtx, span, rc.Seq, rc.EntryHash) logf.FromContext(ctx).Info("decision receipt written", "seq", rc.Seq, "outcome", rec.Outcome, "layer", rec.Layer) return &rc, nil } diff --git a/pkg/tracing/decisiontrace/decisiontrace.go b/pkg/tracing/decisiontrace/decisiontrace.go index 9f83d82..b00b7fd 100644 --- a/pkg/tracing/decisiontrace/decisiontrace.go +++ b/pkg/tracing/decisiontrace/decisiontrace.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "errors" + "slices" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -46,13 +47,14 @@ type evaluationKey struct{} type evaluation struct { span trace.Span namespace, name, mode string + ruleIDs []string } func StartEvaluation(ctx context.Context, namespace, name, mode string) (context.Context, trace.Span) { ctx, span := otel.Tracer(TracerName).Start(ctx, SpanEvaluate) if span.IsRecording() { setWorkload(span, namespace, name, mode) - ctx = context.WithValue(ctx, evaluationKey{}, evaluation{span, namespace, name, mode}) + ctx = context.WithValue(ctx, evaluationKey{}, &evaluation{span: span, namespace: namespace, name: name, mode: mode}) } return ctx, span } @@ -60,7 +62,7 @@ func StartEvaluation(ctx context.Context, namespace, name, mode string) (context func Start(ctx context.Context, name string) (context.Context, trace.Span) { ctx, span := otel.Tracer(TracerName).Start(ctx, name) if span.IsRecording() { - if root, ok := ctx.Value(evaluationKey{}).(evaluation); ok { + if root, ok := ctx.Value(evaluationKey{}).(*evaluation); ok { setWorkload(span, root.namespace, root.name, root.mode) } } @@ -96,6 +98,28 @@ func SetModel(span trace.Span, id, version string, riskMicro int64, mode string) } } +func AddRuleIDs(ctx context.Context, span trace.Span, ruleIDs []string) { + if !span.IsRecording() { + return + } + span.SetAttributes(KeyRuleIDs.StringSlice(ruleIDs)) + if root, ok := ctx.Value(evaluationKey{}).(*evaluation); ok { + for _, id := range ruleIDs { + if !slices.Contains(root.ruleIDs, id) { + root.ruleIDs = append(root.ruleIDs, id) + } + } + root.span.SetAttributes(KeyRuleIDs.StringSlice(root.ruleIDs)) + } +} + +func FailedEvaluation(ctx context.Context, err error, class string) { + Failed(trace.SpanFromContext(ctx), err, class) + if root, ok := ctx.Value(evaluationKey{}).(*evaluation); ok && root.span != trace.SpanFromContext(ctx) { + Failed(root.span, err, class) + } +} + func BindReceipt(ctx context.Context, span trace.Span, seq uint64, entryHash [32]byte) { if !span.IsRecording() { return @@ -103,7 +127,7 @@ func BindReceipt(ctx context.Context, span trace.Span, seq uint64, entryHash [32 attrs := []attribute.KeyValue{KeyAuditSeq.Int64(int64(seq)), KeyEntryHash.String(hex.EncodeToString(entryHash[:]))} span.SetAttributes(attrs...) trace.SpanFromContext(ctx).SetAttributes(attrs...) - if root, ok := ctx.Value(evaluationKey{}).(evaluation); ok { + if root, ok := ctx.Value(evaluationKey{}).(*evaluation); ok { root.span.SetAttributes(attrs...) } } @@ -121,8 +145,5 @@ func ReceiptFailed(ctx context.Context, span trace.Span, err error) { } Failed(span, err, ErrorReceipt) span.AddEvent(EventReceiptWriteFailed) - Failed(trace.SpanFromContext(ctx), err, ErrorReceipt) - if root, ok := ctx.Value(evaluationKey{}).(evaluation); ok { - Failed(root.span, err, ErrorReceipt) - } + FailedEvaluation(ctx, err, ErrorReceipt) } From 90c151167b7aacfcfa51a65fbce737863c1ba1c4 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:49:18 +0530 Subject: [PATCH 21/54] test(tracing): enforce decision coverage matrix Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- Makefile | 4 + internal/controller/decision_tracing_test.go | 89 +++++++++++++++++++ .../decisiontrace/decisiontrace_test.go | 19 ++++ 3 files changed, 112 insertions(+) diff --git a/Makefile b/Makefile index 77998d6..6188828 100644 --- a/Makefile +++ b/Makefile @@ -180,6 +180,10 @@ build: decision-train: ## Seed DRAFT synthetic data and train the decision model twice; fails unless both artifacts match @bash tools/decision-train/run.sh +.PHONY: trace-coverage +trace-coverage: + @$(GO) test ./internal/controller -run '^TestDecisionTraceCoverage$$' -count=1 -v + build-agentctl: @echo "Building agentctl binary..." @mkdir -p bin diff --git a/internal/controller/decision_tracing_test.go b/internal/controller/decision_tracing_test.go index 6c51e3b..35ff23b 100644 --- a/internal/controller/decision_tracing_test.go +++ b/internal/controller/decision_tracing_test.go @@ -24,6 +24,8 @@ import ( "github.com/Clawdlinux/agentgate/pkg/receiptspec" agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" "github.com/Clawdlinux/agentic-operator-core/pkg/approval" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + decisioninput "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" "github.com/Clawdlinux/agentic-operator-core/pkg/tracing/decisiontrace" ) @@ -241,12 +243,36 @@ func TestDecisionTraceCoverage(t *testing.T) { } root := assertDecisionTree(t, spans, row.layer, row.outcome, receipt) assertDecisionPrivacy(t, spans, description, "private edited description", credential, email) + if row.rule != "" && !slices.Contains(spanAttribute(root, decisiontrace.KeyRuleIDs).AsStringSlice(), row.rule) { + t.Fatalf("root missing rule %s: %v", row.rule, root.Attributes()) + } + if !slices.Equal(spanAttribute(root, decisiontrace.KeyPackIDs).AsStringSlice(), row.packs) { + t.Fatalf("root pack IDs = %v, want %v", root.Attributes(), row.packs) + } if spanAttribute(root, decisiontrace.KeyNamespace).AsString() != "default" || spanAttribute(root, decisiontrace.KeyName).AsString() == "" { t.Fatal("missing workload identity") } var names []string + var humanSpan sdktrace.ReadOnlySpan + for _, span := range spans { + if span.Name() == decisiontrace.SpanHuman { + humanSpan = span + } + } for _, span := range spans { names = append(names, span.Name()) + if span != root { + parent := root + if humanSpan != nil && span != humanSpan { + parent = humanSpan + } + if span.Parent().SpanID() != parent.SpanContext().SpanID() { + t.Fatalf("wrong parent for %s", span.Name()) + } + } + if span.Name() == decisiontrace.SpanModel && (spanAttribute(span, decisiontrace.KeyModelID).AsString() != "fake" || spanAttribute(span, decisiontrace.KeyModelVersion).AsString() != "1" || spanAttribute(span, decisiontrace.KeyModelRisk).AsInt64() != row.model.risk || spanAttribute(span, decisiontrace.KeyMode).AsString() != row.mode) { + t.Fatalf("model attributes = %v", span.Attributes()) + } if row.rule != "" && (span.Name() == decisiontrace.SpanInvariants || span.Name() == decisiontrace.SpanPacks) { for _, ruleID := range spanAttribute(span, decisiontrace.KeyRuleIDs).AsStringSlice() { if strings.ContainsAny(ruleID, ":/ ") { @@ -308,3 +334,66 @@ func TestDecisionTracingDisabled(t *testing.T) { t.Fatal("disabled tracing changed decision or receipts") } } + +func TestDecisionTraceHumanReceiptFailure(t *testing.T) { + for _, required := range []bool{true, false} { + t.Run(fmt.Sprintf("required=%t", required), func(t *testing.T) { + const canary = "private approval reason AKIAIOSFODNN7EXAMPLE auditor@example.test" + env := newApprovalEnv(t, fmt.Sprintf("trace-human-failure-%t", required), "", "private description", nil) + env.decide(t, map[string]string{approval.AnnotationDecision: approval.Approve, approval.AnnotationReason: canary}, true, env.pendingI) + env.writer.appendErr = errors.New(canary) + env.r.Receipts.Required = required + recorder := recordDecisionSpans(t, env.events) + before := len(env.events.list()) + env.reconcile(t) + spans := recorder.Ended() + root := assertDecisionTree(t, spans, "human", "approved", nil) + if root.Status().Code != codes.Error { + t.Fatal("human receipt failure missing error status") + } + assertDecisionPrivacy(t, spans, "private description", "private approval reason", "AKIAIOSFODNN7EXAMPLE", "auditor@example.test") + order := env.events.list()[before:] + if slices.Contains(order, "execute_action") == required { + t.Fatalf("receipt failure changed execution rule: %v", order) + } + if !required && slices.Index(order, decisiontrace.SpanReceiptAppend+".end") > slices.Index(order, "execute_action") { + t.Fatalf("optional failed append span extends into execution: %v", order) + } + }) + } +} + +func TestDecisionTraceModelModes(t *testing.T) { + for _, mode := range []string{"off", "shadow", "escalate"} { + t.Run(mode, func(t *testing.T) { + events := &eventLog{} + recorder := recordDecisionSpans(t, events) + ctx, root := decisiontrace.StartEvaluation(context.Background(), "default", "model-mode", mode) + workload := &agenticv1alpha1.AgentWorkload{Spec: agenticv1alpha1.AgentWorkloadSpec{DecisionModel: &agenticv1alpha1.DecisionModel{Mode: mode}}} + const canary = "private score error AKIAIOSFODNN7EXAMPLE auditor@example.test" + scorer := &fakeScorer{err: errors.New(canary)} + reconciler := &AgentWorkloadReconciler{DecisionModel: DecisionModelConfig{Scorer: scorer}} + result, block := reconciler.applyDecisionModel(ctx, workload, "private action description", decisioninput.Input{}, decision.Result{Layer: decision.LayerThreshold, Outcome: decision.Allow}) + decisiontrace.SetResult(root, result.Layer, string(result.Outcome)) + root.End() + spans := recorder.Ended() + assertDecisionPrivacy(t, spans, "private action description", "private score error", "AKIAIOSFODNN7EXAMPLE", "auditor@example.test") + if mode == "off" { + if len(spans) != 1 || scorer.calls != 0 || block != nil || result.Outcome != decision.Allow { + t.Fatal("off mode scored or changed decision") + } + return + } + if len(spans) != 2 || spans[0].Name() != decisiontrace.SpanModel || spans[0].Status().Code != codes.Error || spans[1].Status().Code != codes.Error || block == nil { + t.Fatal("model error missing trace or receipt block") + } + want := decision.Allow + if mode == "escalate" { + want = decision.RequireApproval + } + if result.Outcome != want { + t.Fatalf("model error outcome = %s, want %s", result.Outcome, want) + } + }) + } +} diff --git a/pkg/tracing/decisiontrace/decisiontrace_test.go b/pkg/tracing/decisiontrace/decisiontrace_test.go index 9967b76..eb5bd0f 100644 --- a/pkg/tracing/decisiontrace/decisiontrace_test.go +++ b/pkg/tracing/decisiontrace/decisiontrace_test.go @@ -3,6 +3,7 @@ package decisiontrace import ( "context" "errors" + "slices" "strings" "testing" @@ -56,6 +57,24 @@ func TestDecisionSpans(t *testing.T) { if spans[0].Parent().SpanID() != spans[1].SpanContext().SpanID() || spans[1].Parent().SpanID() != spans[2].SpanContext().SpanID() || spans[2].Parent().IsValid() { t.Fatal("incorrect parent structure") } + childAttributes := attribute.NewSet(spans[1].Attributes()...) + ruleIDs, _ := childAttributes.Value(KeyRuleIDs) + packIDs, _ := childAttributes.Value(KeyPackIDs) + outcome, _ := childAttributes.Value(KeyOutcome) + if !slices.Equal(ruleIDs.AsStringSlice(), test.rules) || !slices.Equal(packIDs.AsStringSlice(), []string{"gdpr-eu@v0.1.0"}) || outcome.AsString() != test.outcome { + t.Fatalf("child attributes = %v", spans[1].Attributes()) + } + if test.child == SpanModel { + for key, want := range map[attribute.Key]attribute.Value{ + KeyModelID: attribute.StringValue("risk-model"), KeyModelVersion: attribute.StringValue("1"), + KeyModelRisk: attribute.Int64Value(900000), KeyMode: attribute.StringValue("escalate"), + } { + got, ok := childAttributes.Value(key) + if !ok || got != want { + t.Errorf("%s = %v, want %v", key, got, want) + } + } + } attributes := attribute.NewSet(spans[2].Attributes()...) wantLayer := test.layer if wantLayer == "pack" { From 3ae619545366a590083eecea04f8d9351a6bb5d7 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 01:56:55 +0530 Subject: [PATCH 22/54] fix(tracing): retain blocked human rule IDs Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- internal/controller/approvals.go | 2 ++ internal/controller/decision_tracing_test.go | 35 ++++++++++++++++++++ pkg/tracing/decisiontrace/decisiontrace.go | 14 ++++++-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/internal/controller/approvals.go b/internal/controller/approvals.go index e13a704..1443e8d 100644 --- a/internal/controller/approvals.go +++ b/internal/controller/approvals.go @@ -332,6 +332,8 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age case blocked: // A human cannot override invariants or pack denies. Record the deny. denyCtx, denySpan := decisiontrace.StartEvaluation(parentCtx, wl.Namespace, wl.Name, wl.Spec.DecisionModelMode()) + decisiontrace.SetRules(denySpan, nil, wl.Spec.PolicyPacks) + decisiontrace.AddRuleIDs(denyCtx, denySpan, decisiontrace.RuleIDs(ctx)) denied, _ := r.recordDecision(denyCtx, wl, tgt.name, in, layers, decision.Decide(layers), mode, nil) decisiontrace.SetResult(denySpan, denied.Layer, string(denied.Outcome)) denySpan.End() diff --git a/internal/controller/decision_tracing_test.go b/internal/controller/decision_tracing_test.go index 35ff23b..0789dea 100644 --- a/internal/controller/decision_tracing_test.go +++ b/internal/controller/decision_tracing_test.go @@ -397,3 +397,38 @@ func TestDecisionTraceModelModes(t *testing.T) { }) } } + +func TestDecisionTraceBlockedHuman(t *testing.T) { + const description = "private blocked description AKIAIOSFODNN7EXAMPLE" + env := newApprovalEnv(t, "trace-human-blocked", "permissive", description, nil) + env.decide(t, map[string]string{approval.AnnotationDecision: approval.Approve}, true, env.pendingI) + writer := &traceReceiptWriter{fakeReceiptWriter: env.writer} + env.r.Receipts.Writer = writer + recorder := recordDecisionSpans(t, env.events) + env.reconcile(t) + if len(writer.returned) != 2 || env.get(t).Status.Phase != "PolicyDenied" { + t.Fatal("human decision bypassed invariant or lost follow-up receipt") + } + byTrace := map[string][]sdktrace.ReadOnlySpan{} + for _, span := range recorder.Ended() { + id := span.SpanContext().TraceID().String() + byTrace[id] = append(byTrace[id], span) + } + if len(byTrace) != 2 { + t.Fatal("human receipt and follow-up deny need separate evaluation roots") + } + for _, spans := range byTrace { + root := spans[len(spans)-1] + layer, outcome := "human", "approved" + receipt := &writer.returned[0] + if spanAttribute(root, decisiontrace.KeyLayer).AsString() == "invariant" { + layer, outcome = "invariant", "deny" + receipt = &writer.returned[1] + } + assertDecisionTree(t, spans, layer, outcome, receipt) + if !slices.Contains(spanAttribute(root, decisiontrace.KeyRuleIDs).AsStringSlice(), "INV-01") { + t.Fatal("follow-up trace lost checked invariant IDs") + } + assertDecisionPrivacy(t, spans, description, "AKIAIOSFODNN7EXAMPLE") + } +} diff --git a/pkg/tracing/decisiontrace/decisiontrace.go b/pkg/tracing/decisiontrace/decisiontrace.go index b00b7fd..7bf6a62 100644 --- a/pkg/tracing/decisiontrace/decisiontrace.go +++ b/pkg/tracing/decisiontrace/decisiontrace.go @@ -120,14 +120,24 @@ func FailedEvaluation(ctx context.Context, err error, class string) { } } +func RuleIDs(ctx context.Context) []string { + if root, ok := ctx.Value(evaluationKey{}).(*evaluation); ok { + return slices.Clone(root.ruleIDs) + } + return nil +} + func BindReceipt(ctx context.Context, span trace.Span, seq uint64, entryHash [32]byte) { if !span.IsRecording() { return } attrs := []attribute.KeyValue{KeyAuditSeq.Int64(int64(seq)), KeyEntryHash.String(hex.EncodeToString(entryHash[:]))} span.SetAttributes(attrs...) - trace.SpanFromContext(ctx).SetAttributes(attrs...) - if root, ok := ctx.Value(evaluationKey{}).(*evaluation); ok { + parent := trace.SpanFromContext(ctx) + if parent != span { + parent.SetAttributes(attrs...) + } + if root, ok := ctx.Value(evaluationKey{}).(*evaluation); ok && root.span != span && root.span != parent { root.span.SetAttributes(attrs...) } } From bbfac00fd80a06df6207c03cb076dfab1f5dce46 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 02:02:26 +0530 Subject: [PATCH 23/54] fix(tracing): use supported attribute rendering Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- internal/controller/decision_tracing_test.go | 4 ++-- pkg/tracing/decisiontrace/decisiontrace_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/controller/decision_tracing_test.go b/internal/controller/decision_tracing_test.go index 0789dea..6945301 100644 --- a/internal/controller/decision_tracing_test.go +++ b/internal/controller/decision_tracing_test.go @@ -79,12 +79,12 @@ func assertDecisionPrivacy(t *testing.T, spans []sdktrace.ReadOnlySpan, canaries for _, span := range spans { values := []string{span.Name(), span.Status().Description} for _, attr := range span.Attributes() { - values = append(values, string(attr.Key), attr.Value.Emit()) + values = append(values, string(attr.Key), attr.Value.String()) } for _, event := range span.Events() { values = append(values, event.Name) for _, attr := range event.Attributes { - values = append(values, string(attr.Key), attr.Value.Emit()) + values = append(values, string(attr.Key), attr.Value.String()) } } for _, value := range values { diff --git a/pkg/tracing/decisiontrace/decisiontrace_test.go b/pkg/tracing/decisiontrace/decisiontrace_test.go index eb5bd0f..d498e41 100644 --- a/pkg/tracing/decisiontrace/decisiontrace_test.go +++ b/pkg/tracing/decisiontrace/decisiontrace_test.go @@ -116,7 +116,7 @@ func TestFailurePrivacy(t *testing.T) { } for _, event := range recorded.Events() { for _, attr := range event.Attributes { - if strings.Contains(attr.Value.Emit(), canary) || strings.Contains(attr.Value.Emit(), "AKIA") || strings.Contains(attr.Value.Emit(), "@") { + if strings.Contains(attr.Value.String(), canary) || strings.Contains(attr.Value.String(), "AKIA") || strings.Contains(attr.Value.String(), "@") { t.Fatalf("leaked error: %v", attr) } } From 418e77ff439658bc105ad69e2d12763396bf3126 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 02:04:57 +0530 Subject: [PATCH 24/54] docs(tracing): document decision coverage and gaps Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- CHANGELOG.md | 5 + ROADMAP.md | 6 + docs/TRACING.md | 147 ++++++++++++++++++++- docs/approvals.md | 2 + docs/architecture/decision-architecture.md | 10 +- docs/architecture/decision-model.md | 2 + docs/receipts.md | 2 + 7 files changed, 170 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5245b70..4bcd368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added P7 decision spans on direct proposals and stamped human approve, reject, and edit decisions. + Successful receipt appends bind spans to writer-returned sequence and entry hash. + Added privacy canary tests, no-op checks, write-ahead assertions, and `make trace-coverage` with an 11-path matrix. + Runtime adapters, execution outcomes, receipt-writer internals, and writer trace propagation remain untraced. + See [decision tracing](docs/TRACING.md#decision-tracing). - Added a working booth prepare/present flow with real provider and cost proof. - Added `agentctl doctor sandbox` to verify RuntimeClass and ready-node sandbox evidence before workload deployment. - Added optional `spec.declaredIntent` on `AgentWorkload` and observed decision inputs. The legacy action path now denies or escalates actions that send undeclared data classes or call undeclared hosts. Agent-claimed confidence and cluster health can only tighten. New packages: `pkg/dataclass`, `pkg/decision`, `pkg/decision/input`, `pkg/decision/observe`. See `docs/policy-input.md`. diff --git a/ROADMAP.md b/ROADMAP.md index d62b748..de796c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,10 +57,16 @@ Strong CNCF base runtimes for agents on Kubernetes exist and are improving. Claw - [x] Human approval protocol and signed approval dataset. Webhook-stamped approver, append-once decisions, edit re-validated against invariants, `spec.approvalCapture`, `approvals.jsonl` on the receipt-writer, `agentctl dataset export|verify`. Direct action path only; Argo approval gates not covered. See [approvals](docs/approvals.md) - [x] Decision model phase A: logistic regression with integer-only inference, escalate-only, `spec.decisionModel.mode` shadow by default, `DECISION_MODEL_PATH`, receipt `model` block, `agentctl decision eval` with a hand-weighted baseline, stdlib trainer with byte-identical reruns. Shipped artifact trained on synthetic DRAFT scenarios, not production-validated. Phase B not built. See [decision model](docs/architecture/decision-model.md) +- [x] P7 decision traces on direct proposals and stamped human approve, reject, and edit decisions. + Receipt joins use writer-returned sequence and entry hash. Privacy and write-ahead tests enforce an 11-path coverage matrix. + See [decision tracing](docs/TRACING.md#decision-tracing). + ## Now Priority order. Validation before features. +- [ ] Decision trace gaps: runtime adapters, execution outcomes, receipt-writer internals, and cross-process propagation to the writer. + - [ ] Qualified platform and security conversations, including active production-review blockers and 1 scoped design-partner engagement - [ ] Fintech booth demo use case - [ ] Air-gapped install smoke test in CI diff --git a/docs/TRACING.md b/docs/TRACING.md index 50a4797..dbe89b9 100644 --- a/docs/TRACING.md +++ b/docs/TRACING.md @@ -1,4 +1,4 @@ -# Model Routing Observability with OpenTelemetry +# Model Routing and Decision Tracing with OpenTelemetry This document describes how to observe and debug model routing decisions using OpenTelemetry tracing. @@ -71,6 +71,148 @@ Spans record events for key decisions and errors: - `model_call_failed` - API call failed - `routing_completed` - Routing succeeded +## Decision tracing + +P7 instruments valid proposals on the legacy direct action path. +Each proposal starts `decision.evaluate` using tracer `agentic.operator/decision`. +Stamped human approve, reject, and edit decisions start separate evaluation spans. +Decision logic, precedence, receipt format, and execution behavior are unchanged. + +### Span tree + +```text +decision.evaluate + +- decision.threshold + +- decision.invariants + +- decision.packs + +- decision.model [model loaded and mode != off] + +- decision.receipt.append [receipts enabled] + +decision.evaluate + +- decision.human + +- decision.threshold [approve or edit] + +- decision.invariants [approve or edit] + +- decision.packs [approve or edit] + +- decision.receipt.append [receipts enabled] +``` + +The threshold runs first in existing controller code. Instrumentation does not reorder it. +Packs have a span even when no packs are configured. +Human approve and edit recheck invariants and packs, but do not rerun the model. +A blocked human decision produces another evaluation span for its subsequent deny receipt. +Receipt append spans finish before `execute_action` runs. +Evaluation and human spans also finish before execution starts. + +### Attributes + +These are the exact decision attribute keys. No other decision payload attributes are emitted. + +| Key | Type | Meaning and placement | +|---|---|---| +| `clawd.workload.namespace` | string | Kubernetes namespace identifier, on root and children | +| `clawd.workload.name` | string | Workload identifier, on root and children | +| `clawd.decision.layer` | string | Root's decisive layer; child's stage: `invariant`, `rules`, `threshold`, `human`, `model` | +| `clawd.decision.outcome` | string | `allow`, `deny`, `require_approval`, `approved`, `rejected`, `edited` | +| `clawd.audit.seq` | int64 | Writer-returned receipt sequence, on append span, its parent, and evaluation root | +| `clawd.audit.entry_hash` | string | Full hexadecimal receipt entry hash, on the same spans as sequence | +| `clawd.decision.rule_ids` | string slice | Invariant IDs and pack rule IDs only; collected on evaluation root and corresponding layer spans | +| `clawd.decision.pack_ids` | string slice | Configured versioned pack IDs, on evaluation root and pack span | +| `clawd.decision.model.id` | string | Scored model identifier, on model span | +| `clawd.decision.model.version` | string | Scored model version, on model span | +| `clawd.decision.model.risk_micro` | int64 | Scored risk in micro-units, on model span | +| `clawd.decision.mode` | string | Effective model mode: `off`, `shadow`, or `escalate`; on root and children | + +`pack` in the internal decision result becomes `rules` in trace attributes, matching receipts. +Rule IDs exclude pack prefixes, matched data, and reason text. Threshold prose is never used as a rule ID. +Only a successful append sets receipt sequence and hash. Disabled receipts and failed writes leave both absent. +A required write failure changes the proposal root to `invariant` / `deny`, matching existing INV-05 behavior. +A human write failure keeps the attempted human outcome, adds error status, and follows existing required-receipt behavior. +Errors call `span.RecordError` with fixed error classes and set error status. +Classes are `receipt_write_failed`, `policy_pack_failed`, `model_score_failed`, and `human_decision_failed`. +Append failures also emit `receipt.write.failed` without payload attributes. +Original error messages are never recorded in decision spans. + +### Enablement and retention + +The operator initializes the existing global OTel provider through `pkg/otel/genai`. +Set `OTEL_EXPORTER_OTLP_ENDPOINT` to an OTLP/gRPC collector address, for example `otel-collector:4317`. +No configured endpoint leaves the default no-op tracer. No collector, network call, or tracing-specific decision gate is added. +Spans use that global provider, including its sampler and exporter. +Every evaluated decision reaches instrumentation. Sampling, exporter failures, and backend retention can still remove trace evidence. +Keep all decision traces at the collector when audit trace coverage matters. +Signed receipts remain the evidence source; traces are an unsigned diagnostic index. + +### Join traces to receipts + +1. Select a `decision.evaluate` span by workload, layer, and outcome. +2. Read `clawd.audit.seq` and `clawd.audit.entry_hash`. +3. Export the corresponding writer chain and verify it against an independently pinned trust root. + +```sh +agentctl receipts export --writer http://localhost:8080 \ + --token-file ./token --out ./evidence +agentctl receipts verify ./evidence --trust-root ./pinned-trust.json +``` + +Successful verification prints these lines: + +```text +PASS: receipts verified, head seq= hash=<16 hex characters> +PASS: decision records bound to their receipts +``` + +Find the receipt whose `seq` equals `clawd.audit.seq` and compare its full entry hash. +Scope sequences to that writer chain. Sequences alone are not unique across writers or reset chains. +The verified receipt binds its signed record hash to the corresponding decision record. +The CLI prints only a shortened head hash. Compare the complete receipt hash, not that prefix. +The trace itself is not signed or verified by `agentctl receipts verify`. +Without a successful receipt append, no signed-chain join is available. +See [decision receipts](receipts.md#verify-offline) and [human approvals](approvals.md). + +Generic OTLP backend pseudo-query, adapted to the backend's span index: + +```sql +SELECT trace_id, attributes['clawd.audit.seq'], attributes['clawd.audit.entry_hash'] +FROM spans +WHERE span_name = 'decision.evaluate' + AND attributes['clawd.workload.namespace'] = 'default' + AND attributes['clawd.workload.name'] = 'my-workload' + AND attributes['clawd.decision.outcome'] = 'require_approval' +``` + +### Privacy rules + +Decision attributes and events contain identifiers, classes, hashes, and integers only. +Never add raw params, action descriptions, matched data, approver names, free-text reasons, tokens, or personal data. +Do not copy error strings into span status or events. They can contain credentials or email addresses. +Approved identifier fields are not substitutes for free-text fields. +Recorder tests plant description, credential, email, approver, and reason canaries and scan attributes and events. +These rules apply to decision spans. Existing model-routing instrumentation has its own older attribute schema. + +### Coverage check + +```sh +make trace-coverage +go test ./internal/controller -run '^TestDecisionTraceCoverage$' -count=1 -v +``` + +Both commands print the same 11-path matrix and assert root layer and outcome values. +Cases cover allow, invariant deny, pack deny, threshold deny, pack approval, and model escalation. +They also cover human approve, reject, edit, disabled receipts, and required receipt failure. +SDK recorder tests check receipt identity, parent links, privacy, and write-ahead span completion. +Separate tests cover no-op tracing, model off/errors, and human receipt failures. + +### Not covered + +- The runtime-adapter path (`reconcileViaRuntime`) has no decision spans. +- Execution outcome after the receipt is not traced as a decision. +- Receipt-writer service internals are not traced. +- Cross-process trace propagation to the receipt-writer is not implemented. +- Invalid proposals before typed evaluation, unstamped decisions, stale annotations, and replay recovery have no decision evaluation spans. + +`pkg/receipts/remote.go` uses a plain HTTP client without propagator injection or an OTel transport wrapper. +The global propagator alone does not inject headers into those requests. + ## Setup & Configuration ### Prerequisites @@ -82,7 +224,8 @@ Spans record events for key decisions and errors: ### Enable Tracing -Traces are **automatically enabled** when OpenTelemetry SDK is initialized. No configuration needed. +The operator exports traces when `OTEL_EXPORTER_OTLP_ENDPOINT` configures its global OpenTelemetry provider. +Without that endpoint, the default tracer is a no-op. See [Decision tracing](#decision-tracing) for current operator configuration. ### Export to Loki diff --git a/docs/approvals.md b/docs/approvals.md index f320450..2cd6bfb 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -2,6 +2,8 @@ Direct action path only. Approvals need receipts on for the signed dataset. +[Decision tracing](TRACING.md#decision-tracing) covers human approve, reject, and edit without exposing approver names or reasons. + When the operator holds an action in `PendingApproval`, a human decides it. Each decision is signed as a receipt and stored as one labelled example. This is layer 3 of the [decision architecture](architecture/decision-architecture.md). diff --git a/docs/architecture/decision-architecture.md b/docs/architecture/decision-architecture.md index 9f4cf2a..bde26a7 100644 --- a/docs/architecture/decision-architecture.md +++ b/docs/architecture/decision-architecture.md @@ -23,7 +23,7 @@ Rule count explodes, edge cases never end. Policy documents are prose. So model ## Receipt must record for every model decision model id and version, input hash, option set and order, per-option probabilities, threshold used, outcome, layer that decided. Replay with same inputs must reproduce the score (pin version, temperature 0, fixed seed). Air-gap: model runs in cluster (self-hosted small model). No hosted scoring API in the default path. -## Today vs target (verified in code 2026-10-01) +## Today vs target (verified in code 2026-10-02) - Threshold evaluator moved from pkg/opa to pkg/rules/threshold. Still hardcoded Go thresholds. pkg/opa is a deprecated alias package. - go.mod now has OPA (v1.19.1). pkg/rules/engine embeds it and evaluates the @@ -38,6 +38,10 @@ model id and version, input hash, option set and order, per-option probabilities `model` block. Execution outcome and the runtime-adapter path are not receipted. - Layers combine in pkg/decision.Decide. Strictest wins. +- P7 decision tracing covers direct proposals and stamped human decisions. + Evaluation spans join successful receipt appends through sequence and entry hash. + Runtime adapters, execution outcomes, writer internals, and writer trace propagation remain untraced. + See [decision tracing](../TRACING.md#decision-tracing). - Human approval (layer 3) works on the direct path. Decisions are annotations; the webhook stamps the approver. Each decision gets a signed layer "human" receipt and one example in approvals.jsonl. Approve and edit @@ -72,7 +76,9 @@ model id and version, input hash, option set and order, per-option probabilities eval with a baseline, deterministic stdlib trainer. Not done: training on real labels, the gate in [decision model](decision-model.md), the LLM judge as a second signal. See [decision model](decision-model.md). -6. Phase B only after shadow results and calibration are documented. +6. P7 trace coverage for direct decisions. Shipped: layer spans, receipt joins, privacy tests, and `make trace-coverage`. + Runtime and cross-process gaps remain. See [decision tracing](../TRACING.md#not-covered). +7. Phase B only after shadow results and calibration are documented. ## Candidate model types (decide by spike, not now) Small fine-tuned classifier on structured features (cheap, fast, auditable features). Small local LLM judge with typed options (Jev-style, AgentJev-0.6B is Apache-2.0). Start with the classifier. LLM judge as second signal. Both escalate-only at first. diff --git a/docs/architecture/decision-model.md b/docs/architecture/decision-model.md index c79fc70..d51bf27 100644 --- a/docs/architecture/decision-model.md +++ b/docs/architecture/decision-model.md @@ -7,6 +7,8 @@ scenarios. Read [Honest status](#honest-status) before quoting any number. See the [decision architecture ADR](decision-architecture.md) for why the model sits between invariants and humans. +[Decision tracing](../TRACING.md#decision-tracing) records model identifier, version, and integer risk only when a loaded model runs. + ## What it is A logistic regression over a small, fixed feature vector. It returns the diff --git a/docs/receipts.md b/docs/receipts.md index 99bdd72..792c92c 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -2,6 +2,8 @@ Opt-in. Off by default. Direct action path only. +[Decision tracing](TRACING.md#decision-tracing) joins evaluation spans to signed receipts by sequence and full entry hash. + When receipts are on, the operator writes a signed receipt for every decision on the legacy direct action path, before it executes the action. The receipt uses the AgentGate receipt format (`github.com/Clawdlinux/agentgate/pkg/receiptspec`), From 5074561633edcbf678f7635db8cf9ce19643ba1a Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 02:48:39 +0530 Subject: [PATCH 25/54] fix(controller): stop status-update hot loop and raw MCP value logs Status-only updates re-triggered Reconcile, which re-proposed and re-ran the direct-path action in a tight loop (1638 receipts in one demo run). Filter on generation, annotation, label, and deletion changes. Terminal paths already return without requeue, so nothing relied on the echo. MCP status, proposal, and execution values can hold personal data or credentials. Log field counts only. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- .../controller/agentworkload_controller.go | 26 +++++++-- .../controller/workload_event_filter_test.go | 57 +++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 internal/controller/workload_event_filter_test.go diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index 9fbd14a..a1d61b3 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -35,9 +35,12 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" "github.com/Clawdlinux/agentic-operator-core/pkg/decision" @@ -493,7 +496,8 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } - log.Info("Got status from MCP", "status", status) + // MCP replies can carry personal data or credentials. Never log values. + log.Info("Got status from MCP", "fields", len(status)) // Extract cluster health from status // Default to 75 if not provided by MCP, but log a warning @@ -531,7 +535,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } - log.Info("Proposed action from MCP", "proposal", proposal) + log.Info("Proposed action from MCP", "fields", len(proposal)) // Step 4: Evaluate action safety using OPA now := metav1.Now() @@ -654,7 +658,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques prunedProposed := pruneActions(workload.Status.ProposedActions, maxActionsInStatus) workload.Status.ProposedActions = prunedProposed } else { - log.Info("Action executed successfully", "action", action.Name, "result", execution) + log.Info("Action executed successfully", "action", action.Name, "resultFields", len(execution)) action.Approved = boolPtr(true) workload.Status.ExecutedActions = append(workload.Status.ExecutedActions, action) prunedExecuted := pruneActions(workload.Status.ExecutedActions, maxActionsInStatus) @@ -1133,11 +1137,25 @@ func (r *AgentWorkloadReconciler) SetupWithManager(mgr ctrl.Manager) error { } return ctrl.NewControllerManagedBy(mgr). - For(&agenticv1alpha1.AgentWorkload{}). + For(&agenticv1alpha1.AgentWorkload{}, builder.WithPredicates(workloadEventFilter())). Named("agentworkload"). Complete(r) } +// workloadEventFilter drops status-only updates. Without it every status write +// re-triggers Reconcile, which re-proposes and re-executes the direct-path +// action in a hot loop instead of on the RequeueAfter interval. +func workloadEventFilter() predicate.Predicate { + return predicate.Or( + predicate.GenerationChangedPredicate{}, + predicate.AnnotationChangedPredicate{}, + predicate.LabelChangedPredicate{}, + predicate.Funcs{UpdateFunc: func(e event.UpdateEvent) bool { + return e.ObjectNew != nil && !e.ObjectNew.GetDeletionTimestamp().IsZero() + }}, + ) +} + func (r *AgentWorkloadReconciler) updateWorkloadCostAnnotation(ctx context.Context, workload *agenticv1alpha1.AgentWorkload) error { costToday, err := r.CostReporter.WorkloadCostToday(ctx, workload.Name, workload.Namespace) if err != nil { diff --git a/internal/controller/workload_event_filter_test.go b/internal/controller/workload_event_filter_test.go new file mode 100644 index 0000000..fe134e7 --- /dev/null +++ b/internal/controller/workload_event_filter_test.go @@ -0,0 +1,57 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" +) + +func TestWorkloadEventFilter(t *testing.T) { + base := func() *agenticv1alpha1.AgentWorkload { + return &agenticv1alpha1.AgentWorkload{ObjectMeta: metav1.ObjectMeta{ + Name: "w", Namespace: "ns", Generation: 1, ResourceVersion: "1", + Annotations: map[string]string{"a": "1"}, Labels: map[string]string{"l": "1"}, + }} + } + now := metav1.Now() + tests := []struct { + name string + mutate func(*agenticv1alpha1.AgentWorkload) + want bool + }{ + {"status only", func(w *agenticv1alpha1.AgentWorkload) { + w.ResourceVersion = "2" + w.Status.Phase = "Completed" + }, false}, + {"finalizer only", func(w *agenticv1alpha1.AgentWorkload) { + w.Finalizers = []string{AgentWorkloadFinalizer} + }, false}, + {"spec change", func(w *agenticv1alpha1.AgentWorkload) { w.Generation = 2 }, true}, + {"approval annotation", func(w *agenticv1alpha1.AgentWorkload) { + w.Annotations["clawdlinux.org/approval-decision"] = "approve" + }, true}, + {"label change", func(w *agenticv1alpha1.AgentWorkload) { w.Labels["l"] = "2" }, true}, + {"deletion", func(w *agenticv1alpha1.AgentWorkload) { w.DeletionTimestamp = &now }, true}, + } + f := workloadEventFilter() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + old, upd := base(), base() + tt.mutate(upd) + if got := f.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: upd}); got != tt.want { + t.Fatalf("Update() = %v, want %v", got, tt.want) + } + }) + } + if !f.Create(event.CreateEvent{Object: base()}) { + t.Fatal("Create must reconcile") + } +} From 009f08f0a5086da333740094102c5bc87fea13d3 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 02:54:45 +0530 Subject: [PATCH 26/54] feat(demo): add claims demo on a fresh kind cluster scripts/demo-claims.sh proves the decision-architecture claims with no API keys. A deterministic mock MCP server plays the agent. Eight scenarios print the exact command and a PASS or FAIL line, and the script exits nonzero on any failure. The repo Dockerfile cannot build while go.mod carries the local receiptspec replace, so the demo builds linux binaries on the host and packages them in a demo-only image loaded with kind load. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- scripts/demo-claims.sh | 641 +++++++++++++++++++++++++++++++++++ scripts/demo/Dockerfile.demo | 11 + scripts/demo/claims_check.py | 134 ++++++++ scripts/demo/mockmcp/main.go | 163 +++++++++ 4 files changed, 949 insertions(+) create mode 100755 scripts/demo-claims.sh create mode 100644 scripts/demo/Dockerfile.demo create mode 100644 scripts/demo/claims_check.py create mode 100644 scripts/demo/mockmcp/main.go diff --git a/scripts/demo-claims.sh b/scripts/demo-claims.sh new file mode 100755 index 0000000..f028854 --- /dev/null +++ b/scripts/demo-claims.sh @@ -0,0 +1,641 @@ +#!/usr/bin/env bash +# demo-claims.sh: prove the decision-architecture claims on a fresh kind cluster. +# +# No API keys. No external model provider. A deterministic mock MCP server +# (scripts/demo/mockmcp) plays the agent. Each scenario prints its heading, +# the exact command, and a PASS or FAIL line. The script exits nonzero if any +# proof fails. +# +# Build note: go.mod carries a dev-only replace for receiptspec +# (../agentgate-receiptspec, see BLOCKERS.md), so the repo Dockerfile cannot +# build. This script builds linux binaries on the host and packages them with +# scripts/demo/Dockerfile.demo, then loads that image into kind. That detour +# goes away once agentgate v0.1.4 is tagged and the replace is dropped. +# +# Prerequisites: docker (running), kind, kubectl, helm, go, python3, curl, +# openssl. Optional: ../agentgate-receiptspec for the AgentGate verifier. +# +# Usage: scripts/demo-claims.sh [--keep] [--skip-build] [--cluster-name NAME] +# [--dry-run] [--help] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CHECK="${SCRIPT_DIR}/demo/claims_check.py" + +CLUSTER_NAME="clawdlinux-claims" +KEEP=false +SKIP_BUILD=false +DRY_RUN=false +NS="agentic-system" +DEMO_NS="claims-demo" +RELEASE="claims" +IMAGE="clawdlinux-claims-demo:dev" +BUILD_DIR="${DEMO_BUILD_DIR:-/tmp/demo-claims-build}" +CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.17.2}" +HELM_TIMEOUT="${HELM_TIMEOUT:-240s}" +PF_PORT="${PF_PORT:-18080}" +MOCK_HOST="mock-mcp.${DEMO_NS}.svc" +MOCK_URL="https://${MOCK_HOST}" +RECEIPTSPEC_DIR="${RECEIPTSPEC_DIR:-${REPO_ROOT}/../agentgate-receiptspec}" +MODEL_JSON="${REPO_ROOT}/tools/decision-train/out/model.json" +SYNTH_APPROVALS="${REPO_ROOT}/tools/decision-train/out/approvals.jsonl" +FAKE_AWS_KEY="AKIAIOSFODNN7EXAMPLE" +TEST_AADHAAR="234123412346" + +usage() { + cat <&2; exit 2; }; CLUSTER_NAME="$2"; shift ;; + --dry-run) DRY_RUN=true ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown flag: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +BIN="${BUILD_DIR}/bin" +PF_PID="" + +say() { printf '%s\n' "$*"; } +heading() { printf '\n==== %s ====\n' "$*"; } +note() { printf ' %s\n' "$*"; } +# run prints the exact command, then runs it. +run() { printf '$ %s\n' "$*"; "$@"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +check() { + local desc="$1"; shift + if "$@"; then note "ok: ${desc}"; else note "NOT OK: ${desc}"; return 1; fi +} + +kc() { kubectl --context "kind-${CLUSTER_NAME}" "$@"; } +agentctl() { "${BIN}/agentctl" --kubeconfig "${KUBECONFIG_FILE}" "$@"; } + +plan() { + cat <"${RESULTS}" + +cleanup() { + local rc=$? + [[ -n "${PF_PID}" ]] && kill "${PF_PID}" >/dev/null 2>&1 || true + if [[ "${KEEP}" == true ]]; then + say "Keeping cluster ${CLUSTER_NAME} and image ${IMAGE}. Work dir: ${WORK}" + else + kind delete cluster --name "${CLUSTER_NAME}" --kubeconfig "${KUBECONFIG_FILE}" >/dev/null 2>&1 || true + docker image rm "${IMAGE}" >/dev/null 2>&1 || true + say "Deleted kind cluster ${CLUSTER_NAME} and image ${IMAGE}. Work dir kept: ${WORK}" + fi + exit "${rc}" +} +trap cleanup EXIT + +for c in docker kind kubectl helm go python3 curl openssl; do + command -v "$c" >/dev/null 2>&1 || die "missing prerequisite: $c" +done +docker info >/dev/null 2>&1 || die "docker daemon is not running" + +# ---------------------------------------------------------------- build ---- +build() { + heading "Build (host binaries, demo image)" + local arch + arch="$(go env GOARCH)" + mkdir -p "${BIN}" "${BUILD_DIR}/ctx" + ( + cd "${REPO_ROOT}" + run go build -o "${BIN}/agentctl" ./cmd/agentctl + run env GOOS=linux GOARCH="${arch}" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "${BUILD_DIR}/ctx/manager" ./cmd + run env GOOS=linux GOARCH="${arch}" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "${BUILD_DIR}/ctx/receipt-writer" ./cmd/receipt-writer + run env GOOS=linux GOARCH="${arch}" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "${BUILD_DIR}/ctx/mockmcp" ./scripts/demo/mockmcp + ) + rm -f "${BIN}/agentgate-verify" + if [[ -d "${RECEIPTSPEC_DIR}/cmd/agentgate-verify" ]]; then + (cd "${RECEIPTSPEC_DIR}" && run go build -o "${BIN}/agentgate-verify" ./cmd/agentgate-verify) + else + note "AgentGate verifier source not found at ${RECEIPTSPEC_DIR}. Cross-check will be skipped." + fi + + # Throwaway CA and serving cert for the mock MCP server. Demo only. + local ctx="${BUILD_DIR}/ctx" + openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj "/CN=claims-demo-ca" \ + -keyout "${BUILD_DIR}/ca.key" -out "${ctx}/ca.crt" >/dev/null 2>&1 + openssl req -newkey rsa:2048 -nodes -subj "/CN=${MOCK_HOST}" \ + -keyout "${ctx}/tls.key" -out "${BUILD_DIR}/tls.csr" >/dev/null 2>&1 + printf 'subjectAltName=DNS:%s,DNS:%s.cluster.local\nextendedKeyUsage=serverAuth\n' "${MOCK_HOST}" "${MOCK_HOST}" >"${BUILD_DIR}/san.ext" + openssl x509 -req -in "${BUILD_DIR}/tls.csr" -CA "${ctx}/ca.crt" -CAkey "${BUILD_DIR}/ca.key" \ + -CAcreateserial -days 2 -extfile "${BUILD_DIR}/san.ext" -out "${ctx}/tls.crt" >/dev/null 2>&1 + run docker build -q -f "${SCRIPT_DIR}/demo/Dockerfile.demo" -t "${IMAGE}" "${ctx}" +} + +if [[ "${SKIP_BUILD}" == true ]]; then + heading "Build skipped (--skip-build)" + [[ -x "${BIN}/agentctl" ]] || die "no agentctl in ${BIN}; run without --skip-build" + docker image inspect "${IMAGE}" >/dev/null 2>&1 || die "image ${IMAGE} missing; run without --skip-build" +else + build +fi + +# --------------------------------------------------------------- cluster --- +heading "Cluster ${CLUSTER_NAME}" +if kind get clusters 2>/dev/null | grep -Fxq "${CLUSTER_NAME}"; then + note "deleting existing cluster ${CLUSTER_NAME} for a fresh start" + kind delete cluster --name "${CLUSTER_NAME}" >/dev/null +fi +run kind create cluster --name "${CLUSTER_NAME}" --kubeconfig "${KUBECONFIG_FILE}" --wait 120s +export KUBECONFIG="${KUBECONFIG_FILE}" +run kind load docker-image "${IMAGE}" --name "${CLUSTER_NAME}" + +heading "Install cert-manager ${CERT_MANAGER_VERSION}" +run helm upgrade --install cert-manager oci://quay.io/jetstack/charts/cert-manager \ + --namespace cert-manager --create-namespace --version "${CERT_MANAGER_VERSION}" \ + --set crds.enabled=true --timeout "${HELM_TIMEOUT}" --wait >/dev/null + +heading "Install Clawdlinux (receipts required, AgentWorkload webhook on)" +kc create namespace "${NS}" >/dev/null +kc create namespace "${DEMO_NS}" >/dev/null +kc apply -f "${REPO_ROOT}/config/crd/bases" >/dev/null +kc wait --for=condition=Established crd/agentworkloads.agentic.clawdlinux.org --timeout=60s >/dev/null +kc -n "${NS}" create secret generic receipt-signing-key \ + --from-literal=signing-key="$(openssl rand -hex 32)" >/dev/null +note "signing key Secret created (value not printed)" +image_repo="${IMAGE%%:*}" +image_tag="${IMAGE##*:}" +run helm upgrade --install "${RELEASE}" "${REPO_ROOT}/charts" --namespace "${NS}" \ + --set-string license.key=dev-license \ + --set argo.enabled=false --set browserless.enabled=false --set litellm.enabled=false \ + --set minio.enabled=false --set postgresql.enabled=false \ + --set clawdlinuxObservability.enabled=false --set ciliumPolicy.enabled=false \ + --set webUI.enabled=false \ + --set global.runtimeSandbox.enabled=false \ + --set agenticOperator.webhook.enabled=true \ + --set agenticOperator.webhook.agentWorkloadEnabled=true \ + --set-string agentic-operator.env.ENABLE_WEBHOOKS=true \ + --set-string agentic-operator.env.SSL_CERT_FILE=/demo/ca.crt \ + --set agentic-operator.leaderElection=false \ + --set agentic-operator.image.repository="${image_repo}" \ + --set agentic-operator.image.tag="${image_tag}" \ + --set agentic-operator.image.pullPolicy=IfNotPresent \ + --set global.receipts.enabled=true \ + --set global.receipts.required=true \ + --set global.receipts.signingKey.existingSecret=receipt-signing-key \ + --set global.receipts.image.repository="${image_repo}" \ + --set global.receipts.image.tag="${image_tag}" \ + --set global.receipts.image.pullPolicy=IfNotPresent \ + --timeout "${HELM_TIMEOUT}" --wait >/dev/null +OPERATOR_SEL="app.kubernetes.io/name=agentic-operator,app.kubernetes.io/component=operator" +kc -n "${NS}" wait --for=condition=Ready "certificate/${RELEASE}-agentic-operator-webhook-cert" --timeout="${HELM_TIMEOUT}" >/dev/null +kc -n "${NS}" rollout status statefulset/"${RELEASE}-receipt-writer" --timeout="${HELM_TIMEOUT}" >/dev/null +kc -n "${NS}" wait --for=condition=Ready pod -l "${OPERATOR_SEL}" --timeout="${HELM_TIMEOUT}" >/dev/null +kc -n "${NS}" get secret "${RELEASE}-receipt-writer-token" -o jsonpath='{.data.token}' | base64 -d >"${TOKEN_FILE}" +chmod 600 "${TOKEN_FILE}" + +heading "Mock MCP server at ${MOCK_URL}" +kc apply -f - >/dev/null </dev/null +note "mock MCP ready (TLS, CA baked into the demo image, operator trusts it via SSL_CERT_FILE)" + +# --------------------------------------------------------------- helpers --- +start_port_forward() { + [[ -n "${PF_PID}" ]] && kill "${PF_PID}" >/dev/null 2>&1 || true + kc -n "${NS}" port-forward "svc/${RELEASE}-receipt-writer" "${PF_PORT}:8080" >"${WORK}/port-forward.log" 2>&1 & + PF_PID=$! + local i + for i in $(seq 1 30); do + curl -fsS "http://127.0.0.1:${PF_PORT}/healthz" >/dev/null 2>&1 && return 0 + sleep 1 + done + die "receipt-writer port-forward did not come up" +} +start_port_forward +WRITER="http://127.0.0.1:${PF_PORT}" + +# apply_workload NAME SCENARIO MODE [extra spec yaml, indented 2] +apply_workload() { + local name="$1" scenario="$2" mode="$3" extra="${4:-}" + local manifest="${WORK}/${name}.yaml" + cat >"${manifest}" <&1)"; then say "${out}"; return 0; fi + case "${out}" in + *"failed calling webhook"*|*"connection refused"*|*"no endpoints"*|*"x509"*) sleep 3 ;; + *) say "${out}"; return 1 ;; + esac + done + say "${out}" + return 1 +} + +wait_phase() { + local name="$1" want="$2" timeout="${3:-180}" phase="" end + end=$(( $(date +%s) + timeout )) + while (( $(date +%s) < end )); do + phase="$(kc -n "${DEMO_NS}" get agentworkload "${name}" -o jsonpath='{.status.phase}' 2>/dev/null || true)" + [[ "${phase}" == "${want}" ]] && { note "phase ${name}: ${phase}"; return 0; } + sleep 2 + done + note "phase ${name}: '${phase}', wanted ${want}" + return 1 +} + +delete_workload() { + kc -n "${DEMO_NS}" delete agentworkload "$1" --wait=true --timeout=60s >/dev/null 2>&1 || true +} + +mock_log() { + kc -n "${DEMO_NS}" logs deployment/mock-mcp >"${WORK}/mock.log" 2>/dev/null + printf '%s' "${WORK}/mock.log" +} + +calls() { python3 "${CHECK}" calls "$(mock_log)" "$1" "$2"; } + +operator_log_clean() { + local value="$1" log="${WORK}/operator.log" + kc -n "${NS}" logs -l "${OPERATOR_SEL}" --tail=-1 >"${log}" + ! grep -Fq "${value}" "${log}" +} + +export_receipts() { + rm -rf "$1" + run agentctl receipts export --writer "${WRITER}" --token-file "${TOKEN_FILE}" --out "$1" >/dev/null +} + +export_dataset() { + rm -rf "$1" + run agentctl dataset export --writer "${WRITER}" --token-file "${TOKEN_FILE}" --out "$1" >/dev/null +} + +condition_message() { + kc -n "${DEMO_NS}" get agentworkload "$1" -o jsonpath="{.status.conditions[?(@.type==\"$2\")].message}" +} + +eq() { [[ "$1" == "$2" ]]; } +contains() { [[ "$1" == *"$2"* ]]; } + +# scenario NUMBER TITLE FUNCTION runs the function in a subshell with errexit +# on, so one failed proof does not stop the others. +scenario() { + local num="$1" title="$2" fn="$3" rc + heading "Scenario ${num}: ${title}" + set +e + ( set -e; "${fn}" ) + rc=$? + set -e + # Remove this scenario's workloads even if a proof failed half way. + kc -n "${DEMO_NS}" delete agentworkloads --all --wait=true --timeout=90s >/dev/null 2>&1 || true + if [[ "${rc}" -eq 0 ]]; then + say "PASS ${num}: ${title}"; printf 'PASS %s: %s\n' "${num}" "${title}" >>"${RESULTS}" + else + say "FAIL ${num}: ${title}"; printf 'FAIL %s: %s\n' "${num}" "${title}" >>"${RESULTS}" + fi +} + +DPDP_INTENT=" declaredIntent: + purpose: \"refund decisions for KYC verified customers\" + decisionType: automated + allowedDataClasses: [aadhaar] + allowedDestinations: [\"${MOCK_HOST}\"] + policyPacks: [\"dpdp-in@v0.1.0\"]" + +# ------------------------------------------------------------- scenarios --- +s1() { + apply_workload s1-clean s1-clean strict " declaredIntent: + purpose: \"scale the web tier\" + decisionType: assisted + allowedDataClasses: [] + allowedDestinations: [\"${MOCK_HOST}\"]" + wait_phase s1-clean Completed + export_receipts "${WORK}/s1" + run python3 "${CHECK}" records "${WORK}/s1" s1-clean + check "exactly one allow receipt stored before the first execute_action" \ + python3 "${CHECK}" write-ahead "${WORK}/s1" s1-clean "$(mock_log)" s1-clean + delete_workload s1-clean +} + +s2() { + apply_workload s2-aadhaar s2-aadhaar strict " declaredIntent: + purpose: \"sync CRM records\" + decisionType: assisted + allowedDataClasses: [] + allowedDestinations: [\"crm.partner.example\"]" + wait_phase s2-aadhaar PolicyDenied + local msg + msg="$(condition_message s2-aadhaar PolicyDenied)" + note "PolicyDenied message: ${msg}" + check "condition names INV-03 (personal data to undeclared destination)" contains "${msg}" "INV-03" + export_receipts "${WORK}/s2" + run python3 "${CHECK}" records "${WORK}/s2" s2-aadhaar + check "deny receipt from the invariant layer, with the 0.99 and 100 claims recorded" \ + bash -c "python3 '${CHECK}' records '${WORK}/s2' s2-aadhaar | grep -q 'layer=invariant outcome=deny .*INV-03.* claimed_confidence=0.99 claimed_health=100'" + check "propose_action was called" test "$(calls s2-aadhaar propose_action)" -ge 1 + check "execute_action was never called" eq "$(calls s2-aadhaar execute_action)" 0 + check "operator log does not contain the Aadhaar number" operator_log_clean "${TEST_AADHAAR}" + delete_workload s2-aadhaar +} + +s3() { + apply_workload s3-credential s3-credential strict "" + wait_phase s3-credential PolicyDenied + local msg + msg="$(condition_message s3-credential PolicyDenied)" + note "PolicyDenied message: ${msg}" + check "condition names INV-01" contains "${msg}" "INV-01" + export_receipts "${WORK}/s3" + run python3 "${CHECK}" records "${WORK}/s3" s3-credential + check "deny receipt with INV-01" \ + bash -c "python3 '${CHECK}' records '${WORK}/s3' s3-credential | grep -q 'outcome=deny .*rules=\[INV-01[],]'" + check "execute_action was never called" eq "$(calls s3-credential execute_action)" 0 + check "operator log does not contain the key" operator_log_clean "${FAKE_AWS_KEY}" + delete_workload s3-credential +} + +s4() { + apply_workload s4-dpdp s4-dpdp permissive "${DPDP_INTENT}" + wait_phase s4-dpdp PendingApproval + local reason msg + reason="$(kc -n "${DEMO_NS}" get agentworkload s4-dpdp -o jsonpath='{.status.conditions[?(@.type=="ApprovalRequired")].reason}')" + msg="$(condition_message s4-dpdp ApprovalRequired)" + note "ApprovalRequired ${reason}: ${msg}" + check "pack approval finding holds the action in permissive mode" eq "${reason}" "PolicyPackApproval" + check "finding is dpdp-in@v0.1.0/DPDP-IN-04" contains "${msg}" "dpdp-in@v0.1.0/DPDP-IN-04" + export_receipts "${WORK}/s4" + run python3 "${CHECK}" records "${WORK}/s4" s4-dpdp + check "require_approval receipt from the rules layer" \ + bash -c "python3 '${CHECK}' records '${WORK}/s4' s4-dpdp | grep -q 'layer=rules outcome=require_approval'" + check "execute_action was never called" eq "$(calls s4-dpdp execute_action)" 0 + delete_workload s4-dpdp +} + +s5() { + local me stamp out pending + me="$(kc auth whoami -o jsonpath='{.status.userInfo.username}')" + note "kubectl identity: ${me}" + + say "-- 5a: forged stamp, then a real approve" + apply_workload s5-approve s5-approve permissive "${DPDP_INTENT}" + wait_phase s5-approve PendingApproval + pending="$(kc -n "${DEMO_NS}" get agentworkload s5-approve -o jsonpath='{.status.pendingApproval.id}')" + note "pending id: ${pending}" + printf '$ kubectl annotate agentworkload s5-approve clawdlinux.org/approval-decision=approve %s\n' \ + "'clawdlinux.org/approval-by={\"username\":\"mallory\",\"groups_sha256\":\"x\"}'" + if out="$(kc -n "${DEMO_NS}" annotate agentworkload s5-approve clawdlinux.org/approval-decision=approve \ + 'clawdlinux.org/approval-by={"username":"mallory","groups_sha256":"x"}' 2>&1)"; then + say "${out}"; note "NOT OK: the forged stamp was accepted"; return 1 + fi + say "${out}" + check "webhook rejected the client-forged approval-by" contains "$(printf '%s' "${out}" | tr '[:upper:]' '[:lower:]')" "forbidden" + check "no decision recorded after the forged attempt" \ + eq "$(kc -n "${DEMO_NS}" get agentworkload s5-approve -o jsonpath='{.metadata.annotations.clawdlinux\.org/approval-decision}')" "" + + run agentctl -n "${DEMO_NS}" approve s5-approve --reason "checked the refund" + wait_phase s5-approve Completed + stamp="$(kc -n "${DEMO_NS}" get agentworkload s5-approve -o jsonpath='{.metadata.annotations.clawdlinux\.org/approval-by}')" + note "approval-by: ${stamp}" + check "webhook stamped the real kubectl identity" contains "${stamp}" "\"username\":\"${me}\"" + check "execute_action called once after approval" eq "$(calls s5-approve execute_action)" 1 + export_dataset "${WORK}/s5" + run python3 "${CHECK}" records "${WORK}/s5" s5-approve + check "layer human receipt with the approver as principal" \ + bash -c "python3 '${CHECK}' records '${WORK}/s5' s5-approve | grep -q 'layer=human outcome=approved policy_decision=allow principal=${me} '" + run python3 "${CHECK}" approvals "${WORK}/s5/approvals.jsonl" s5-approve + check "one approvals.jsonl example" eq "$(python3 "${CHECK}" approvals "${WORK}/s5/approvals.jsonl" s5-approve | tail -1)" 1 + note "example line:" + grep -F '"s5-approve"' "${WORK}/s5/approvals.jsonl" | head -1 | sed 's/^/ | /' + delete_workload s5-approve + + say "-- 5b: a human edit cannot bypass an invariant" + apply_workload s5-edit s5-edit strict "${DPDP_INTENT}" + wait_phase s5-edit PendingApproval + cat >"${WORK}/edit.json" </dev/null + local end + end=$(( $(date +%s) + 120 )) + until kc -n "${NS}" logs -l "${OPERATOR_SEL}" --tail=-1 2>/dev/null | grep -q "Decision model loaded"; do + (( $(date +%s) < end )) || { note "operator never logged 'Decision model loaded'"; return 1; } + sleep 2 + done + kc -n "${NS}" logs -l "${OPERATOR_SEL}" --tail=-1 | grep "Decision model loaded" | tail -1 | cut -c1-400 + apply_workload s7-shadow s7-shadow strict " declaredIntent: + purpose: \"scale the web tier\" + decisionType: assisted + allowedDataClasses: [] + allowedDestinations: [\"${MOCK_HOST}\"]" + wait_phase s7-shadow Completed + export_dataset "${WORK}/s7" + run python3 "${CHECK}" model "${WORK}/s7" s7-shadow "${MODEL_JSON}" + check "shadow model did not change the outcome; action executed" test "$(calls s7-shadow execute_action)" -ge 1 + delete_workload s7-shadow + + local out + run agentctl decision eval --dataset "${WORK}/s7" --model "${MODEL_JSON}" \ + --trust-root "${WORK}/pinned-trust.json" --baseline | tee "${WORK}/eval-export.txt" + out="$(cat "${WORK}/eval-export.txt")" + check "eval on the exported dataset prints a WARNING" contains "${out}" "WARNING" + run agentctl decision eval --dataset "${SYNTH_APPROVALS}" --model "${MODEL_JSON}" \ + --allow-unverified --baseline >"${WORK}/eval-synthetic.txt" + sed -n '1,8p' "${WORK}/eval-synthetic.txt" + out="$(cat "${WORK}/eval-synthetic.txt")" + check "eval on the shipped training set warns the data is synthetic" contains "${out}" "are synthetic" +} + +s8() { + note "No collector here. The Go coverage test asserts the controller emits decision spans." + (cd "${REPO_ROOT}" && run make trace-coverage) | tee "${WORK}/trace.txt" | grep -vE '^=== RUN|^--- PASS|^PASS$|^ok ' || true + grep -q '^ok ' "${WORK}/trace.txt" +} + +T0=$(date +%s) +scenario 1 "clean action to a declared destination, receipt before execute" s1 +scenario 2 "confident agent, Aadhaar to an undeclared destination" s2 +scenario 3 "credential in params, INV-01" s3 +scenario 4 "dpdp-in pack escalation in permissive mode" s4 +scenario 5 "human approve with a stamped identity, edit cannot bypass invariants" s5 +scenario 6 "offline verification and tamper detection" s6 +scenario 7 "decision model in shadow mode" s7 +scenario 8 "decision tracing coverage (Go test, no collector)" s8 + +heading "Results ($(( $(date +%s) - T0 ))s of scenarios)" +cat "${RESULTS}" +cat <<'EOF' + +Proven by this demo, on a kind cluster: +- invariants deny credentials, undeclared egress, and personal data to an + undeclared host, whatever the agent claims +- a signed receipt is stored before the action runs, and denied actions are + never executed +- a policy pack approval finding holds an action even in permissive mode +- the webhook stamps the approver and rejects a forged stamp +- a human edit is re-checked and cannot bypass an invariant +- receipts verify offline, tamper is detected, and AgentGate verifies the + same chain +- the decision model scores in shadow without changing the outcome +- decision spans are covered by a Go test + +NOT proven by this demo: +- the runtime-adapter path (spec.orchestration): no invariants, packs, or + receipts there yet +- Argo approval gates +- execution outcome receipts (only the pre-execution decision is signed) +- production validity of the decision model (trained on synthetic DRAFT data) +- packet-level egress enforcement (kind's CNI does not enforce NetworkPolicy; + INV-02 checks the configured MCP endpoint only) +- caller identity observation (observed.callerIdentity is empty) +EOF + +if grep -q '^FAIL' "${RESULTS}"; then + say "OVERALL: FAIL" + exit 1 +fi +say "OVERALL: PASS" diff --git a/scripts/demo/Dockerfile.demo b/scripts/demo/Dockerfile.demo new file mode 100644 index 0000000..c3a8021 --- /dev/null +++ b/scripts/demo/Dockerfile.demo @@ -0,0 +1,11 @@ +# Demo-only image for scripts/demo-claims.sh. Not a release artifact. +# The repo Dockerfile cannot build while go.mod carries the local receiptspec +# replace (see BLOCKERS.md), so the script builds linux binaries on the host +# and copies them in. Drop this file once agentgate v0.1.4 is tagged. +FROM gcr.io/distroless/static:nonroot +COPY --chmod=0555 manager receipt-writer mockmcp / +# Throwaway CA and serving cert for the mock MCP server, generated per build. +# 0555, not 0444: --chmod also sets the new /demo dir mode, which needs x. +COPY --chmod=0555 ca.crt tls.crt tls.key /demo/ +USER nonroot:nonroot +ENTRYPOINT ["/receipt-writer"] diff --git a/scripts/demo/claims_check.py b/scripts/demo/claims_check.py new file mode 100644 index 0000000..75c72d8 --- /dev/null +++ b/scripts/demo/claims_check.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Read-only checks for scripts/demo-claims.sh. Stdlib only. Demo only.""" +import json +import os +import sys + + +def jsonl(path): + out = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line.startswith("{"): + continue + try: + out.append(json.loads(line)) + except ValueError: + continue + return out + + +def records(export_dir, workload): + """Decision records for a workload, joined to their receipt.""" + receipts = {r["seq"]: r for r in jsonl(os.path.join(export_dir, "receipts.jsonl")) + if r.get("type") == "receipt"} + rows = [] + for line in jsonl(os.path.join(export_dir, "records.jsonl")): + rec = line["record"] + if rec["workload"]["name"] != workload: + continue + rows.append((line["seq"], rec, receipts.get(line["seq"], {}))) + return sorted(rows, key=lambda r: r[0]) + + +def cmd_records(export_dir, workload): + for seq, rec, rc in records(export_dir, workload): + ids = ",".join(r["rule_id"] for r in rec.get("reasons") or []) + print(f"seq={seq} layer={rec['layer']} outcome={rec['outcome']} " + f"policy_decision={rc.get('policy_decision')} principal={rc.get('human_principal')} " + f"ts_ns={rc.get('timestamp_unix_ns')} rules=[{ids}] " + f"claimed_confidence={rec.get('claimed', {}).get('confidence')} " + f"claimed_health={rec.get('claimed', {}).get('cluster_health')}") + + +def tool_calls(log, scenario, tool): + return [l for l in jsonl(log) if l.get("scenario") == scenario and l.get("tool") == tool] + + +def cmd_calls(log, scenario, tool): + print(len(tool_calls(log, scenario, tool))) + + +def cmd_write_ahead(export_dir, workload, log, scenario): + """Exactly one receipt for the workload precedes the first execute.""" + execs = tool_calls(log, scenario, "execute_action") + if not execs: + print("FAIL no execute_action call") + return 1 + first = min(e["unix_ns"] for e in execs) + before = [(s, rec, rc) for s, rec, rc in records(export_dir, workload) + if int(rc.get("timestamp_unix_ns", 0)) < first] + for s, rec, rc in before: + print(f"receipt seq={s} outcome={rec['outcome']} ts_ns={rc['timestamp_unix_ns']}") + print(f"first execute_action unix_ns={first}") + if len(before) != 1 or before[0][1]["outcome"] != "allow": + print(f"FAIL want exactly 1 allow receipt before execute, got {len(before)}") + return 1 + print(f"receipt precedes execute by {(first - int(before[0][2]['timestamp_unix_ns'])) / 1e6:.1f} ms") + return 0 + + +def cmd_model(export_dir, workload, model_path): + with open(model_path, encoding="utf-8") as f: + artifact = json.load(f) + rows = [r for r in records(export_dir, workload) if r[1].get("model")] + if not rows: + print("FAIL no model block") + return 1 + seq, rec, _ = rows[0] + m = rec["model"] + print(json.dumps({"seq": seq, "record_outcome": rec["outcome"], "model": { + k: m.get(k) for k in ("mode", "id", "version", "artifact_sha256", "risk_micro", + "threshold_micro", "option_set", "option_micro", + "base_outcome", "outcome")}}, indent=2)) + ok = (m["mode"] == "shadow" and m["artifact_sha256"] == artifact["artifact_sha256"] + and isinstance(m["risk_micro"], int) and m["outcome"] == m["base_outcome"] + and rec["outcome"] == "allow" and all(isinstance(x, int) for x in m["option_micro"])) + print("model block checks:", "ok" if ok else "FAIL") + return 0 if ok else 1 + + +def cmd_approvals(path, workload): + rows = [r for r in jsonl(path) if r.get("workload", {}).get("name") == workload] + for r in rows: + print(f"example_id={r['example_id']} label={r['label']} receipt_seq={r['receipt_seq']} " + f"capture_mode={r.get('capture_mode')}") + print(len(rows)) + + +def cmd_flip(path, needle): + """Flip one bit of the byte right after the first needle.""" + with open(path, "rb") as f: + data = bytearray(f.read()) + i = data.find(needle.encode()) + if i < 0: + print(f"needle {needle!r} not found") + return 1 + i += len(needle) + old = data[i] + data[i] ^= 0x01 + with open(path, "wb") as f: + f.write(data) + print(f"flipped byte {i}: {chr(old)!r} -> {chr(data[i])!r}") + return 0 + + +def main(argv): + cmds = { + "records": (cmd_records, 2), + "calls": (cmd_calls, 3), + "write-ahead": (cmd_write_ahead, 4), + "model": (cmd_model, 3), + "approvals": (cmd_approvals, 2), + "flip": (cmd_flip, 2), + } + if len(argv) < 2 or argv[1] not in cmds or len(argv) - 2 != cmds[argv[1]][1]: + print("usage: claims_check.py " + "|".join(cmds) + " args...", file=sys.stderr) + return 2 + rc = cmds[argv[1]][0](*argv[2:]) + return rc or 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/demo/mockmcp/main.go b/scripts/demo/mockmcp/main.go new file mode 100644 index 0000000..ae9349c --- /dev/null +++ b/scripts/demo/mockmcp/main.go @@ -0,0 +1,163 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +// Command mockmcp is a deterministic MCP server for scripts/demo-claims.sh. +// Demo only. It picks a canned proposal from a "scenario=" token in the +// objective and logs one JSON line per tool call to stdout. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "regexp" + "sync" + "time" +) + +// Checksum-valid test values. Not real identifiers or keys. +const ( + testAadhaar = "234123412346" + testAWSKeyID = "AKIAIOSFODNN7EXAMPLE" + defaultReason = "demo" +) + +var scenarioRE = regexp.MustCompile(`scenario=([a-z0-9-]+)`) + +type toolRequest struct { + Tool string `json:"tool"` + Params map[string]interface{} `json:"params"` +} + +type toolResponse struct { + Tool string `json:"tool"` + Result map[string]interface{} `json:"result,omitempty"` + Error string `json:"error,omitempty"` + Success bool `json:"success"` +} + +type logLine struct { + TS string `json:"ts"` + UnixNS int64 `json:"unix_ns"` + Tool string `json:"tool"` + Scenario string `json:"scenario,omitempty"` + Action string `json:"action,omitempty"` +} + +var ( + logMu sync.Mutex + stdout = json.NewEncoder(os.Stdout) +) + +func emit(tool, scenario, action string) { + now := time.Now().UTC() + logMu.Lock() + defer logMu.Unlock() + _ = stdout.Encode(logLine{TS: now.Format(time.RFC3339Nano), UnixNS: now.UnixNano(), Tool: tool, Scenario: scenario, Action: action}) +} + +// proposal returns the canned propose_action reply for a scenario. +func proposal(scenario string) map[string]interface{} { + p := map[string]interface{}{ + "action": "scale_replicas", + "description": "scale web to 3 replicas", + "confidence": "0.97", + "params": map[string]interface{}{"scenario": scenario, "replicas": 3}, + } + switch scenario { + case "s2-aadhaar": + p["action"] = "sync_customer_record" + p["description"] = "copy a customer record to the partner CRM" + p["confidence"] = "0.99" + p["params"] = map[string]interface{}{"scenario": scenario, "customer_id": testAadhaar} + case "s3-credential": + p["action"] = "configure_backup" + p["description"] = "configure backup upload" + p["params"] = map[string]interface{}{"scenario": scenario, "aws_access_key_id": testAWSKeyID} + case "s4-dpdp", "s5-approve", "s5-edit": + p["action"] = "decide_kyc_refund" + p["description"] = "refund decision for a verified customer" + p["params"] = map[string]interface{}{"scenario": scenario, "customer_id": testAadhaar} + } + return p +} + +func scenarioOf(req toolRequest) string { + if obj, ok := req.Params["objective"].(string); ok { + if m := scenarioRE.FindStringSubmatch(obj); m != nil { + return m[1] + } + } + // execute_action carries the proposal under params. + if inner, ok := req.Params["params"].(map[string]interface{}); ok { + if p, ok := inner["params"].(map[string]interface{}); ok { + if s, ok := p["scenario"].(string); ok { + return s + } + } + if s, ok := inner["scenario"].(string); ok { + return s + } + } + return "" +} + +func writeJSON(w http.ResponseWriter, code int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func callTool(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req toolRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + scenario := scenarioOf(req) + var result map[string]interface{} + switch req.Tool { + case "get_status": + emit(req.Tool, scenario, "") + // The agent claims perfect health. The platform must not trust it. + result = map[string]interface{}{"status": "healthy", "cluster_health": 100} + case "propose_action": + result = proposal(scenario) + emit(req.Tool, scenario, fmt.Sprint(result["action"])) + case "execute_action": + action, _ := req.Params["action"].(string) + emit(req.Tool, scenario, action) + result = map[string]interface{}{"executed": true, "action": action, "result": defaultReason} + default: + writeJSON(w, http.StatusNotFound, toolResponse{Tool: req.Tool, Error: "unknown tool"}) + return + } + writeJSON(w, http.StatusOK, toolResponse{Tool: req.Tool, Result: result, Success: true}) +} + +func main() { + addr := flag.String("addr", ":8443", "listen address") + cert := flag.String("cert", "/demo/tls.crt", "TLS certificate") + key := flag.String("key", "/demo/tls.key", "TLS key") + flag.Parse() + + mux := http.NewServeMux() + mux.HandleFunc("/call_tool", callTool) + mux.HandleFunc("/tools", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string][]string{"tools": {"get_status", "propose_action", "execute_action"}}) + }) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + srv := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + log.SetOutput(os.Stderr) + log.Printf("mockmcp listening on %s", *addr) + log.Fatal(srv.ListenAndServeTLS(*cert, *key)) +} From ae934e27bc180a8c83fc24424f4dcd847e61e3a7 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 02:58:20 +0530 Subject: [PATCH 27/54] fix(demo): allow mock MCP egress and pin kube context kindnet enforces NetworkPolicy. The chart's default-deny egress has no rule for an MCP endpoint, so after the scenario 7 helm upgrade the new operator pod could not reach the mock. Allow the kind pod CIDR on the mock port only. Pass the kind context to every helm and kubectl call. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- scripts/demo-claims.sh | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/demo-claims.sh b/scripts/demo-claims.sh index f028854..fc1ea64 100755 --- a/scripts/demo-claims.sh +++ b/scripts/demo-claims.sh @@ -194,7 +194,7 @@ export KUBECONFIG="${KUBECONFIG_FILE}" run kind load docker-image "${IMAGE}" --name "${CLUSTER_NAME}" heading "Install cert-manager ${CERT_MANAGER_VERSION}" -run helm upgrade --install cert-manager oci://quay.io/jetstack/charts/cert-manager \ +run helm --kube-context "kind-${CLUSTER_NAME}" upgrade --install cert-manager oci://quay.io/jetstack/charts/cert-manager \ --namespace cert-manager --create-namespace --version "${CERT_MANAGER_VERSION}" \ --set crds.enabled=true --timeout "${HELM_TIMEOUT}" --wait >/dev/null @@ -208,7 +208,12 @@ kc -n "${NS}" create secret generic receipt-signing-key \ note "signing key Secret created (value not printed)" image_repo="${IMAGE%%:*}" image_tag="${IMAGE##*:}" -run helm upgrade --install "${RELEASE}" "${REPO_ROOT}/charts" --namespace "${NS}" \ +# kindnet enforces NetworkPolicy. The chart's default-deny egress has no rule +# for an MCP endpoint, so allow the kind pod CIDR on the mock's port only. +run helm --kube-context "kind-${CLUSTER_NAME}" upgrade --install "${RELEASE}" "${REPO_ROOT}/charts" --namespace "${NS}" \ + --set 'networkPolicy.additionalAllowedHosts[0].cidr=10.244.0.0/16' \ + --set 'networkPolicy.additionalAllowedHosts[0].ports[0].port=8443' \ + --set 'networkPolicy.additionalAllowedHosts[0].ports[0].protocol=TCP' \ --set-string license.key=dev-license \ --set argo.enabled=false --set browserless.enabled=false --set litellm.enabled=false \ --set minio.enabled=false --set postgresql.enabled=false \ @@ -557,8 +562,8 @@ s6() { } s7() { - run kubectl -n "${NS}" create configmap decision-model --from-file=model.json="${MODEL_JSON}" - run helm upgrade "${RELEASE}" "${REPO_ROOT}/charts" -n "${NS}" --reuse-values \ + run kubectl --context "kind-${CLUSTER_NAME}" -n "${NS}" create configmap decision-model --from-file=model.json="${MODEL_JSON}" + run helm --kube-context "kind-${CLUSTER_NAME}" upgrade "${RELEASE}" "${REPO_ROOT}/charts" -n "${NS}" --reuse-values \ --set global.decisionModel.existingConfigMap=decision-model --timeout "${HELM_TIMEOUT}" --wait >/dev/null local end end=$(( $(date +%s) + 120 )) @@ -629,8 +634,9 @@ NOT proven by this demo: - Argo approval gates - execution outcome receipts (only the pre-execution decision is signed) - production validity of the decision model (trained on synthetic DRAFT data) -- packet-level egress enforcement (kind's CNI does not enforce NetworkPolicy; - INV-02 checks the configured MCP endpoint only) +- packet-level egress enforcement (not asserted here; the demo opens operator + egress to the mock on the pod CIDR, and INV-02 checks the configured MCP + endpoint only) - caller identity observation (observed.callerIdentity is empty) EOF From de191b347a416ae774272f24d5f42110e051759e Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 03:04:29 +0530 Subject: [PATCH 28/54] docs(readme): add claims demo section and receipt status Document scripts/demo-claims.sh with its measured runtime, what it proves, and what it does not. Update the audit row to describe opt-in decision receipts on the direct path. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- README.md | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 92b41d7..e1dfa26 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ How an action is decided, as a target design (see [decision architecture](docs/a 1. Invariants in code. Credentials never reach the agent. Egress only to declared destinations. 2. A decision model scores each action. It can only escalate to a human. -3. Humans approve or reject. Every decision is a signed, labelled example. +3. Humans approve, reject, or edit. Every decision is a signed, labelled example. Today the repo ships invariants, policy packs, a threshold evaluator, and human approvals. The decision model exists but is escalate-only, runs in @@ -74,7 +74,7 @@ decisions. The ADR lists what is built and what is not. |---|---| | Runtime isolation | gVisor `RuntimeClass` mutation for labeled pods; nodes must provide `runsc` | | Network controls | Default-deny and allow-list policy generation; enforcement depends on the cluster CNI | -| Audit | HMAC hash-chain and JSONL verifier; automatic same-run capture is not connected | +| Audit | Opt-in Ed25519 decision receipts on the direct action path ([receipts](docs/receipts.md)), plus HMAC hash-chain primitives; execution outcomes and runtime-adapter runs are not receipted | | Cost | Per-workload usage and estimated-cost paths plus chargeback hooks | | Context | ANF view snapshots (internal tooling): `agentctl` renders token-minimal Kubernetes and agent state for the model | | Delivery | Helm packaging and offline JWT validation; full air-gapped install testing remains a release gate | @@ -198,6 +198,34 @@ kubectl -n agentic-system get agentworkloads -w --- +## Run the claims demo + +One script proves the decision-architecture claims on a fresh kind cluster. +No API keys. A deterministic mock MCP server plays the agent. Each scenario +prints its command and a PASS or FAIL line. The script exits nonzero on any +failure and deletes its cluster at the end. + +Prerequisites: docker (running), kind, kubectl, helm, go, python3, openssl, +curl. + +```bash +scripts/demo-claims.sh # --keep, --skip-build, --cluster-name, --dry-run, --help +``` + +Runtime: about 3 minutes end to end, build included. Two runs from a fresh +cluster took 151s and 165s on an Apple Silicon laptop (kind v0.31, images +already pulled). + +It proves invariants, write-ahead receipts, pack escalation, webhook-stamped +approvals, offline verification, and the shadow decision model. It does not +prove the runtime-adapter path, Argo approval gates, execution outcome +receipts, production model quality, packet-level egress, or caller identity. +The script ends with the full list. It builds a demo-only image from host +binaries until the receiptspec dependency is tagged. See +[BLOCKERS.md](BLOCKERS.md). + +--- + ## Architecture ```mermaid @@ -221,8 +249,9 @@ templates consume those labels. Actual sandboxing requires gVisor on the nodes. Network enforcement depends on the cluster CNI. The audit package and offline JSONL verifier are implemented. The controller does -not yet append each run event into that chain. Durable storage, production signing -keys, and independently verified checkpoints remain integration work. +not yet append each run event into that chain. Separately, opt-in signed decision +receipts cover each direct-path decision before it runs. Durable storage, key +rotation, and independently verified checkpoints remain integration work. --- From 5f2fec5fea3fb6b9d6da819f77a48591afebd539 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 03:06:16 +0530 Subject: [PATCH 29/54] docs: record demo detour, known gaps, and changelog summary ADR Today vs target now lists the event filter, log redaction, the claims demo, and what is not built. BLOCKERS gains the demo image detour and the known gaps the demo exposed. CHANGELOG Unreleased summarizes the series. values.yaml notes that the egress policy has no MCP endpoint rule. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- BLOCKERS.md | 22 ++++++++++++++++++++++ CHANGELOG.md | 12 ++++++++++++ charts/values.yaml | 3 ++- docs/architecture/decision-architecture.md | 10 ++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/BLOCKERS.md b/BLOCKERS.md index 167d675..e83968c 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -41,3 +41,25 @@ The chart `receipts` block points at `ghcr.io/clawdlinux/agentic-operator-core/receipt-writer`. No CI job publishes that image yet. Build it with `docker build --target receipt-writer .` and push it, or add a release job. Blocked on the receiptspec entry above. + +## Demo-only image + +`scripts/demo-claims.sh` builds linux binaries on the host and packages them +with `scripts/demo/Dockerfile.demo`, because the repo Dockerfile cannot build +while the receiptspec `replace` is in `go.mod`. Once v0.1.4 is pinned, switch +the demo to the repo Dockerfile targets and delete `Dockerfile.demo`. + +## Known gaps (not blockers for this branch) + +- Runtime-adapter parity: `reconcileViaRuntime` runs no invariants, packs, + receipts, or approvals. Packs on that path fail closed. +- Argo approval gates are not covered by human approvals. +- Execution outcomes are not receipted. Only the pre-execution decision is. +- Chart egress: the default-deny NetworkPolicy has no rule for the workload + MCP endpoint. On an enforcing CNI (kindnet included) set + `networkPolicy.additionalAllowedHosts`. +- A Completed direct-path workload is reconciled every 30s and proposes and + runs a new action each time. Pre-existing behavior, unchanged here. +- `callerIdentity` is always empty. +- `TestControllers` needs envtest binaries (`bin/k8s` or + `/usr/local/kubebuilder/bin`). It fails locally without them. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcd368..55ed25d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +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 +before the action runs. `scripts/demo-claims.sh` proves this on kind. The +runtime-adapter path, Argo approval gates, and execution outcomes are not +covered yet. See [decision architecture](docs/architecture/decision-architecture.md). + +### Fixed +- Status-only updates no longer re-trigger Reconcile. Before, each status write re-ran the direct-path action in a hot loop. +- The controller no longer logs MCP status, proposal, or execution values. They can carry personal data or credentials. + ### Added +- Added `scripts/demo-claims.sh`, a no-API-key claims demo on a fresh kind cluster with a mock MCP server. It builds a demo-only image from host binaries until the receiptspec dependency is tagged. - Added P7 decision spans on direct proposals and stamped human approve, reject, and edit decisions. Successful receipt appends bind spans to writer-returned sequence and entry hash. Added privacy canary tests, no-op checks, write-ahead assertions, and `make trace-coverage` with an 11-path matrix. diff --git a/charts/values.yaml b/charts/values.yaml index daa218a..96d23cc 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -430,7 +430,8 @@ networkPolicy: # MinIO, and Browserless run. Empty uses the release namespace. platformNamespace: "" # Extra allow-list entries appended to the default policy. Each entry is an - # IP CIDR plus optional ports. + # IP CIDR plus optional ports. The default list has no rule for the + # workload MCP endpoint. Add one here when your CNI enforces NetworkPolicy. # Example: # additionalAllowedHosts: # - cidr: 10.20.0.0/16 diff --git a/docs/architecture/decision-architecture.md b/docs/architecture/decision-architecture.md index bde26a7..b05d55e 100644 --- a/docs/architecture/decision-architecture.md +++ b/docs/architecture/decision-architecture.md @@ -60,6 +60,16 @@ model id and version, input hash, option set and order, per-option probabilities The shipped artifact is trained on 30 DRAFT synthetic scenarios and is not production-validated. Offline evaluation: `agentctl decision eval`. See [decision model](decision-model.md). Phase B (bounded autonomy) is not built. +- The controller ignores status-only updates, so a status write does not + re-run a direct-path action. MCP replies are not logged by value. +- `scripts/demo-claims.sh` proves the direct-path claims above on a fresh kind + cluster with a mock MCP server and no API keys. +- Not built: caller identity observation (`callerIdentity` is always empty), + packet-level egress proof (INV-02 checks the configured MCP endpoint only), + and a chart egress rule for the MCP endpoint (use + `networkPolicy.additionalAllowedHosts`). +- Unchanged: a Completed direct-path workload is reconciled again every 30s + and proposes a new action each time. ## Build order 1. Measure, stop trusting agent self-report (observed inputs). See From afaa2b2a0ea07ea9e2fd756da409782e00e683fa Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 03:21:57 +0530 Subject: [PATCH 30/54] fix(dataclass): fail closed on incomplete scans via INV-06 Scans past depth, node, byte, or per-string limits used to drop the rest silently. A credential past 64 KiB escaped INV-01, INV-03/04 and packs. Scan now reports completeness and INV-06 denies incomplete scans. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- docs/architecture/decision-architecture.md | 2 +- docs/architecture/invariants.md | 3 ++ docs/policy-input.md | 5 +++- pkg/dataclass/dataclass.go | 33 ++++++++++++++++++---- pkg/dataclass/dataclass_test.go | 30 ++++++++++++++++++++ pkg/decision/input/input.go | 3 ++ pkg/decision/observe/observe.go | 4 ++- pkg/decision/observe/observe_test.go | 7 +++++ pkg/invariants/invariants.go | 11 ++++++++ pkg/invariants/invariants_test.go | 5 +++- 10 files changed, 93 insertions(+), 10 deletions(-) diff --git a/docs/architecture/decision-architecture.md b/docs/architecture/decision-architecture.md index b05d55e..9137b5d 100644 --- a/docs/architecture/decision-architecture.md +++ b/docs/architecture/decision-architecture.md @@ -29,7 +29,7 @@ model id and version, input hash, option set and order, per-option probabilities - go.mod now has OPA (v1.19.1). pkg/rules/engine embeds it and evaluates the shipped packs (dpdp-in, gdpr-eu) when spec.policyPacks is set. The config/policies and pkg/rules/threshold .rego samples are still unevaluated. -- Invariants (pkg/invariants, INV-01 to INV-05) run on every direct-path +- Invariants (pkg/invariants, INV-01 to INV-06) run on every direct-path action. See [invariants](invariants.md). INV-05 fails closed when `RECEIPTS_REQUIRED=true` and no receipt can be written. - Receipts (pkg/receipts, cmd/receipt-writer) are opt-in. With receipts on, diff --git a/docs/architecture/invariants.md b/docs/architecture/invariants.md index 7555854..d1d4331 100644 --- a/docs/architecture/invariants.md +++ b/docs/architecture/invariants.md @@ -18,6 +18,7 @@ The set stays under 15 entries. IDs are never reused or renumbered. | INV-03 | Personal data (`email`, `phone`, `aadhaar`, `pan`, `iban`, `card`) sent to a destination not in `allowedDestinations`. | `declaredIntent` has any field set. | Declared vs observed | | INV-04 | Observed data classes outside `allowedDataClasses`. | `declaredIntent` has any field set. | Declared vs observed | | INV-05 | Deciding without a receipt when receipts are required. Fails closed if no receipt writer is available or the receipt append fails. | `RECEIPTS_REQUIRED=true`. Off by default. See [receipts](../receipts.md). | Platform | +| INV-06 | Acting on a payload the data class scan could not fully read. Depth 8, 64 KiB per string, 1 MiB and 10000 nodes per value, or a leaf of an unknown type. Unscanned content could hold a credential or personal data, so the action is denied. | Always. | Observed (`dataclass.Scan` completeness) | Reasons never contain matched content. They name the class or host only. @@ -29,6 +30,8 @@ Reasons never contain matched content. They name the class or host only. ## Known limits - Detection is pattern based. A credential in an unknown format is missed. +- Scan limits are kept. A payload past them is denied by INV-06, never + partly scanned and allowed. - Destination is the configured MCP endpoint, not every host the MCP server calls. See [policy input](../policy-input.md). - INV-01 only sees the `propose_action` reply. The objective was already sent diff --git a/docs/policy-input.md b/docs/policy-input.md index 9989f47..e1ac9ea 100644 --- a/docs/policy-input.md +++ b/docs/policy-input.md @@ -45,6 +45,7 @@ Built by `observe.Observe` from values the controller already holds. | `callerIdentity` | Empty. `AgentWorkload` has no caller or actor field today. | | `priorActionCount` | `len(status.executedActions) + len(status.proposedActions)`. | | `priorDeniedCount` | Proposed actions with `approved: false`. | +| `scanIncomplete` | True when the data class scan hit a limit. Invariant input only. Not passed to policy packs. | Detectors are deterministic. Aadhaar requires a valid Verhoeff checksum. Card requires Luhn. IBAN requires mod-97. Phone is pattern based (E.164 with `+`, @@ -53,7 +54,9 @@ Credential is pattern based: AWS access key IDs, PEM private key headers, JWT-shaped tokens, `Bearer` tokens of 20+ characters, and GitHub `ghp_` and `github_pat_` tokens. Scanning is bounded: depth 8, 64 KiB per string, 1 MiB and 10000 nodes per -value. Map keys are not scanned. +value. Map keys are not scanned. A payload past any limit, or with a leaf of +an unknown type, sets `scanIncomplete` and INV-06 denies the action. The +scanner fails closed. It never allows content it did not read. ### Not observed yet diff --git a/pkg/dataclass/dataclass.go b/pkg/dataclass/dataclass.go index 806bd1c..4cea86f 100644 --- a/pkg/dataclass/dataclass.go +++ b/pkg/dataclass/dataclass.go @@ -83,31 +83,45 @@ func Detect(s string) []Class { // DetectValue walks JSON-like values (map[string]any, []any, strings, numbers) // and returns the sorted, deduplicated classes found in string and numeric -// leaves. Map keys are not scanned. +// leaves. Map keys are not scanned. Use Scan when completeness matters. func DetectValue(v any) []Class { + cs, _ := Scan(v) + return cs +} + +// Scan is DetectValue plus completeness. complete is false when a depth, +// node, byte, or per-string limit cut the scan short, or a leaf had an +// unsupported type. Callers that gate actions must fail closed on false. +func Scan(v any) (classes []Class, complete bool) { set := map[Class]struct{}{} b := &budget{bytes: MaxTotalBytes, nodes: MaxNodes} walk(v, 0, b, set) - return sorted(set) + return sorted(set), !b.truncated } type budget struct { - bytes int - nodes int + bytes int + nodes int + truncated bool } func walk(v any, depth int, b *budget, set map[Class]struct{}) { if depth > MaxDepth || b.nodes <= 0 || b.bytes <= 0 { + b.truncated = true return } b.nodes-- switch t := v.(type) { + case nil, bool: case string: if len(t) > b.bytes { t = t[:b.bytes] + b.truncated = true } b.bytes -= len(t) - detectInto(t, set) + if !detectInto(t, set) { + b.truncated = true + } case json.Number: walk(t.String(), depth, b, set) case int, int32, int64, uint, uint32, uint64: @@ -136,12 +150,18 @@ func walk(v any, depth int, b *budget, set map[Class]struct{}) { for _, k := range keys { walk(t[k], depth+1, b, set) } + default: + // Unknown leaf types are not scanned, so the scan is not complete. + b.truncated = true } } -func detectInto(s string, set map[Class]struct{}) { +// detectInto scans s and reports false when s was cut to MaxStringBytes. +func detectInto(s string, set map[Class]struct{}) bool { + complete := true if len(s) > MaxStringBytes { s = s[:MaxStringBytes] + complete = false } if emailRe.MatchString(s) { set[Email] = struct{}{} @@ -167,6 +187,7 @@ func detectInto(s string, set map[Class]struct{}) { break } } + return complete } // anyIsolated reports whether some match of re stands alone (no adjacent digit diff --git a/pkg/dataclass/dataclass_test.go b/pkg/dataclass/dataclass_test.go index 4cb6b86..706ca38 100644 --- a/pkg/dataclass/dataclass_test.go +++ b/pkg/dataclass/dataclass_test.go @@ -134,6 +134,36 @@ func TestDetectValueSizeLimited(t *testing.T) { } } +func TestScanReportsIncomplete(t *testing.T) { + deep := any("AKIAABCDEFGHIJKLMNOP") + for i := 0; i <= MaxDepth+1; i++ { + deep = []any{deep} + } + manyNodes := make([]any, MaxNodes+1) + for i := range manyNodes { + manyNodes[i] = "x" + } + tests := []struct { + name string + v any + want bool + }{ + {"small", map[string]any{"a": "b", "n": 1.0, "t": true, "z": nil}, true}, + {"string past per-string cap", strings.Repeat("y", MaxStringBytes) + " AKIAABCDEFGHIJKLMNOP", false}, + {"total budget exhausted", []any{strings.Repeat("x", MaxTotalBytes), "a@b.io"}, false}, + {"too deep", deep, false}, + {"too many nodes", manyNodes, false}, + {"unknown leaf type", []any{struct{ S string }{"a@b.io"}}, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, got := Scan(tc.v); got != tc.want { + t.Fatalf("complete = %v, want %v", got, tc.want) + } + }) + } +} + func TestDetectCredential(t *testing.T) { // Test fixtures only. None of these are live secrets. tests := []struct { diff --git a/pkg/decision/input/input.go b/pkg/decision/input/input.go index 556e57f..0e1a6c7 100644 --- a/pkg/decision/input/input.go +++ b/pkg/decision/input/input.go @@ -43,6 +43,9 @@ type Observed struct { CallerIdentity string PriorActionCount int PriorDeniedCount int + // ScanIncomplete is true when the data class scan hit a limit. INV-06 + // denies it, because unscanned content could hold any class. + ScanIncomplete bool } // AgentClaimed is self-reported by the agent. It may only tighten a decision. diff --git a/pkg/decision/observe/observe.go b/pkg/decision/observe/observe.go index fefcd22..cbceee6 100644 --- a/pkg/decision/observe/observe.go +++ b/pkg/decision/observe/observe.go @@ -41,13 +41,15 @@ type Facts struct { // Observe converts facts into an input.Observed. func Observe(f Facts) input.Observed { + classes, complete := dataclass.Scan(f.Payload) return input.Observed{ Destination: input.Host(f.Endpoint), - DataClasses: dataclass.Strings(dataclass.DetectValue(f.Payload)), + DataClasses: dataclass.Strings(classes), Tool: f.Tool, CallerIdentity: f.CallerIdentity, PriorActionCount: f.PriorActionCount, PriorDeniedCount: f.PriorDeniedCount, + ScanIncomplete: !complete, } } diff --git a/pkg/decision/observe/observe_test.go b/pkg/decision/observe/observe_test.go index 9c15dc3..d2b020b 100644 --- a/pkg/decision/observe/observe_test.go +++ b/pkg/decision/observe/observe_test.go @@ -2,9 +2,11 @@ package observe import ( "reflect" + "strings" "testing" agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/dataclass" "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" ) @@ -40,6 +42,11 @@ func TestObserve(t *testing.T) { Facts{Payload: map[string]any{"params": map[string]any{"key": "AKIAIOSFODNN7EXAMPLE"}}}, input.Observed{DataClasses: []string{"credential"}}, }, + { + "credential past scan limit", + Facts{Payload: map[string]any{"params": strings.Repeat("x", dataclass.MaxStringBytes) + " AKIAIOSFODNN7EXAMPLE"}}, + input.Observed{DataClasses: []string{}, ScanIncomplete: true}, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/invariants/invariants.go b/pkg/invariants/invariants.go index 484529a..1d1f999 100644 --- a/pkg/invariants/invariants.go +++ b/pkg/invariants/invariants.go @@ -36,6 +36,7 @@ const ( PersonalDataDeclaredDest = "INV-03" DataClassesSubsetOfDeclared = "INV-04" ReceiptsOrFailClosed = "INV-05" + ScanCompleteOrDeny = "INV-06" ) // Result is one invariant violation. Reason never contains matched content. @@ -67,6 +68,7 @@ var all = []invariant{ {PersonalDataDeclaredDest, personalDest}, {DataClassesSubsetOfDeclared, dataClassSubset}, {ReceiptsOrFailClosed, receipts}, + {ScanCompleteOrDeny, scanComplete}, } // IDs returns every invariant ID in check order. @@ -142,3 +144,12 @@ func receipts(_ input.Input, ctx Context) string { } return "" } + +// scanComplete is INV-06. A payload the data class scan could not fully read +// is denied. Content past a scan limit could carry any class. Always applies. +func scanComplete(in input.Input, _ Context) string { + if in.Observed.ScanIncomplete { + return "payload exceeds data class scan limits; not fully scanned" + } + return "" +} diff --git a/pkg/invariants/invariants_test.go b/pkg/invariants/invariants_test.go index bb34e25..f358fc5 100644 --- a/pkg/invariants/invariants_test.go +++ b/pkg/invariants/invariants_test.go @@ -71,6 +71,9 @@ func TestCheck(t *testing.T) { {"INV-05 pass writer available", input.Input{}, Context{ReceiptsRequired: true, ReceiptWriterAvailable: true}, nil}, {"INV-05 fail writer missing", input.Input{}, Context{ReceiptsRequired: true}, []string{"INV-05"}}, + {"INV-06 pass scan complete", input.Input{}, Context{}, nil}, + {"INV-06 fail scan incomplete", input.Input{Observed: input.Observed{ScanIncomplete: true}}, Context{}, []string{"INV-06"}}, + {"all fire in order", input.Input{Declared: declared, Observed: input.Observed{Destination: "evil.example", DataClasses: []string{"credential", "pan"}}}, Context{ReceiptsRequired: true}, []string{"INV-01", "INV-02", "INV-03", "INV-04", "INV-05"}}, @@ -100,7 +103,7 @@ func TestReasonsHaveNoContent(t *testing.T) { } func TestIDsStableAndBounded(t *testing.T) { - want := []string{"INV-01", "INV-02", "INV-03", "INV-04", "INV-05"} + want := []string{"INV-01", "INV-02", "INV-03", "INV-04", "INV-05", "INV-06"} if got := IDs(); !reflect.DeepEqual(got, want) { t.Fatalf("IDs = %v, want %v", got, want) } From 425111f91ba7bab9b862bc4ab9c12029327ca154 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 03:25:58 +0530 Subject: [PATCH 31/54] fix(decision): agent claims can only tighten model risk Score with claims and with claims removed and keep the higher risk. A mid confidence claim could pull risk under the threshold and turn require_approval into allow. Both risks go in the model block (v2). Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- docs/architecture/decision-model.md | 15 ++- docs/receipts.md | 8 +- .../agentworkload_controller_model_test.go | 13 +-- internal/controller/decision_model.go | 6 +- pkg/decision/learned/claims_test.go | 102 ++++++++++++++++++ pkg/decision/model.go | 43 ++++++++ pkg/receipts/record.go | 17 ++- 7 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 pkg/decision/learned/claims_test.go diff --git a/docs/architecture/decision-model.md b/docs/architecture/decision-model.md index d51bf27..3c9af30 100644 --- a/docs/architecture/decision-model.md +++ b/docs/architecture/decision-model.md @@ -89,7 +89,8 @@ gets a `model` block. See [receipts](../receipts.md#model-block). It holds the model id and version, artifact sha256, feature spec and feature hash, option set and order, per-option micro probabilities, risk, threshold used, calibration note, top reason codes, the outcome before and after the model, -and the scorer error if any. +the claimed and claim-independent risks, and the scorer error if any. Model +block schema version is 2. ## Escalate-only guarantee @@ -111,6 +112,18 @@ A model can never turn a deny or a require_approval into an allow. Enforced at: Neither path allows. 5. A model-escalated action goes to the normal approval flow. Human approve and edit re-run invariants and packs, not the model. +6. Agent claims only tighten. `decision.ScoreClaimSafe` scores the action + twice: with the claimed confidence and cluster health, and with both + removed (`conf_absent`, `health_absent`). Removing claims is the + claim-independent baseline: the score an agent gets by claiming nothing. + The risk used is the higher of the two. A mid confidence or healthy + cluster claim can no longer pull the risk under the threshold. + `TestClaimsOnlyTighten` checks every confidence and health bucket. + Both risks go in the model block as `claimed_risk_micro` and + `baseline_risk_micro`, with `baseline_feature_hash`. The artifact and + `golden.json` are unchanged: the combination happens above the scorer. + Offline metrics in `metrics.json` score claimed features only, so they + can understate the live escalation rate. ## Configuration diff --git a/docs/receipts.md b/docs/receipts.md index 792c92c..f20c55d 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -59,18 +59,20 @@ receiptspec `DECISION.md` (RFC 8785 subset). Present only when `DECISION_MODEL_PATH` loaded an artifact and the workload mode is `shadow` or `escalate`. Absent otherwise, so records without a model are byte-identical to before. The record `schema_version` stays 1; the block -has its own `schema_version` (1). See +has its own `schema_version` (2). See [decision model](architecture/decision-model.md). | Field | Meaning | |---|---| -| `schema_version` | model block version, 1 | +| `schema_version` | model block version, 2 | | `mode` | `shadow` or `escalate` | | `id`, `version`, `artifact_sha256` | the artifact that scored | | `feature_spec`, `feature_hash` | feature spec version and SHA-256 of the feature vector | | `option_set` | `["allow", "require_approval"]`, fixed order | | `option_micro` | per-option probability in micro-units, same order, sums to 1000000 | -| `risk_micro` | P(human rejects or edits), micro-units | +| `risk_micro` | P(human rejects or edits), micro-units. The higher of the two below | +| `claimed_risk_micro` | risk with the agent claims as sent | +| `baseline_risk_micro`, `baseline_feature_hash` | risk and feature hash with claimed confidence and health removed. Claims can only raise risk | | `threshold_micro` | effective threshold, after a stricter workload override | | `calibration` | calibration note from the artifact | | `reason_codes` | top 3 positive feature contributions | diff --git a/internal/controller/agentworkload_controller_model_test.go b/internal/controller/agentworkload_controller_model_test.go index c82439d..783aa2e 100644 --- a/internal/controller/agentworkload_controller_model_test.go +++ b/internal/controller/agentworkload_controller_model_test.go @@ -50,6 +50,7 @@ func TestReconcile_DecisionModel(t *testing.T) { override *int64 wantPhase string wantExecute bool + // wantCalls is 2 on success: claimed and claim-independent scores. wantCalls int wantOutcome string wantLayer string @@ -61,21 +62,21 @@ func TestReconcile_DecisionModel(t *testing.T) { {name: "mode off never scores", confidence: "0.98", scorer: &fakeScorer{risk: 900000, threshold: 500000}, mode: "off", wantPhase: "Completed", wantExecute: true, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold}, {name: "default shadow no effect", confidence: "0.98", scorer: &fakeScorer{risk: 900000, threshold: 500000}, - wantPhase: "Completed", wantExecute: true, wantCalls: 1, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold, wantBlock: true}, + wantPhase: "Completed", wantExecute: true, wantCalls: 2, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold, wantBlock: true}, {name: "escalate allow to require approval", confidence: "0.98", scorer: &fakeScorer{risk: 900000, threshold: 500000}, mode: "escalate", - wantPhase: "PendingApproval", wantCalls: 1, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true}, + wantPhase: "PendingApproval", wantCalls: 2, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true}, {name: "escalate low risk keeps allow", confidence: "0.98", scorer: &fakeScorer{risk: 100000, threshold: 500000}, mode: "escalate", - wantPhase: "Completed", wantExecute: true, wantCalls: 1, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold, wantBlock: true}, + wantPhase: "Completed", wantExecute: true, wantCalls: 2, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold, wantBlock: true}, {name: "escalate never loosens deny", confidence: "0.82", scorer: &fakeScorer{risk: 0, threshold: 500000}, mode: "escalate", - wantPhase: "PolicyDenied", wantCalls: 1, wantOutcome: receipts.OutcomeDeny, wantLayer: receipts.LayerThreshold, wantBlock: true}, + wantPhase: "PolicyDenied", wantCalls: 2, wantOutcome: receipts.OutcomeDeny, wantLayer: receipts.LayerThreshold, wantBlock: true}, {name: "escalate scorer error fails safe", confidence: "0.98", scorer: &fakeScorer{err: errors.New("artifact read failed")}, mode: "escalate", wantPhase: "PendingApproval", wantCalls: 1, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true, wantError: true}, {name: "shadow scorer error never blocks", confidence: "0.98", scorer: &fakeScorer{err: errors.New("artifact read failed")}, mode: "shadow", wantPhase: "Completed", wantExecute: true, wantCalls: 1, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold, wantBlock: true, wantError: true}, {name: "stricter override escalates", confidence: "0.98", scorer: &fakeScorer{risk: 400000, threshold: 500000}, mode: "escalate", override: i(300000), - wantPhase: "PendingApproval", wantCalls: 1, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true}, + wantPhase: "PendingApproval", wantCalls: 2, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true}, {name: "looser override ignored", confidence: "0.98", scorer: &fakeScorer{risk: 600000, threshold: 500000}, mode: "escalate", override: i(900000), - wantPhase: "PendingApproval", wantCalls: 1, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true}, + wantPhase: "PendingApproval", wantCalls: 2, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true}, } for idx, tc := range tests { tc, idx := tc, idx diff --git a/internal/controller/decision_model.go b/internal/controller/decision_model.go index 470d39b..49ef65f 100644 --- a/internal/controller/decision_model.go +++ b/internal/controller/decision_model.go @@ -50,12 +50,14 @@ func (r *AgentWorkloadReconciler) applyDecisionModel( if base.Outcome != decision.Allow { esc = base.Layer } - f := decision.ExtractFeatures(decision.FeatureInput{Input: in, Action: action, PolicyPacks: wl.Spec.PolicyPacks, EscalatedBy: esc}) + fi := decision.FeatureInput{Input: in, Action: action, PolicyPacks: wl.Spec.PolicyPacks, EscalatedBy: esc} + f := decision.ExtractFeatures(fi) var override *int64 if wl.Spec.DecisionModel != nil { override = wl.Spec.DecisionModel.ThresholdMicro } - s, err := r.DecisionModel.Scorer.Score(ctx, f) + // Claims may only tighten: the risk is never below the no-claim risk. + s, err := decision.ScoreClaimSafe(ctx, r.DecisionModel.Scorer, fi) if err != nil { s = decision.Score{FeatureSpec: f.SpecVersion, FeatureHash: f.Hash()} } else { diff --git a/pkg/decision/learned/claims_test.go b/pkg/decision/learned/claims_test.go new file mode 100644 index 0000000..a940b99 --- /dev/null +++ b/pkg/decision/learned/claims_test.go @@ -0,0 +1,102 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package learned_test + +import ( + "context" + "reflect" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision" + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +func fptr(v float64) *float64 { return &v } + +// TestClaimsOnlyTighten checks every claimed confidence and health bucket over +// a grid of inputs. The final risk is never below the no-claim risk, and an +// escalation without claims stays an escalation with any claim. +func TestClaimsOnlyTighten(t *testing.T) { + m := shipped(t) + ctx := context.Background() + confs := []*float64{nil, fptr(0), fptr(0.1), fptr(0.5), fptr(0.6), fptr(0.8), fptr(0.9), fptr(1)} + healths := []*float64{nil, fptr(0), fptr(10), fptr(50), fptr(60), fptr(80), fptr(90), fptr(100)} + declared := []input.Declared{{}, {DecisionType: "automated", AllowedDestinations: []string{"mcp.internal"}, AllowedDataClasses: []string{"email"}}} + dests := []string{"", "mcp.internal", "evil.example"} + classes := [][]string{nil, {"email"}, {"card", "email"}} + actions := []string{"read", "delete_user"} + packs := [][]string{nil, {"dpdp-in"}} + allow := decision.Result{Outcome: decision.Allow, Layer: decision.LayerThreshold} + checked := 0 + for _, d := range declared { + for _, dest := range dests { + for _, cl := range classes { + for _, act := range actions { + for _, p := range packs { + base := input.Input{Declared: d, Observed: input.Observed{Destination: dest, DataClasses: cl}} + noClaim, err := decision.ScoreClaimSafe(ctx, m, decision.FeatureInput{Input: base, Action: act, PolicyPacks: p}) + if err != nil { + t.Fatal(err) + } + noClaimOut := decision.ApplyModel(allow, noClaim, nil, decision.ModelEscalate) + for _, c := range confs { + for _, h := range healths { + in := base + in.Claimed = input.AgentClaimed{Confidence: c, ClusterHealth: h} + fi := decision.FeatureInput{Input: in, Action: act, PolicyPacks: p} + s, err := decision.ScoreClaimSafe(ctx, m, fi) + if err != nil { + t.Fatal(err) + } + if s.RiskMicro < noClaim.RiskMicro || s.RiskMicro < s.ClaimedRiskMicro || s.BaselineRiskMicro != noClaim.RiskMicro { + t.Fatalf("claims lowered risk: %+v vs no-claim %d", s, noClaim.RiskMicro) + } + out := decision.ApplyModel(allow, s, nil, decision.ModelEscalate) + if decision.Strictness(out.Outcome) < decision.Strictness(noClaimOut.Outcome) { + t.Fatalf("claims loosened %s to %s", noClaimOut.Outcome, out.Outcome) + } + again, _ := decision.ScoreClaimSafe(ctx, m, fi) + if !reflect.DeepEqual(s, again) { + t.Fatal("score not deterministic") + } + checked++ + } + } + } + } + } + } + } + if checked == 0 { + t.Fatal("no cases checked") + } +} + +// TestClaimedConfidenceCannotFlipToAllow reproduces the review case: an +// undeclared read with a destination. A mid confidence claim used to drop the +// raw risk under the threshold and allow the action. +func TestClaimedConfidenceCannotFlipToAllow(t *testing.T) { + m := shipped(t) + ctx := context.Background() + in := input.Input{Observed: input.Observed{Destination: "mcp-server"}, Claimed: input.AgentClaimed{Confidence: fptr(0.6), ClusterHealth: fptr(90)}} + fi := decision.FeatureInput{Input: in, Action: "read"} + raw, err := m.Score(ctx, decision.ExtractFeatures(fi)) + if err != nil { + t.Fatal(err) + } + if raw.RiskMicro >= m.ThresholdMicro() { + t.Fatalf("fixture no longer shows the bypass: raw risk %d", raw.RiskMicro) + } + s, err := decision.ScoreClaimSafe(ctx, m, fi) + if err != nil { + t.Fatal(err) + } + s.ThresholdMicro = m.ThresholdMicro() + out := decision.ApplyModel(decision.Result{Outcome: decision.Allow}, s, nil, decision.ModelEscalate) + if out.Outcome != decision.RequireApproval { + t.Fatalf("claimed confidence loosened the decision: risk %d claimed %d baseline %d", s.RiskMicro, s.ClaimedRiskMicro, s.BaselineRiskMicro) + } +} diff --git a/pkg/decision/model.go b/pkg/decision/model.go index aa57d0a..31986ed 100644 --- a/pkg/decision/model.go +++ b/pkg/decision/model.go @@ -8,6 +8,8 @@ package decision import ( "context" "fmt" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" ) // LayerModel is the layer name when the decision model escalated an action. @@ -59,6 +61,12 @@ type Score struct { // ThresholdMicro is the escalation threshold the score is compared to. ThresholdMicro int64 Calibration string + // ClaimedRiskMicro is the risk with the agent claims as sent. + // BaselineRiskMicro is the risk with the claims removed. RiskMicro is the + // higher of the two. Set by ScoreClaimSafe only. + ClaimedRiskMicro int64 + BaselineRiskMicro int64 + BaselineFeatureHash string } // Scorer scores one action. It runs in process. No network calls. @@ -66,6 +74,41 @@ type Scorer interface { Score(ctx context.Context, f Features) (Score, error) } +// NeutralClaims returns in without agent-claimed confidence and cluster +// health. This is the claim-independent baseline: the agent claimed nothing. +func NeutralClaims(in input.Input) input.Input { + in.Claimed.Confidence = nil + in.Claimed.ClusterHealth = nil + return in +} + +// ScoreClaimSafe scores fi with the agent claims and again with the claims +// removed, and keeps the higher risk. A claim can raise risk. It can never +// lower risk below the claim-independent baseline. +func ScoreClaimSafe(ctx context.Context, s Scorer, fi FeatureInput) (Score, error) { + claimed, err := s.Score(ctx, ExtractFeatures(fi)) + if err != nil { + return Score{}, err + } + nfi := fi + nfi.Input = NeutralClaims(fi.Input) + baseFeatures := ExtractFeatures(nfi) + base, err := s.Score(ctx, baseFeatures) + if err != nil { + return Score{}, err + } + out := claimed + if base.RiskMicro > claimed.RiskMicro { + out.RiskMicro = base.RiskMicro + out.OptionMicro = Options(base.RiskMicro) + out.ReasonCodes = append([]string{}, base.ReasonCodes...) + } + out.ClaimedRiskMicro = claimed.RiskMicro + out.BaselineRiskMicro = base.RiskMicro + out.BaselineFeatureHash = baseFeatures.Hash() + return out, nil +} + // Options returns the option micro-units for risk, in OptionSet order. func Options(riskMicro int64) map[string]int64 { r := ClampMicro(riskMicro) diff --git a/pkg/receipts/record.go b/pkg/receipts/record.go index 2a132cf..c3884e7 100644 --- a/pkg/receipts/record.go +++ b/pkg/receipts/record.go @@ -93,7 +93,7 @@ type ClaimedSummary struct { // ModelBlockVersion is the schema version of the Model block. The record // SchemaVersion stays 1: the block was reserved and never emitted before. -const ModelBlockVersion = 1 +const ModelBlockVersion = 2 // Model is the decision model block. It is set whenever a model scored the // action, in shadow or escalate mode, so the score can be replayed. Every @@ -109,9 +109,14 @@ type Model struct { OptionSet []string `json:"option_set"` OptionMicro []int64 `json:"option_micro"` RiskMicro int64 `json:"risk_micro"` - ThresholdMicro int64 `json:"threshold_micro"` - Calibration string `json:"calibration"` - ReasonCodes []string `json:"reason_codes"` + // ClaimedRiskMicro uses the agent claims. BaselineRiskMicro removes them. + // RiskMicro is the higher of the two, so claims can only tighten. + ClaimedRiskMicro int64 `json:"claimed_risk_micro"` + BaselineRiskMicro int64 `json:"baseline_risk_micro"` + BaselineFeatureHash string `json:"baseline_feature_hash"` + ThresholdMicro int64 `json:"threshold_micro"` + Calibration string `json:"calibration"` + ReasonCodes []string `json:"reason_codes"` // BaseOutcome is the outcome before the model. Outcome is after it. BaseOutcome string `json:"base_outcome"` Outcome string `json:"outcome"` @@ -137,6 +142,10 @@ func NewModelBlock(mode decision.ModelMode, s decision.Score, scoreErr error, ba ReasonCodes: append([]string{}, s.ReasonCodes...), BaseOutcome: string(base.Outcome), Outcome: string(final.Outcome), + + ClaimedRiskMicro: s.ClaimedRiskMicro, + BaselineRiskMicro: s.BaselineRiskMicro, + BaselineFeatureHash: s.BaselineFeatureHash, } for i, o := range decision.OptionSet { m.OptionMicro[i] = s.OptionMicro[o] From b97543d6117c34929df2da08fd289f30b00b4232 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 03:28:49 +0530 Subject: [PATCH 32/54] fix(controller): reserve execution before running an allowed action After the receipt append, write a status reservation with the decision's resourceVersion. A spec change during the append now conflicts and the action is decided again instead of executing a stale allow. Order: receipt, reservation, execute. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- docs/receipts.md | 9 +- .../controller/agentworkload_controller.go | 14 ++ internal/controller/reservation.go | 34 +++++ internal/controller/reservation_test.go | 139 ++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 internal/controller/reservation.go create mode 100644 internal/controller/reservation_test.go diff --git a/docs/receipts.md b/docs/receipts.md index f20c55d..4db3ea6 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -103,7 +103,14 @@ Order on the direct path: 1. Invariants, packs, threshold run. `decision.Decide` picks the outcome. 2. The operator appends the receipt. `Append` returns only after the record and receipt are fsynced on the writer. -3. Only then is the action executed or held. +3. On allow, the operator reserves execution: a status write (condition + `ExecutionReserved`) sent with the resourceVersion the decision read. If + the workload changed since then, for example a pack was added or declared + intent was tightened while the append was blocked, the write conflicts. + The action is not executed. The workload is decided again on the fresh + object. The receipt from step 2 stays: it records a decision that did not + run. +4. Only then is the action executed or held. `RECEIPTS_REQUIRED=true`: diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index a1d61b3..be9411b 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -29,6 +29,7 @@ import ( "time" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" apiMeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -640,6 +641,19 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques } if result.Outcome == decision.Allow { + // Order: receipt append, reservation, execute. The reservation is an + // optimistic status write. A spec change since the decision read + // (new pack, tighter declared intent) conflicts here, before any side + // effect, and the action is decided again on the fresh object. + if err := r.reserveExecution(ctx, &workload, action.Name, recorded, now); err != nil { + if apierrors.IsConflict(err) { + log.Info("workload changed after the decision; not executing, deciding again", "action", action.Name) + return ctrl.Result{RequeueAfter: time.Second}, nil + } + log.Error(err, "execution reservation failed; not executing", "action", action.Name) + return ctrl.Result{}, err + } + // Step 5a: Execute approved action via MCP log.Info("OPA approved action, executing", "action", action.Name) diff --git a/internal/controller/reservation.go b/internal/controller/reservation.go new file mode 100644 index 0000000..f193f0c --- /dev/null +++ b/internal/controller/reservation.go @@ -0,0 +1,34 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" +) + +// executionReservedCondition marks the last action reserved for execution. +const executionReservedCondition = "ExecutionReserved" + +// reserveExecution writes a status update with the resourceVersion the +// decision was made on. The API server rejects it with a conflict when the +// workload changed since that read, so a stale decision never executes. Same +// technique as the approval path persisting its intent first. +func (r *AgentWorkloadReconciler) reserveExecution(ctx context.Context, wl *agenticv1alpha1.AgentWorkload, action string, recorded decided, now metav1.Time) error { + msg := fmt.Sprintf("action %q reserved for execution", action) + if recorded.receipt != nil { + msg = fmt.Sprintf("action %q reserved for execution after receipt %d", action, recorded.receipt.Seq) + } + wl.Status.Conditions = upsertCondition(wl.Status.Conditions, metav1.Condition{ + Type: executionReservedCondition, Status: metav1.ConditionTrue, ObservedGeneration: wl.Generation, + Reason: "Reserved", Message: msg, LastTransitionTime: now, + }) + return r.Status().Update(ctx, wl) +} diff --git a/internal/controller/reservation_test.go b/internal/controller/reservation_test.go new file mode 100644 index 0000000..570b88e --- /dev/null +++ b/internal/controller/reservation_test.go @@ -0,0 +1,139 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "context" + "slices" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" +) + +// hookWriter runs onAppend while the receipt append is in flight. +type hookWriter struct { + fakeReceiptWriter + onAppend func() +} + +func (h *hookWriter) Append(ctx context.Context, rec receipts.DecisionRecord) (receiptspec.Receipt, error) { + if h.onAppend != nil { + h.onAppend() + } + return h.fakeReceiptWriter.Append(ctx, rec) +} + +func TestReconcile_StaleDecisionNeverExecutes(t *testing.T) { + t.Parallel() + tests := []struct { + name string + tighten bool + conflict bool + }{ + {name: "spec tightened during append", tighten: true}, + {name: "injected status conflict", conflict: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + scheme := newControllerTestScheme(t) + log := &eventLog{} + server := newLoggingMCPServer(t, mockMCPScenario{confidence: "0.98", clusterHealth: 90.0}, log) + objective := "optimize resources" + policy := "strict" + wl := &agenticv1alpha1.AgentWorkload{ + ObjectMeta: metav1.ObjectMeta{Name: "stale", Namespace: "default", UID: types.UID("uid-stale")}, + Spec: agenticv1alpha1.AgentWorkloadSpec{MCPServerEndpoint: &server.URL, Objective: &objective, OPAPolicy: &policy}, + } + builder := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&agenticv1alpha1.AgentWorkload{}). + WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, wl) + if tc.conflict { + builder = builder.WithInterceptorFuncs(interceptor.Funcs{ + SubResourceUpdate: func(context.Context, client.Client, string, client.Object, ...client.SubResourceUpdateOption) error { + return apierrors.NewConflict(schema.GroupResource{Group: "agentic.clawdlinux.org", Resource: "agentworkloads"}, "stale", nil) + }, + }) + } + k8sClient := builder.Build() + writer := &hookWriter{fakeReceiptWriter: fakeReceiptWriter{log: log, ready: true}} + if tc.tighten { + writer.onAppend = func() { + var cur agenticv1alpha1.AgentWorkload + if err := k8sClient.Get(ctx, types.NamespacedName{Name: "stale", Namespace: "default"}, &cur); err != nil { + t.Errorf("get: %v", err) + return + } + cur.Spec.PolicyPacks = []string{"dpdp-in"} + if err := k8sClient.Update(ctx, &cur); err != nil { + t.Errorf("tighten: %v", err) + } + } + } + r := &AgentWorkloadReconciler{Client: k8sClient, Scheme: scheme, Receipts: ReceiptsConfig{Enabled: true, Writer: writer}} + res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "stale", Namespace: "default"}}) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if slices.Contains(log.list(), "execute_action") { + t.Fatalf("stale decision executed; events %v", log.list()) + } + if res.RequeueAfter == 0 { + t.Fatal("expected a requeue to decide again") + } + if len(writer.records) != 1 { + t.Fatalf("records = %d, want the write-ahead receipt", len(writer.records)) + } + }) + } +} + +func TestReconcile_ReservationBeforeExecute(t *testing.T) { + t.Parallel() + ctx := context.Background() + scheme := newControllerTestScheme(t) + log := &eventLog{} + server := newLoggingMCPServer(t, mockMCPScenario{confidence: "0.98", clusterHealth: 90.0}, log) + objective := "optimize resources" + policy := "strict" + wl := &agenticv1alpha1.AgentWorkload{ + ObjectMeta: metav1.ObjectMeta{Name: "fresh", Namespace: "default", UID: types.UID("uid-fresh")}, + Spec: agenticv1alpha1.AgentWorkloadSpec{MCPServerEndpoint: &server.URL, Objective: &objective, OPAPolicy: &policy}, + } + k8sClient := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&agenticv1alpha1.AgentWorkload{}). + WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, wl). + WithInterceptorFuncs(interceptor.Funcs{ + SubResourceUpdate: func(ctx context.Context, c client.Client, sub string, obj client.Object, opts ...client.SubResourceUpdateOption) error { + log.add("status") + return c.SubResource(sub).Update(ctx, obj, opts...) + }, + }).Build() + writer := &fakeReceiptWriter{log: log, ready: true} + r := &AgentWorkloadReconciler{Client: k8sClient, Scheme: scheme, Receipts: ReceiptsConfig{Enabled: true, Writer: writer}} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "fresh", Namespace: "default"}}); err != nil { + t.Fatalf("reconcile: %v", err) + } + ev := log.list() + ri, si, ei := slices.Index(ev, "receipt"), slices.Index(ev, "status"), slices.Index(ev, "execute_action") + if ri < 0 || si < 0 || ei < 0 || !(ri < si && si < ei) { + t.Fatalf("want receipt, then reservation, then execute; got %v", ev) + } +} From 1727035b825c7c2ddf48f9953ed84bd9a65fb9d2 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 03:36:43 +0530 Subject: [PATCH 33/54] fix(receipts): verify writer receipts under a pinned key RemoteWriter.Append checked only the record hash binding. A spoofed writer or MITM could return a fabricated receipt and satisfy INV-05. Append now checks entry hash, signature under a pinned key, and chain continuity. RECEIPTS_WRITER_TRUST_FILE is required when receipts are enabled; RECEIPTS_INSECURE_NO_PIN=true is a demo-only escape. Chart gains global.receipts.trustRoot and insecureNoPin. agentctl gains receipts trust-root. The claims demo pins the key. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- .../templates/deployment.yaml | 20 ++ charts/templates/receipt-writer.yaml | 3 + charts/tests/receipts_test.yaml | 79 +++++++ charts/values.schema.json | 9 + charts/values.yaml | 11 + cmd/agentctl/receipts.go | 28 ++- cmd/main.go | 3 + cmd/main_test.go | 3 +- docs/receipts.md | 24 ++- pkg/receipts/record_test.go | 6 +- pkg/receipts/remote.go | 132 +++++++++++- pkg/receipts/remote_pin_test.go | 193 ++++++++++++++++++ pkg/receipts/remote_test.go | 18 +- pkg/receipts/trustroot.go | 37 ++++ scripts/demo-claims.sh | 12 +- 15 files changed, 563 insertions(+), 15 deletions(-) create mode 100644 pkg/receipts/remote_pin_test.go create mode 100644 pkg/receipts/trustroot.go diff --git a/charts/charts/agentic-operator/templates/deployment.yaml b/charts/charts/agentic-operator/templates/deployment.yaml index 5e5a516..4545a2a 100644 --- a/charts/charts/agentic-operator/templates/deployment.yaml +++ b/charts/charts/agentic-operator/templates/deployment.yaml @@ -75,6 +75,13 @@ spec: value: /etc/receipts/token/{{ (.token).key | default "token" }} - name: RECEIPTS_WRITER_TIMEOUT value: {{ .timeout | default "5s" | quote }} + {{- if (.trustRoot).existingConfigMap }} + - name: RECEIPTS_WRITER_TRUST_FILE + value: /etc/receipts/trust/{{ (.trustRoot).key | default "trust.json" }} + {{- else if .insecureNoPin }} + - name: RECEIPTS_INSECURE_NO_PIN + value: "true" + {{- end }} {{- end }} {{- end }} {{- with (.Values.global).decisionModel }} @@ -132,6 +139,11 @@ spec: - 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 }} {{- if ((.Values.global).decisionModel).existingConfigMap }} - name: decision-model @@ -149,6 +161,14 @@ spec: 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 }} {{- with (.Values.global).decisionModel }} diff --git a/charts/templates/receipt-writer.yaml b/charts/templates/receipt-writer.yaml index a87b712..eee1b8a 100644 --- a/charts/templates/receipt-writer.yaml +++ b/charts/templates/receipt-writer.yaml @@ -9,6 +9,9 @@ The signing key must come from an existing Secret. The chart never makes one. {{- if not ($r.signingKey).existingSecret -}} {{- fail "global.receipts.signingKey.existingSecret is required when global.receipts.enabled=true. The chart never generates the signing key. See docs/receipts.md." -}} {{- end -}} +{{- if and (not ($r.trustRoot).existingConfigMap) (not $r.insecureNoPin) -}} +{{- fail "global.receipts.trustRoot.existingConfigMap is required when global.receipts.enabled=true. It pins the writer public key. Make it with agentctl receipts trust-root. global.receipts.insecureNoPin=true is for demos only. See docs/receipts.md." -}} +{{- end -}} {{- $name := printf "%s-receipt-writer" .Release.Name | trunc 63 | trimSuffix "-" -}} {{- $ns := .Release.Namespace -}} {{- $port := int ($r.port | default 8080) -}} diff --git a/charts/tests/receipts_test.yaml b/charts/tests/receipts_test.yaml index 3ae77b1..53ec49e 100644 --- a/charts/tests/receipts_test.yaml +++ b/charts/tests/receipts_test.yaml @@ -45,6 +45,8 @@ tests: enabled: true signingKey: existingSecret: receipt-signing-key + trustRoot: + existingConfigMap: receipt-trust-root asserts: - hasDocuments: count: 4 @@ -110,6 +112,8 @@ tests: enabled: true signingKey: existingSecret: receipt-signing-key + trustRoot: + existingConfigMap: receipt-trust-root asserts: - isKind: of: NetworkPolicy @@ -155,6 +159,8 @@ tests: enabled: true signingKey: existingSecret: receipt-signing-key + trustRoot: + existingConfigMap: receipt-trust-root token: existingSecret: my-token key: bearer @@ -184,6 +190,8 @@ tests: required: true signingKey: existingSecret: receipt-signing-key + trustRoot: + existingConfigMap: receipt-trust-root asserts: - contains: path: spec.template.spec.containers[0].env @@ -203,3 +211,74 @@ tests: name: RECEIPTS_WRITER_TOKEN_FILE value: /etc/receipts/token/token template: charts/agentic-operator/templates/deployment.yaml + + - it: fails without a pinned trust root + set: + license: + key: "test" + global: + receipts: + enabled: true + signingKey: + existingSecret: receipt-signing-key + asserts: + - failedTemplate: + errorPattern: "trustRoot.existingConfigMap is required" + template: receipt-writer.yaml + + - it: mounts the pinned trust root into the operator + release: + name: rel + set: + license: + key: "test" + global: + receipts: + enabled: true + signingKey: + existingSecret: receipt-signing-key + trustRoot: + existingConfigMap: receipt-trust-root + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: RECEIPTS_WRITER_TRUST_FILE + value: /etc/receipts/trust/trust.json + template: charts/agentic-operator/templates/deployment.yaml + - contains: + path: spec.template.spec.volumes + content: + name: receipts-trust + configMap: + name: receipt-trust-root + items: + - key: trust.json + path: trust.json + template: charts/agentic-operator/templates/deployment.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: RECEIPTS_INSECURE_NO_PIN + value: "true" + template: charts/agentic-operator/templates/deployment.yaml + + - it: insecureNoPin sets the demo-only env + release: + name: rel + set: + license: + key: "test" + global: + receipts: + enabled: true + insecureNoPin: true + signingKey: + existingSecret: receipt-signing-key + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: RECEIPTS_INSECURE_NO_PIN + value: "true" + template: charts/agentic-operator/templates/deployment.yaml diff --git a/charts/values.schema.json b/charts/values.schema.json index 50e9ee4..7e8c419 100644 --- a/charts/values.schema.json +++ b/charts/values.schema.json @@ -366,6 +366,15 @@ }, "token": { "$ref": "#/$defs/secretRef" }, "signingKey": { "$ref": "#/$defs/secretRef" }, + "trustRoot": { + "type": "object", + "properties": { + "existingConfigMap": { "type": "string" }, + "key": { "type": "string", "pattern": "^[-._a-zA-Z0-9]+$" } + }, + "additionalProperties": false + }, + "insecureNoPin": { "type": "boolean" }, "resources": { "$ref": "#/$defs/resources" } } }, diff --git a/charts/values.yaml b/charts/values.yaml index 96d23cc..8a3da5e 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -79,6 +79,17 @@ global: signingKey: existingSecret: "" key: signing-key + # Pinned writer public key. REQUIRED when enabled. The operator verifies + # every returned receipt (signature, entry hash, chain continuity) under + # it. Make the trust file from the signing key, then a ConfigMap: + # agentctl receipts trust-root --signing-key-file key.hex > trust.json + # kubectl create configmap receipt-trust-root --from-file=trust.json + trustRoot: + existingConfigMap: "" + key: trust.json + # Demo only. Accept writer receipts without verifying them. A spoofed + # writer or a MITM can then fake receipts. + insecureNoPin: false resources: requests: cpu: "50m" diff --git a/cmd/agentctl/receipts.go b/cmd/agentctl/receipts.go index 4f160eb..3f3421d 100644 --- a/cmd/agentctl/receipts.go +++ b/cmd/agentctl/receipts.go @@ -29,7 +29,7 @@ func newReceiptsCommand(opts *cliOptions) *cobra.Command { // Receipts work offline. Do not load a kubeconfig. PersistentPreRunE: func(*cobra.Command, []string) error { return opts.validateOutput() }, } - cmd.AddCommand(newReceiptsExportCommand(), newReceiptsVerifyCommand()) + cmd.AddCommand(newReceiptsExportCommand(), newReceiptsVerifyCommand(), newReceiptsTrustRootCommand()) return cmd } @@ -131,3 +131,29 @@ func newReceiptsVerifyCommand() *cobra.Command { cmd.Flags().StringVar(&trustRoot, "trust-root", "", "trust file pinned out of band (receiptspec trust file format)") return cmd } + +func newReceiptsTrustRootCommand() *cobra.Command { + var keyFile string + cmd := &cobra.Command{ + Use: "trust-root", + Short: "Print the pinned trust file for a receipt-writer signing key", + Long: "Reads the hex Ed25519 signing key the receipt-writer uses and prints the trust file " + + "(public key only) for RECEIPTS_WRITER_TRUST_FILE and --trust-root. Run it where the key already lives.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + data, err := os.ReadFile(keyFile) + if err != nil { + return err + } + trust, err := receipts.TrustRootFromKeyHex(string(data)) + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), string(trust)) + return err + }, + } + cmd.Flags().StringVar(&keyFile, "signing-key-file", "", "file holding the hex Ed25519 signing key") + _ = cmd.MarkFlagRequired("signing-key-file") + return cmd +} diff --git a/cmd/main.go b/cmd/main.go index 55492ea..e07a83c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -381,6 +381,9 @@ func buildReceiptsConfig(cfg receipts.Config, logger logr.Logger) controller.Rec return out } out.Writer = w + if cfg.InsecureNoPin && len(cfg.TrustedKeys) == 0 { + logger.Info("WARNING: RECEIPTS_INSECURE_NO_PIN=true. Writer receipts are not verified. Demo only.") + } if ds, err := dataset.NewRemote(cfg.WriterURL, receipts.TokenFile(cfg.TokenFile), cfg.Timeout); err == nil { out.Approvals = ds } else { diff --git a/cmd/main_test.go b/cmd/main_test.go index beab50a..7b430f1 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -63,7 +63,8 @@ func TestBuildReceiptsConfig(t *testing.T) { }{ {"disabled", receipts.Config{}, false}, {"enabled without token file", receipts.Config{Enabled: true, Required: true, WriterURL: "http://w:8080"}, false}, - {"enabled", receipts.Config{Enabled: true, WriterURL: "http://w:8080", TokenFile: "/var/run/token"}, true}, + {"enabled without pin", receipts.Config{Enabled: true, WriterURL: "http://w:8080", TokenFile: "/var/run/token"}, false}, + {"enabled insecure demo", receipts.Config{Enabled: true, WriterURL: "http://w:8080", TokenFile: "/var/run/token", InsecureNoPin: true}, true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/docs/receipts.md b/docs/receipts.md index 4db3ea6..95d05fb 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -139,6 +139,17 @@ Operator env: | `RECEIPTS_WRITER_URL` | | Writer base URL | | `RECEIPTS_WRITER_TOKEN_FILE` | | Bearer token file, read per request | | `RECEIPTS_WRITER_TIMEOUT` | 5s | Per request timeout | +| `RECEIPTS_WRITER_TRUST_FILE` | | Pinned writer public key (trust file). Required when enabled. The operator refuses to start without it | +| `RECEIPTS_INSECURE_NO_PIN` | false | Demo only. Start without a pinned key and accept unverified writer receipts | + +With a pinned key, `RemoteWriter.Append` checks every receipt the writer +returns: it binds to the submitted record, its entry hash recomputes, its +signature verifies under a pinned key inside that key's sequence window, and +its seq and prev hash extend the last accepted head by one. The first append +reads `GET /v1/head` to learn the head. A forged, re-signed, or replayed +receipt fails the append, which fails closed under `RECEIPTS_REQUIRED`. The +head read is not signed. A MITM on the first call can shift the start point, +but it still cannot forge a receipt. Appends are serialized per operator. receipt-writer env: @@ -158,14 +169,23 @@ fields, and trailing data are rejected. Helm: ```sh +(umask 077 && openssl rand -hex 32 > key.hex) kubectl -n agentic-system create secret generic receipt-signing-key \ - --from-literal=signing-key=$(openssl rand -hex 32) + --from-file=signing-key=key.hex +agentctl receipts trust-root --signing-key-file key.hex > trust.json +kubectl -n agentic-system create configmap receipt-trust-root \ + --from-file=trust.json=trust.json helm upgrade --install rel charts -n agentic-system \ --set global.receipts.enabled=true \ --set global.receipts.required=true \ - --set global.receipts.signingKey.existingSecret=receipt-signing-key + --set global.receipts.signingKey.existingSecret=receipt-signing-key \ + --set global.receipts.trustRoot.existingConfigMap=receipt-trust-root ``` +Keep `key.hex` offline after this. `trust.json` holds only the public key. +The chart fails to render with receipts enabled and no `trustRoot` unless +`global.receipts.insecureNoPin=true`, which is for demos only. + The block lives under `global` because the operator Deployment is a subchart. The chart never generates the signing key. It creates the token Secret once unless `token.existingSecret` is set. A NetworkPolicy lets only operator pods diff --git a/pkg/receipts/record_test.go b/pkg/receipts/record_test.go index 3d99409..73c3001 100644 --- a/pkg/receipts/record_test.go +++ b/pkg/receipts/record_test.go @@ -233,8 +233,10 @@ func TestConfigFromEnv(t *testing.T) { wantErr bool }{ {"defaults off", nil, false, false, false}, - {"enabled", map[string]string{"RECEIPTS_ENABLED": "true"}, true, false, false}, - {"required implies enabled", map[string]string{"RECEIPTS_REQUIRED": "true"}, true, true, false}, + {"enabled insecure", map[string]string{"RECEIPTS_ENABLED": "true", "RECEIPTS_INSECURE_NO_PIN": "true"}, true, false, false}, + {"required implies enabled", map[string]string{"RECEIPTS_REQUIRED": "true", "RECEIPTS_INSECURE_NO_PIN": "true"}, true, true, false}, + {"enabled without pin refused", map[string]string{"RECEIPTS_ENABLED": "true"}, false, false, true}, + {"missing trust file refused", map[string]string{"RECEIPTS_ENABLED": "true", "RECEIPTS_WRITER_TRUST_FILE": "/nonexistent/trust.json"}, false, false, true}, {"bad bool", map[string]string{"RECEIPTS_ENABLED": "yes please"}, false, false, true}, {"bad timeout", map[string]string{"RECEIPTS_WRITER_TIMEOUT": "soon"}, false, false, true}, } diff --git a/pkg/receipts/remote.go b/pkg/receipts/remote.go index b46a911..6dbf651 100644 --- a/pkg/receipts/remote.go +++ b/pkg/receipts/remote.go @@ -8,6 +8,7 @@ package receipts import ( "bytes" "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -17,6 +18,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "github.com/Clawdlinux/agentgate/pkg/receiptspec" @@ -44,6 +46,17 @@ type RemoteWriter struct { baseURL string token func() (string, error) client *http.Client + + // trusted pins the writer keys. Append refuses to run without them + // unless insecure is set. + trusted []receiptspec.TrustedKey + insecure bool + + // mu serializes Append so chain continuity is checked in order. + mu sync.Mutex + headKnown bool + headSeq uint64 + headHash [32]byte } // NewRemoteWriter returns a client for baseURL. token is called per request, @@ -81,8 +94,36 @@ func TokenFile(path string) func() (string, error) { } } -// Append posts rec and checks that the returned receipt binds to it. +// Pin sets the writer keys every returned receipt must verify under. +func (w *RemoteWriter) Pin(keys []receiptspec.TrustedKey) error { + if len(keys) == 0 { + return receiptspec.ErrNoTrustedKeys + } + w.trusted = append([]receiptspec.TrustedKey(nil), keys...) + return nil +} + +// AllowUnpinned lets Append accept receipts it cannot verify. Demo only: a +// spoofed writer or a MITM can then return fabricated receipts. +func (w *RemoteWriter) AllowUnpinned() { w.insecure = true } + +// ErrUnpinned means Append was called with no pinned writer key. +var ErrUnpinned = errors.New("receipts: no pinned writer key; set RECEIPTS_WRITER_TRUST_FILE") + +// Append posts rec and checks the returned receipt. It must bind to rec. With +// pinned keys it must also carry a valid entry hash and signature from a +// pinned key, and extend the last accepted head by exactly one. func (w *RemoteWriter) Append(ctx context.Context, rec DecisionRecord) (receiptspec.Receipt, error) { + if len(w.trusted) == 0 && !w.insecure { + return receiptspec.Receipt{}, ErrUnpinned + } + w.mu.Lock() + defer w.mu.Unlock() + if len(w.trusted) > 0 && !w.headKnown { + if err := w.syncHead(ctx); err != nil { + return receiptspec.Receipt{}, err + } + } body, err := json.Marshal(rec) if err != nil { return receiptspec.Receipt{}, err @@ -106,9 +147,57 @@ func (w *RemoteWriter) Append(ctx context.Context, rec DecisionRecord) (receipts if err := receiptspec.VerifyRecordBinding(r, canonical); err != nil { return receiptspec.Receipt{}, fmt.Errorf("receipts: writer receipt does not bind to the record: %w", err) } + if len(w.trusted) == 0 { + return r, nil + } + if err := verifyPinned(r, w.trusted); err != nil { + return receiptspec.Receipt{}, err + } + if r.Seq != w.headSeq+1 || r.PrevHash != w.headHash { + return receiptspec.Receipt{}, fmt.Errorf("receipts: writer receipt seq %d does not extend head %d", r.Seq, w.headSeq) + } + w.headSeq, w.headHash = r.Seq, r.EntryHash return r, nil } +// syncHead reads the writer head once. Later receipts must chain from it. +func (w *RemoteWriter) syncHead(ctx context.Context) error { + data, err := w.do(ctx, http.MethodGet, "/v1/head", nil) + if err != nil { + return err + } + var h HeadResponse + if err := json.Unmarshal(data, &h); err != nil { + return fmt.Errorf("receipts: parse writer head: %w", err) + } + raw, err := hex.DecodeString(h.EntryHash) + if err != nil || len(raw) != 32 { + return errors.New("receipts: writer head entry_hash must be 32 bytes hex") + } + w.headSeq = h.Seq + copy(w.headHash[:], raw) + w.headKnown = true + return nil +} + +// verifyPinned checks r under the pinned key named by its SignerKID, inside +// that key's sequence window. +func verifyPinned(r receiptspec.Receipt, keys []receiptspec.TrustedKey) error { + for _, k := range keys { + if k.KID != r.SignerKID { + continue + } + if r.Seq < k.ValidFromSeq || (k.ValidUntilSeq != nil && r.Seq > *k.ValidUntilSeq) { + return fmt.Errorf("receipts: writer receipt seq %d outside key %s window", r.Seq, k.KID) + } + if err := receiptspec.VerifyReceipt(r, k.PublicKey); err != nil { + return fmt.Errorf("receipts: writer receipt does not verify: %w", err) + } + return nil + } + return fmt.Errorf("receipts: writer receipt signed by unpinned key %q", r.SignerKID) +} + // Ready reports whether the writer answers GET /healthz with 200. func (w *RemoteWriter) Ready(ctx context.Context) bool { req, err := http.NewRequestWithContext(ctx, http.MethodGet, w.baseURL+"/healthz", nil) @@ -175,11 +264,18 @@ type Config struct { WriterURL string TokenFile string Timeout time.Duration + // TrustFile pins the writer public keys (receiptspec trust file shape). + TrustFile string + TrustedKeys []receiptspec.TrustedKey + // InsecureNoPin accepts unverified writer receipts. Demo only. + InsecureNoPin bool } // ConfigFromEnv reads RECEIPTS_ENABLED, RECEIPTS_REQUIRED, -// RECEIPTS_WRITER_URL, RECEIPTS_WRITER_TOKEN_FILE, RECEIPTS_WRITER_TIMEOUT. -// RECEIPTS_REQUIRED=true implies enabled. +// RECEIPTS_WRITER_URL, RECEIPTS_WRITER_TOKEN_FILE, RECEIPTS_WRITER_TIMEOUT, +// RECEIPTS_WRITER_TRUST_FILE, and RECEIPTS_INSECURE_NO_PIN. +// RECEIPTS_REQUIRED=true implies enabled. Enabled without a trust file is an +// error unless RECEIPTS_INSECURE_NO_PIN=true. func ConfigFromEnv(getenv func(string) string) (Config, error) { var c Config var err error @@ -189,15 +285,35 @@ func ConfigFromEnv(getenv func(string) string) (Config, error) { if c.Required, err = envBool(getenv, "RECEIPTS_REQUIRED"); err != nil { return c, err } + if c.InsecureNoPin, err = envBool(getenv, "RECEIPTS_INSECURE_NO_PIN"); err != nil { + return c, err + } c.Enabled = c.Enabled || c.Required c.WriterURL = strings.TrimSpace(getenv("RECEIPTS_WRITER_URL")) c.TokenFile = strings.TrimSpace(getenv("RECEIPTS_WRITER_TOKEN_FILE")) + c.TrustFile = strings.TrimSpace(getenv("RECEIPTS_WRITER_TRUST_FILE")) c.Timeout = 5 * time.Second if v := strings.TrimSpace(getenv("RECEIPTS_WRITER_TIMEOUT")); v != "" { if c.Timeout, err = time.ParseDuration(v); err != nil { return c, fmt.Errorf("receipts: RECEIPTS_WRITER_TIMEOUT: %w", err) } } + if !c.Enabled { + return c, nil + } + if c.TrustFile == "" { + if !c.InsecureNoPin { + return c, errors.New("receipts: RECEIPTS_WRITER_TRUST_FILE is required when receipts are enabled (RECEIPTS_INSECURE_NO_PIN=true is for demos only)") + } + return c, nil + } + data, err := os.ReadFile(c.TrustFile) + if err != nil { + return c, fmt.Errorf("receipts: RECEIPTS_WRITER_TRUST_FILE: %w", err) + } + if c.TrustedKeys, err = receiptspec.LoadTrustedKeys(data); err != nil { + return c, fmt.Errorf("receipts: RECEIPTS_WRITER_TRUST_FILE: %w", err) + } return c, nil } @@ -215,6 +331,16 @@ func (c Config) NewWriter() (Writer, error) { if err != nil { return nil, err } + switch { + case len(c.TrustedKeys) > 0: + if err := w.Pin(c.TrustedKeys); err != nil { + return nil, err + } + case c.InsecureNoPin: + w.AllowUnpinned() + default: + return nil, ErrUnpinned + } return w, nil } diff --git a/pkg/receipts/remote_pin_test.go b/pkg/receipts/remote_pin_test.go new file mode 100644 index 0000000..347bfe1 --- /dev/null +++ b/pkg/receipts/remote_pin_test.go @@ -0,0 +1,193 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "context" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +// tamperServer is a writer that signs with lw, then lets tamper replace the +// receipt it returns. It also serves the real head. +func tamperServer(t *testing.T, lw *LocalWriter, tamper func(n int, rc receiptspec.Receipt) receiptspec.Receipt) *httptest.Server { + t.Helper() + n := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/head" { + seq, h := lw.Head() + _ = json.NewEncoder(w).Encode(HeadResponse{Seq: seq, EntryHash: hex.EncodeToString(h[:]), SignerKID: lw.KID()}) + return + } + var rec DecisionRecord + if err := json.NewDecoder(r.Body).Decode(&rec); err != nil { + http.Error(w, "bad", http.StatusBadRequest) + return + } + rc, err := lw.Append(r.Context(), rec) + if err != nil { + http.Error(w, "append", http.StatusInternalServerError) + return + } + n++ + if tamper != nil { + rc = tamper(n, rc) + } + line, _ := receiptspec.MarshalJSONLReceipt(rc) + _ = json.NewEncoder(w).Encode(AppendResponse{Receipt: line}) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestRemoteWriterVerifiesReceipts(t *testing.T) { + var first receiptspec.Receipt + tests := []struct { + name string + pin func(lw *LocalWriter) receiptspec.TrustedKey + tamper func(n int, rc receiptspec.Receipt) receiptspec.Receipt + wantErr []bool + }{ + {name: "genuine chain", wantErr: []bool{false, false}}, + {name: "forged signature", tamper: func(_ int, rc receiptspec.Receipt) receiptspec.Receipt { + rc.Signature[0] ^= 0xff + return rc + }, wantErr: []bool{true}}, + {name: "forged entry hash", tamper: func(_ int, rc receiptspec.Receipt) receiptspec.Receipt { + rc.EntryHash[0] ^= 0xff + return rc + }, wantErr: []bool{true}}, + {name: "resealed by an attacker key with the pinned kid", tamper: func(_ int, rc receiptspec.Receipt) receiptspec.Receipt { + forged, _ := receiptspec.Seal(rc, receiptspec.NewEd25519Signer(testKey(9))) + forged.SignerKID = rc.SignerKID + return forged + }, wantErr: []bool{true}}, + {name: "wrong pinned key", pin: func(*LocalWriter) receiptspec.TrustedKey { + pub := testKey(2).Public().(ed25519.PublicKey) + return receiptspec.TrustedKey{KID: receiptspec.ComputeKID(pub), PublicKey: pub} + }, wantErr: []bool{true}}, + {name: "replayed receipt", tamper: func(n int, rc receiptspec.Receipt) receiptspec.Receipt { + if n == 1 { + first = rc + return rc + } + return first + }, wantErr: []bool{false, true}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + lw := openTest(t, t.TempDir(), 1) + t.Cleanup(func() { _ = lw.Close() }) + srv := tamperServer(t, lw, tc.tamper) + rw, err := NewRemoteWriter(srv.URL, staticToken("tok"), time.Second) + if err != nil { + t.Fatal(err) + } + key := lw.TrustedKey() + if tc.pin != nil { + key = tc.pin(lw) + } + if err := rw.Pin([]receiptspec.TrustedKey{key}); err != nil { + t.Fatal(err) + } + rec := mustRecord(t, testParams()) + for i, want := range tc.wantErr { + _, err := rw.Append(context.Background(), rec) + if (err != nil) != want { + t.Fatalf("append %d: err = %v, wantErr %v", i+1, err, want) + } + } + }) + } +} + +func TestRemoteWriterRejectsSeqGap(t *testing.T) { + lw := openTest(t, t.TempDir(), 1) + t.Cleanup(func() { _ = lw.Close() }) + srv := tamperServer(t, lw, nil) + rw, _ := NewRemoteWriter(srv.URL, staticToken("tok"), time.Second) + if err := rw.Pin([]receiptspec.TrustedKey{lw.TrustedKey()}); err != nil { + t.Fatal(err) + } + rec := mustRecord(t, testParams()) + if _, err := rw.Append(context.Background(), rec); err != nil { + t.Fatal(err) + } + // Another receipt lands on the writer that this client never accepted. + if _, err := lw.Append(context.Background(), rec); err != nil { + t.Fatal(err) + } + if _, err := rw.Append(context.Background(), rec); err == nil { + t.Fatal("non-continuous seq accepted") + } +} + +func TestRemoteWriterRefusesUnpinned(t *testing.T) { + lw := openTest(t, t.TempDir(), 1) + t.Cleanup(func() { _ = lw.Close() }) + srv := tamperServer(t, lw, nil) + rw, _ := NewRemoteWriter(srv.URL, staticToken("tok"), time.Second) + if _, err := rw.Append(context.Background(), mustRecord(t, testParams())); err != ErrUnpinned { + t.Fatalf("err = %v, want ErrUnpinned", err) + } + if err := rw.Pin(nil); err == nil { + t.Fatal("empty pin accepted") + } +} + +func TestConfigPinsTrustFile(t *testing.T) { + lw := openTest(t, t.TempDir(), 1) + t.Cleanup(func() { _ = lw.Close() }) + trust, err := TrustRootJSON(lw.TrustedKey()) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "trust.json") + if err := os.WriteFile(path, trust, 0o600); err != nil { + t.Fatal(err) + } + env := map[string]string{"RECEIPTS_ENABLED": "true", "RECEIPTS_WRITER_TRUST_FILE": path, "RECEIPTS_WRITER_URL": "http://w:8080", "RECEIPTS_WRITER_TOKEN_FILE": "/t"} + c, err := ConfigFromEnv(func(k string) string { return env[k] }) + if err != nil || len(c.TrustedKeys) != 1 { + t.Fatalf("config = %+v, err %v", c, err) + } + if w, err := c.NewWriter(); err != nil || w == nil { + t.Fatalf("pinned writer not built: %v", err) + } + c.TrustedKeys = nil + if _, err := c.NewWriter(); err == nil { + t.Fatal("writer built without a pin") + } + c.InsecureNoPin = true + if _, err := c.NewWriter(); err != nil { + t.Fatalf("insecure demo writer refused: %v", err) + } +} + +func TestTrustRootFromKeyHexMatchesWriter(t *testing.T) { + lw := openTest(t, t.TempDir(), 1) + t.Cleanup(func() { _ = lw.Close() }) + trust, err := TrustRootFromKeyHex(hex.EncodeToString(testKey(1).Seed())) + if err != nil { + t.Fatal(err) + } + keys, err := receiptspec.LoadTrustedKeys(trust) + if err != nil || len(keys) != 1 || keys[0].KID != lw.KID() { + t.Fatalf("keys = %+v, err %v, want kid %s", keys, err, lw.KID()) + } + if _, err := TrustRootFromKeyHex("zz"); err == nil { + t.Fatal("bad hex accepted") + } +} diff --git a/pkg/receipts/remote_test.go b/pkg/receipts/remote_test.go index f144da2..3ad3b6a 100644 --- a/pkg/receipts/remote_test.go +++ b/pkg/receipts/remote_test.go @@ -7,6 +7,7 @@ package receipts import ( "context" + "encoding/hex" "encoding/json" "errors" "net/http" @@ -21,7 +22,7 @@ import ( // fakeWriterServer is a minimal receipt-writer backed by a LocalWriter. // mutate, when set, changes the record before it is signed. -func fakeWriterServer(t *testing.T, status int, mutate func(*DecisionRecord)) *httptest.Server { +func fakeWriterServer(t *testing.T, status int, mutate func(*DecisionRecord)) (*httptest.Server, *LocalWriter) { t.Helper() lw := openTest(t, t.TempDir(), 1) t.Cleanup(func() { _ = lw.Close() }) @@ -38,6 +39,11 @@ func fakeWriterServer(t *testing.T, status int, mutate func(*DecisionRecord)) *h w.WriteHeader(status) return } + if r.URL.Path == "/v1/head" { + seq, h := lw.Head() + _ = json.NewEncoder(w).Encode(HeadResponse{Seq: seq, EntryHash: hex.EncodeToString(h[:]), SignerKID: lw.KID()}) + return + } var rec DecisionRecord if err := json.NewDecoder(r.Body).Decode(&rec); err != nil { http.Error(w, "bad", http.StatusBadRequest) @@ -53,7 +59,7 @@ func fakeWriterServer(t *testing.T, status int, mutate func(*DecisionRecord)) *h } line, _ := receiptspec.MarshalJSONLReceipt(rc) _ = json.NewEncoder(w).Encode(AppendResponse{Receipt: line}) - })) + })), lw } func staticToken(tok string) func() (string, error) { @@ -76,12 +82,15 @@ func TestRemoteWriterAppend(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - srv := fakeWriterServer(t, tc.status, tc.mutate) + srv, lw := fakeWriterServer(t, tc.status, tc.mutate) defer srv.Close() rw, err := NewRemoteWriter(srv.URL, tc.token, time.Second) if err != nil { t.Fatal(err) } + if err := rw.Pin([]receiptspec.TrustedKey{lw.TrustedKey()}); err != nil { + t.Fatal(err) + } r, err := rw.Append(context.Background(), mustRecord(t, testParams())) if (err != nil) != tc.wantErr { t.Fatalf("err = %v, wantErr %v", err, tc.wantErr) @@ -102,6 +111,7 @@ func TestRemoteWriterTimeoutFailsClosed(t *testing.T) { if err != nil { t.Fatal(err) } + rw.AllowUnpinned() if _, err := rw.Append(context.Background(), mustRecord(t, testParams())); err == nil { t.Fatal("append succeeded against a hung writer") } @@ -115,7 +125,7 @@ func TestRemoteWriterReady(t *testing.T) { status int want bool }{{http.StatusOK, true}, {http.StatusServiceUnavailable, false}} { - srv := fakeWriterServer(t, tc.status, nil) + srv, _ := fakeWriterServer(t, tc.status, nil) rw, _ := NewRemoteWriter(srv.URL, staticToken("tok"), time.Second) if got := rw.Ready(context.Background()); got != tc.want { t.Errorf("status %d: ready = %v, want %v", tc.status, got, tc.want) diff --git a/pkg/receipts/trustroot.go b/pkg/receipts/trustroot.go new file mode 100644 index 0000000..28bed93 --- /dev/null +++ b/pkg/receipts/trustroot.go @@ -0,0 +1,37 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" +) + +// TrustRootFromKeyHex derives the pinned trust file from a hex Ed25519 seed +// (32 bytes) or private key (64 bytes), the format the receipt writer reads. +// Only the public key is written. +func TrustRootFromKeyHex(keyHex string) (json.RawMessage, error) { + raw, err := hex.DecodeString(strings.TrimSpace(keyHex)) + if err != nil { + return nil, fmt.Errorf("receipts: signing key is not hex") + } + var priv ed25519.PrivateKey + switch len(raw) { + case ed25519.SeedSize: + priv = ed25519.NewKeyFromSeed(raw) + case ed25519.PrivateKeySize: + priv = ed25519.PrivateKey(raw) + default: + return nil, fmt.Errorf("receipts: signing key must be %d or %d bytes", ed25519.SeedSize, ed25519.PrivateKeySize) + } + pub := priv.Public().(ed25519.PublicKey) + return TrustRootJSON(receiptspec.TrustedKey{KID: receiptspec.ComputeKID(pub), PublicKey: pub}) +} diff --git a/scripts/demo-claims.sh b/scripts/demo-claims.sh index fc1ea64..d12a78c 100755 --- a/scripts/demo-claims.sh +++ b/scripts/demo-claims.sh @@ -203,9 +203,16 @@ kc create namespace "${NS}" >/dev/null kc create namespace "${DEMO_NS}" >/dev/null kc apply -f "${REPO_ROOT}/config/crd/bases" >/dev/null kc wait --for=condition=Established crd/agentworkloads.agentic.clawdlinux.org --timeout=60s >/dev/null +key_file="${WORK}/signing-key.hex" +(umask 077 && openssl rand -hex 32 >"${key_file}") kc -n "${NS}" create secret generic receipt-signing-key \ - --from-literal=signing-key="$(openssl rand -hex 32)" >/dev/null -note "signing key Secret created (value not printed)" + --from-file=signing-key="${key_file}" >/dev/null +# Pin the writer public key so the operator verifies every receipt it gets. +"${BIN}/agentctl" receipts trust-root --signing-key-file "${key_file}" >"${WORK}/writer-trust.json" +rm -f "${key_file}" +kc -n "${NS}" create configmap receipt-trust-root \ + --from-file=trust.json="${WORK}/writer-trust.json" >/dev/null +note "signing key Secret and pinned trust root ConfigMap created (key not printed, key file removed)" image_repo="${IMAGE%%:*}" image_tag="${IMAGE##*:}" # kindnet enforces NetworkPolicy. The chart's default-deny egress has no rule @@ -231,6 +238,7 @@ run helm --kube-context "kind-${CLUSTER_NAME}" upgrade --install "${RELEASE}" "$ --set global.receipts.enabled=true \ --set global.receipts.required=true \ --set global.receipts.signingKey.existingSecret=receipt-signing-key \ + --set global.receipts.trustRoot.existingConfigMap=receipt-trust-root \ --set global.receipts.image.repository="${image_repo}" \ --set global.receipts.image.tag="${image_tag}" \ --set global.receipts.image.pullPolicy=IfNotPresent \ From db80b41ae34d7aa0687eb5b9518edb21a543bce2 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 03:45:12 +0530 Subject: [PATCH 34/54] fix(receipts): require a signed manifest to pass verify receipts verify and dataset verify now fail when no signed export manifest proves completeness. --allow-prefix keeps the weaker check and prints a WARNING. Fixes F8. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- cmd/agentctl/dataset.go | 10 ++++---- cmd/agentctl/dataset_test.go | 14 +++++++++- cmd/agentctl/receipts.go | 36 ++++++++++++++++++-------- cmd/agentctl/receipts_test.go | 14 ++++++++++ docs/approvals.md | 4 ++- docs/receipts.md | 7 +++++ pkg/dataset/verify.go | 6 ++++- pkg/receipts/export.go | 48 ++++++++++++++++++++++++++++++++--- pkg/receipts/local_test.go | 17 +++++++++++++ 9 files changed, 135 insertions(+), 21 deletions(-) diff --git a/cmd/agentctl/dataset.go b/cmd/agentctl/dataset.go index 9df93bb..12a8e1e 100644 --- a/cmd/agentctl/dataset.go +++ b/cmd/agentctl/dataset.go @@ -79,12 +79,14 @@ func newDatasetExportCommand() *cobra.Command { func newDatasetVerifyCommand() *cobra.Command { var trustRoot string + var allowPrefix bool cmd := &cobra.Command{ Use: "verify ", Short: "Verify an approval dataset export offline", Long: "Verifies the receipt chain and decision records like 'agentctl receipts verify', then checks " + "that every example's receipt_seq and entry hash exist in the chain and that its label matches " + - "the human decision receipt.", + "the human decision receipt. A pass requires a signed export manifest that proves completeness " + + "unless --allow-prefix is set.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var trusted []receiptspec.TrustedKey @@ -125,12 +127,10 @@ func newDatasetVerifyCommand() *cobra.Command { if len(rep.Failures) == 0 { fmt.Fprintf(stdout, "PASS: %d approval examples bound to their receipts\n", rep.ExamplesBound) } - if !rep.OK() { - return errVerifyFailed - } - return nil + return completenessVerdict(stdout, stderr, rep.PrefixOK(), rep.Receipts.Complete(), allowPrefix) }, } cmd.Flags().StringVar(&trustRoot, "trust-root", "", "trust file pinned out of band (receiptspec trust file format)") + cmd.Flags().BoolVar(&allowPrefix, "allow-prefix", false, allowPrefixHelp) return cmd } diff --git a/cmd/agentctl/dataset_test.go b/cmd/agentctl/dataset_test.go index 7dd7fbc..070a132 100644 --- a/cmd/agentctl/dataset_test.go +++ b/cmd/agentctl/dataset_test.go @@ -126,7 +126,19 @@ func TestDatasetVerify(t *testing.T) { }) } - e, _ := datasetExport(t) + e, approvals := datasetExport(t) + stripped := filepath.Join(t.TempDir(), "stripped") + if err := receipts.StripToPrefix(e, 0).WriteDir(stripped); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(stripped, dataset.ApprovalsFile), []byte(approvals), 0o600) + if _, errOut, err := runAgentctl(t, "dataset", "verify", stripped); !errors.Is(err, errVerifyFailed) || !strings.Contains(errOut, "FAIL: completeness not proven") { + t.Fatalf("manifest-free dataset err = %v, stderr = %q", err, errOut) + } + if _, errOut, err := runAgentctl(t, "dataset", "verify", "--allow-prefix", stripped); err != nil || !strings.Contains(errOut, "WARNING: completeness NOT proven") { + t.Fatalf("allow-prefix err = %v, stderr = %q", err, errOut) + } + dir := filepath.Join(t.TempDir(), "noapprovals") _ = e.WriteDir(dir) if _, _, err := runAgentctl(t, "dataset", "verify", dir); err == nil || !strings.Contains(err.Error(), dataset.ApprovalsFile) { diff --git a/cmd/agentctl/receipts.go b/cmd/agentctl/receipts.go index 3f3421d..e15aebf 100644 --- a/cmd/agentctl/receipts.go +++ b/cmd/agentctl/receipts.go @@ -9,6 +9,7 @@ import ( "bytes" "errors" "fmt" + "io" "os" "time" @@ -73,12 +74,14 @@ func newReceiptsExportCommand() *cobra.Command { func newReceiptsVerifyCommand() *cobra.Command { var trustRoot string + var allowPrefix bool cmd := &cobra.Command{ Use: "verify ", Short: "Verify a receipt export offline", Long: "Verifies the receipt chain with the same check agentgate-verify runs, then checks that every " + "decision record binds to its receipt. Pass --trust-root with a trust file pinned out of band; " + - "otherwise the export's own trust.json or embedded keys are used.", + "otherwise the export's own trust.json or embedded keys are used. A pass requires a signed " + + "export manifest that proves completeness unless --allow-prefix is set.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var trusted []receiptspec.TrustedKey @@ -107,11 +110,6 @@ func newReceiptsVerifyCommand() *cobra.Command { c := rep.Chain if c.OK { fmt.Fprintf(stdout, "PASS: %d receipts verified, head seq=%d hash=%x\n", c.VerifiedCount, c.HeadSeq, c.HeadEntryHash[:8]) - if c.Complete { - fmt.Fprintln(stdout, "completeness: proven against the signed export manifest") - } else { - fmt.Fprintln(stdout, "completeness: not claimed") - } } else { fmt.Fprintf(stderr, "FAIL: seq=%d reason=%s (%d of %d receipts verified before failure)\n", c.FailedAtSeq, c.Reason, c.VerifiedCount, c.TotalReceipts) @@ -122,16 +120,34 @@ func newReceiptsVerifyCommand() *cobra.Command { if len(rep.RecordFailures) == 0 { fmt.Fprintf(stdout, "PASS: %d decision records bound to their receipts\n", rep.RecordsBound) } - if !rep.OK() { - return errVerifyFailed - } - return nil + return completenessVerdict(stdout, stderr, rep.PrefixOK(), rep.Complete(), allowPrefix) }, } cmd.Flags().StringVar(&trustRoot, "trust-root", "", "trust file pinned out of band (receiptspec trust file format)") + cmd.Flags().BoolVar(&allowPrefix, "allow-prefix", false, allowPrefixHelp) return cmd } +const allowPrefixHelp = "accept an export without a signed manifest; proves only that the receipts present are valid, not that none were removed" + +// completenessVerdict turns a verification into an exit status. Without +// allowPrefix a pass needs a signed manifest that proves completeness. +func completenessVerdict(stdout, stderr io.Writer, prefixOK, complete, allowPrefix bool) error { + if !prefixOK { + return errVerifyFailed + } + if complete { + fmt.Fprintln(stdout, "completeness: proven against the signed export manifest") + return nil + } + if allowPrefix { + fmt.Fprintln(stderr, "WARNING: completeness NOT proven. Prefix-only verification (--allow-prefix). Trailing receipts or records may have been removed.") + return nil + } + fmt.Fprintln(stderr, "FAIL: completeness not proven: no signed export manifest matched the chain head. Use --allow-prefix to accept a prefix.") + return errVerifyFailed +} + func newReceiptsTrustRootCommand() *cobra.Command { var keyFile string cmd := &cobra.Command{ diff --git a/cmd/agentctl/receipts_test.go b/cmd/agentctl/receipts_test.go index b97f33d..b6e4846 100644 --- a/cmd/agentctl/receipts_test.go +++ b/cmd/agentctl/receipts_test.go @@ -72,6 +72,7 @@ func TestReceiptsVerify(t *testing.T) { mutate func(*receipts.Export) bundle bool trustRoot []byte + extra []string wantOut string wantErr string }{ @@ -88,6 +89,12 @@ func TestReceiptsVerify(t *testing.T) { e.RecordsJSONL = strings.Join(lines[1:], "") }, wantErr: "FAIL: record seq=1 reason=record_missing"}, {name: "pinned trust root mismatch fails", trustRoot: otherTrust, wantErr: "FAIL: manifest"}, + {name: "stripped manifest and tail fails", mutate: func(e *receipts.Export) { + *e = receipts.StripToPrefix(*e, 1) + }, wantErr: "FAIL: completeness not proven"}, + {name: "stripped tail passes only with allow-prefix", mutate: func(e *receipts.Export) { + *e = receipts.StripToPrefix(*e, 1) + }, extra: []string{"--allow-prefix"}, wantOut: "PASS: 2 receipts verified", wantErr: "WARNING: completeness NOT proven"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -110,7 +117,14 @@ func TestReceiptsVerify(t *testing.T) { _ = os.WriteFile(trustPath, tc.trustRoot, 0o600) args = append(args, "--trust-root", trustPath) } + args = append(args, tc.extra...) out, errOut, err := runAgentctl(t, args...) + if tc.wantOut != "" && tc.wantErr != "" { + if err != nil || !strings.Contains(out, tc.wantOut) || !strings.Contains(errOut, tc.wantErr) { + t.Fatalf("err = %v, stdout = %q, stderr = %q", err, out, errOut) + } + return + } if tc.wantErr != "" { if !errors.Is(err, errVerifyFailed) || !strings.Contains(errOut, tc.wantErr) { t.Fatalf("err = %v, stderr = %q, want %q", err, errOut, tc.wantErr) diff --git a/docs/approvals.md b/docs/approvals.md index 2cd6bfb..fa9dab3 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -140,7 +140,9 @@ agentctl dataset export --writer http://localhost:8080 \ agentctl dataset verify ./dataset --trust-root ./pinned-trust.json ``` -`verify` runs the same chain and record checks as `agentctl receipts verify`. +`verify` runs the same chain and record checks as `agentctl receipts verify`, +including the signed manifest completeness check. `--allow-prefix` works the +same way and prints the same WARNING. Then for each example it checks that `receipt_seq` and `receipt_entry_hash` exist in the chain, that the receipt is a `human` record, and that the label, pending id, approver hash, reason hash, and workload match it. It prints PASS diff --git a/docs/receipts.md b/docs/receipts.md index 95d05fb..0da7e7c 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -214,6 +214,13 @@ then `receiptspec.VerifyRecordBinding` for every record. It prints PASS or FAIL lines and exits nonzero on failure. Without `--trust-root` it falls back to the export's own `trust.json`, which proves consistency, not origin. +A pass needs a signed export manifest whose head matches the last receipt. +That proves completeness: nobody stripped trailing receipts and records. +An export with no manifest fails with `FAIL: completeness not proven`. +`--allow-prefix` accepts it anyway and prints `WARNING: completeness NOT +proven`. That mode only proves the receipts present are valid. Use it for +partial exports you made yourself, never as evidence of the full history. + The chain alone also verifies with AgentGate: ```sh diff --git a/pkg/dataset/verify.go b/pkg/dataset/verify.go index 2298406..77e5c62 100644 --- a/pkg/dataset/verify.go +++ b/pkg/dataset/verify.go @@ -34,9 +34,13 @@ type Report struct { Failures []Failure } -// OK is true when the receipt export verifies and every example binds. +// OK is true when the receipt export verifies with proven completeness and +// every example binds. func (r Report) OK() bool { return r.Receipts.OK() && len(r.Failures) == 0 } +// PrefixOK is OK without the completeness proof. +func (r Report) PrefixOK() bool { return r.Receipts.PrefixOK() && len(r.Failures) == 0 } + // ReadDir reads a dataset export dir: a receipts export plus approvals.jsonl. func ReadDir(dir string) (receipts.Export, string, error) { e, err := receipts.ReadExport(dir) diff --git a/pkg/receipts/export.go b/pkg/receipts/export.go index 16691d0..6cb6d02 100644 --- a/pkg/receipts/export.go +++ b/pkg/receipts/export.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "sort" + "strings" "github.com/Clawdlinux/agentgate/pkg/receiptspec" ) @@ -104,12 +105,21 @@ const ( // Report is the result of VerifyExport. type Report struct { Chain receiptspec.VerifyResult + HasManifest bool RecordsBound int RecordFailures []RecordFailure } -// OK is true when the chain verifies and every record binds to its receipt. -func (r Report) OK() bool { return r.Chain.OK && len(r.RecordFailures) == 0 } +// Complete is true when a signed manifest proved the chain head. +func (r Report) Complete() bool { return r.HasManifest && r.Chain.Complete } + +// PrefixOK is true when the receipts present verify and bind. It does not +// prove that no trailing receipts or records were removed. +func (r Report) PrefixOK() bool { return r.Chain.OK && len(r.RecordFailures) == 0 } + +// OK is true when the chain verifies, every record binds, and a signed +// manifest proves completeness. +func (r Report) OK() bool { return r.PrefixOK() && r.Complete() } // VerifyExport checks the receipt chain with receiptspec.VerifyBundle, the // entry point agentgate-verify uses, then checks every decision record with @@ -130,7 +140,7 @@ func VerifyExport(e Export, trusted []receiptspec.TrustedKey) (Report, error) { if err != nil { return Report{}, err } - rep := Report{Chain: res} + rep := Report{Chain: res, HasManifest: b.Manifest != nil} records := map[uint64]json.RawMessage{} scanner := bufio.NewScanner(bytes.NewReader([]byte(e.RecordsJSONL))) @@ -172,6 +182,38 @@ func VerifyExport(e Export, trusted []receiptspec.TrustedKey) (Report, error) { return rep, nil } +// StripToPrefix drops the manifest line and the last n receipts and records. +// It models the truncation attack that only a signed manifest can detect. +func StripToPrefix(e Export, n int) Export { + var keep []string + var rec []string + for _, l := range strings.SplitAfter(e.ReceiptsJSONL, "\n") { + if strings.TrimSpace(l) == "" { + continue + } + switch receiptspec.DetectJSONLLineType([]byte(l)) { + case "manifest": + case "receipt": + rec = append(rec, l) + default: + keep = append(keep, l) + } + } + if n > len(rec) { + n = len(rec) + } + e.ReceiptsJSONL = strings.Join(append(keep, rec[:len(rec)-n]...), "") + recs := strings.SplitAfter(strings.TrimRight(e.RecordsJSONL, "\n"), "\n") + if n > len(recs) { + n = len(recs) + } + e.RecordsJSONL = strings.Join(recs[:len(recs)-n], "") + if e.RecordsJSONL != "" && !strings.HasSuffix(e.RecordsJSONL, "\n") { + e.RecordsJSONL += "\n" + } + return e +} + // IsManifestError reports whether err is a manifest tamper finding. func IsManifestError(err error) bool { var m *receiptspec.ManifestError diff --git a/pkg/receipts/local_test.go b/pkg/receipts/local_test.go index c73a3ca..7acc27a 100644 --- a/pkg/receipts/local_test.go +++ b/pkg/receipts/local_test.go @@ -287,3 +287,20 @@ func TestLocalWriterBrokenFailsClosed(t *testing.T) { t.Fatalf("second append err = %v, want ErrWriterBroken", err) } } + +func TestStrippedPrefixIsNotComplete(t *testing.T) { + w := openTest(t, t.TempDir(), 1) + defer func() { _ = w.Close() }() + appendN(t, w, 4) + e := StripToPrefix(mustExport(t, w), 2) + rep, err := VerifyExport(e, nil) + if err != nil { + t.Fatal(err) + } + if !rep.PrefixOK() || rep.RecordsBound != 2 { + t.Fatalf("prefix should still verify: %+v", rep) + } + if rep.HasManifest || rep.Complete() || rep.OK() { + t.Fatalf("stripped export must not be OK: %+v", rep) + } +} From 9d0945b86372010e22f02ff5db97ab8a0d11bbe1 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 03:53:02 +0530 Subject: [PATCH 35/54] fix(receipts): bind the executed payload digest Records carry payload_sha256, a domain-separated digest of the exact execute_action payload, and input_hash covers it. Approval records bind the approved or edited payload. Fixes F15. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- docs/receipts.md | 16 +++-- .../controller/agentworkload_controller.go | 11 ++- .../agentworkload_controller_approval_test.go | 27 +++++++ internal/controller/approvals.go | 8 ++- internal/controller/receipts.go | 7 +- pkg/receipts/record.go | 71 +++++++++++++++---- pkg/receipts/record_test.go | 39 ++++++++++ 7 files changed, 151 insertions(+), 28 deletions(-) diff --git a/docs/receipts.md b/docs/receipts.md index 0da7e7c..f581793 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -41,15 +41,23 @@ Each receipt is a v1 AgentGate receipt: The decision record sits beside the receipt in `records.jsonl`. Fields: `schema_version`, `workload` (namespace, name, uid), `action`, `layer` (`invariant`, `rules`, `threshold`, `human`, `model`), `outcome`, -`reasons` (rule ID plus reason), `input_hash`, `declared`, `observed`, +`reasons` (rule ID plus reason), `input_hash`, `payload_sha256`, `declared`, `observed`, `claimed`, `policy_packs`, `threshold_mode`, `replay_hint`. A `model` block is added whenever the decision model scored the action. See [model block](#model-block). Human decisions add an `approval` block and set `human_principal` to the approver. See [approvals](approvals.md). `input_hash` is the SHA-256 of the canonical JSON of the full decision input: -declared, observed, and agent-claimed (labelled `agent_claimed`). It covers -the raw values without storing them. +declared, observed, agent-claimed (labelled `agent_claimed`), and +`payload_sha256`. It covers the raw values without storing them. + +`payload_sha256` is the digest of the exact `execute_action` argument map: +`{"action", "params", "confidence"}`. It is `sha256("clawdlinux.decision.payload.v1" +|| 0x00 || canonical JSON)`. Canonical JSON sorts object keys and normalizes +numbers through float64, so a JSON round trip gives the same digest. A human +record carries the digest of the payload the human approved or edited. To +check which payload was authorized, recompute the digest from the payload you +hold and compare. The payload itself is never stored. Numbers are integers or decimal strings. Never floats. Canonical JSON follows receiptspec `DECISION.md` (RFC 8785 subset). @@ -84,7 +92,7 @@ In shadow mode `outcome` equals `base_outcome`. In escalate mode the record ## What is not recorded -- Raw action params and the proposal payload. +- Raw action params and the proposal payload. Only `payload_sha256`. - Matched content: no email, phone, card, or credential value. Only class names like `email`. - Declared purpose, agent-claimed intent, caller identity. Hashed only. diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index be9411b..bc8b21f 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -53,6 +53,7 @@ import ( "github.com/Clawdlinux/agentic-operator-core/pkg/mcp" "github.com/Clawdlinux/agentic-operator-core/pkg/metrics" "github.com/Clawdlinux/agentic-operator-core/pkg/multitenancy" + "github.com/Clawdlinux/agentic-operator-core/pkg/receipts" "github.com/Clawdlinux/agentic-operator-core/pkg/resilience" "github.com/Clawdlinux/agentic-operator-core/pkg/routing" "github.com/Clawdlinux/agentic-operator-core/pkg/rules/packs" @@ -626,8 +627,10 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques result := decision.Decide(layers) // The model runs last and can only tighten: escalate-only. result, modelBlock := r.applyDecisionModel(ctx, &workload, actionName, decisionInput, result) + // The receipt binds the digest of exactly this execute_action payload. + executeParams := receipts.ExecutionPayload(actionName, proposal, confidenceStr) // Write-ahead: the receipt is stored before the action can run. - result, recorded := r.recordDecision(ctx, &workload, actionName, decisionInput, layers, result, opaPolicyMode, modelBlock) + result, recorded := r.recordDecision(ctx, &workload, actionName, decisionInput, executeParams, layers, result, opaPolicyMode, modelBlock) decisiontrace.SetResult(decisionSpan, result.Layer, string(result.Outcome)) decisionSpan.End() log.Info("decision", "outcome", result.Outcome, "layer", result.Layer, "destination", decisionInput.Observed.Destination, "dataClasses", decisionInput.Observed.DataClasses, "reasons", result.Reasons) @@ -657,12 +660,6 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Step 5a: Execute approved action via MCP log.Info("OPA approved action, executing", "action", action.Name) - executeParams := map[string]interface{}{ - "action": action.Name, - "params": proposal, - "confidence": confidenceStr, - } - execution, err := mcpClient.CallTool("execute_action", executeParams) if err != nil { log.Error(err, "failed to execute action", "action", action.Name) diff --git a/internal/controller/agentworkload_controller_approval_test.go b/internal/controller/agentworkload_controller_approval_test.go index 8cf03ea..3efc8f8 100644 --- a/internal/controller/agentworkload_controller_approval_test.go +++ b/internal/controller/agentworkload_controller_approval_test.go @@ -48,6 +48,7 @@ type executeLog struct { mu sync.Mutex actions []string tools []string + params []map[string]any } func (l *executeLog) snapshot() ([]string, []string) { @@ -70,6 +71,7 @@ func newExecuteLoggingMCP(t *testing.T, scenario mockMCPScenario, events *eventL if req.Tool == "execute_action" { name, _ := req.Params["action"].(string) l.actions = append(l.actions, name) + l.params = append(l.params, req.Params) } l.mu.Unlock() events.add(req.Tool) @@ -264,6 +266,31 @@ func TestReconcile_ApprovalDecisions(t *testing.T) { } } +func TestReconcile_ReceiptBindsExecutedPayload(t *testing.T) { + t.Parallel() + env := newApprovalEnv(t, "approval-payload", "", "", nil) + env.decide(t, map[string]string{approval.AnnotationDecision: approval.Edit, + approval.AnnotationEdit: `{"name":"scale","description":"scale to 2","params":{"replicas":2}}`}, true, env.pendingI) + env.reconcile(t) + env.exec.mu.Lock() + sent := env.exec.params + env.exec.mu.Unlock() + if len(sent) != 1 { + t.Fatalf("execute_action calls = %d", len(sent)) + } + want, err := receipts.PayloadSHA256(sent[0]) + if err != nil { + t.Fatal(err) + } + human := env.writer.records[len(env.writer.records)-1] + if human.Layer != receipts.LayerHuman || human.PayloadSHA256 != want { + t.Fatalf("human receipt payload_sha256 = %q, want digest of the sent payload %q", human.PayloadSHA256, want) + } + if env.writer.records[0].PayloadSHA256 == "" || env.writer.records[0].PayloadSHA256 == want { + t.Fatalf("original receipt must bind the original payload, got %q", env.writer.records[0].PayloadSHA256) + } +} + func TestReconcile_ApprovalHumanReceiptBeforeExecute(t *testing.T) { t.Parallel() env := newApprovalEnv(t, "approval-order", "", "", nil) diff --git a/internal/controller/approvals.go b/internal/controller/approvals.go index 1443e8d..30fdb94 100644 --- a/internal/controller/approvals.go +++ b/internal/controller/approvals.go @@ -263,6 +263,8 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age Claimed: decisioninput.AgentClaimed{Confidence: &confidence, ClusterHealth: claimedHealth, Intent: tgt.description}, } + execPayload := receipts.ExecutionPayload(tgt.name, tgt.payload, p.Confidence) + // Approve and edit re-run every layer on the action that would execute. var layers decision.Layers blocked := false @@ -291,6 +293,7 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age Workload: workloadRef(wl), Action: tgt.name, Input: in, + Payload: execPayload, PolicyPacks: wl.Spec.PolicyPacks, ThresholdMode: mode, Approval: receipts.Approval{ @@ -334,7 +337,7 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age denyCtx, denySpan := decisiontrace.StartEvaluation(parentCtx, wl.Namespace, wl.Name, wl.Spec.DecisionModelMode()) decisiontrace.SetRules(denySpan, nil, wl.Spec.PolicyPacks) decisiontrace.AddRuleIDs(denyCtx, denySpan, decisiontrace.RuleIDs(ctx)) - denied, _ := r.recordDecision(denyCtx, wl, tgt.name, in, layers, decision.Decide(layers), mode, nil) + denied, _ := r.recordDecision(denyCtx, wl, tgt.name, in, execPayload, layers, decision.Decide(layers), mode, nil) decisiontrace.SetResult(denySpan, denied.Layer, string(denied.Outcome)) denySpan.End() return r.finishApproval(ctx, wl, d.Label, approvalDenied, d.ApproverSHA256(), receiptSeq(humanReceipt), @@ -345,8 +348,7 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age if err := r.Status().Update(ctx, wl); err != nil { return ctrl.Result{}, err } - params := map[string]interface{}{"action": tgt.name, "params": tgt.payload, "confidence": p.Confidence} - _, execErr := mcp.NewMCPClient(endpoint).CallTool("execute_action", params) + _, execErr := mcp.NewMCPClient(endpoint).CallTool("execute_action", execPayload) executed := agenticv1alpha1.Action{Name: tgt.name, Description: tgt.description, Confidence: p.Confidence, Timestamp: &now} if execErr != nil { log.Error(execErr, "failed to execute approved action", "action", tgt.name) diff --git a/internal/controller/receipts.go b/internal/controller/receipts.go index 1092964..a4e6003 100644 --- a/internal/controller/receipts.go +++ b/internal/controller/receipts.go @@ -64,12 +64,13 @@ func (r *AgentWorkloadReconciler) recordDecision( wl *agenticv1alpha1.AgentWorkload, action string, in decisioninput.Input, + payload map[string]any, layers decision.Layers, result decision.Result, thresholdMode string, model *receipts.Model, ) (decision.Result, decided) { - rec, err := newRecord(wl, action, in, layers, result, thresholdMode, model) + rec, err := newRecord(wl, action, in, payload, layers, result, thresholdMode, model) out := decided{record: rec} if !r.Receipts.Enabled { return result, out @@ -94,7 +95,7 @@ func (r *AgentWorkloadReconciler) recordDecision( } result = decision.Decide(layers) decisiontrace.AddRuleIDs(ctx, trace.SpanFromContext(ctx), []string{invariants.ReceiptsOrFailClosed}) - out.record, _ = newRecord(wl, action, in, layers, result, thresholdMode, model) + out.record, _ = newRecord(wl, action, in, payload, layers, result, thresholdMode, model) return result, out } @@ -102,6 +103,7 @@ func newRecord( wl *agenticv1alpha1.AgentWorkload, action string, in decisioninput.Input, + payload map[string]any, layers decision.Layers, result decision.Result, thresholdMode string, @@ -111,6 +113,7 @@ func newRecord( Workload: workloadRef(wl), Action: action, Input: in, + Payload: payload, Layers: layers, Result: result, PolicyPacks: wl.Spec.PolicyPacks, diff --git a/pkg/receipts/record.go b/pkg/receipts/record.go index c3884e7..f7ab1b7 100644 --- a/pkg/receipts/record.go +++ b/pkg/receipts/record.go @@ -10,7 +10,9 @@ Licensed under the Apache License, Version 2.0. package receipts import ( + "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "sort" "strconv" @@ -166,6 +168,7 @@ type DecisionRecord struct { Outcome string `json:"outcome"` Reasons []Reason `json:"reasons"` InputHash string `json:"input_hash"` + PayloadSHA256 string `json:"payload_sha256"` Declared DeclaredSummary `json:"declared"` Observed ObservedSummary `json:"observed"` Claimed ClaimedSummary `json:"claimed"` @@ -213,9 +216,11 @@ func OutcomeForLabel(label string) (string, error) { // of the action the human decided on: the original for approve and reject, // the edited action for edit. type HumanParams struct { - Workload Workload - Action string - Input input.Input + Workload Workload + Action string + Input input.Input + // Payload is the execution payload the decision applies to. + Payload map[string]any PolicyPacks []string ThresholdMode string Approval Approval @@ -234,6 +239,7 @@ func NewHumanRecord(p HumanParams) (DecisionRecord, error) { Workload: p.Workload, Action: p.Action, Input: p.Input, + Payload: p.Payload, Result: decision.Result{Outcome: decision.Allow, Layer: decision.LayerThreshold}, PolicyPacks: p.PolicyPacks, ThresholdMode: p.ThresholdMode, @@ -260,9 +266,12 @@ func NewHumanRecord(p HumanParams) (DecisionRecord, error) { // Params are the inputs to NewDecisionRecord. type Params struct { - Workload Workload - Action string - Input input.Input + Workload Workload + Action string + Input input.Input + // Payload is the execution payload sent if the action runs. Only its + // digest is recorded. + Payload map[string]any Layers decision.Layers Result decision.Result PolicyPacks []string @@ -278,7 +287,11 @@ func NewDecisionRecord(p Params) (DecisionRecord, error) { if err != nil { return DecisionRecord{}, err } - inputHash, err := InputHash(p.Input) + payloadSHA, err := PayloadSHA256(p.Payload) + if err != nil { + return DecisionRecord{}, err + } + inputHash, err := InputHash(p.Input, payloadSHA) if err != nil { return DecisionRecord{}, err } @@ -302,6 +315,7 @@ func NewDecisionRecord(p Params) (DecisionRecord, error) { Outcome: outcome, Reasons: ReasonsFromLayers(p.Layers), InputHash: inputHash, + PayloadSHA256: payloadSHA, Declared: DeclaredSummary{ SHA256: declHash, DecisionType: p.Input.Declared.DecisionType, @@ -453,15 +467,48 @@ func clip(s string, max int, fallback string) string { } // InputHash is the SHA-256 hex of the canonical JSON of the full input, -// agent claims included under "agent_claimed". -func InputHash(in input.Input) (string, error) { +// agent claims included under "agent_claimed", and the payload digest. +func InputHash(in input.Input, payloadSHA256 string) (string, error) { return hashHex(map[string]any{ - "declared": canonDeclared(in.Declared), - "observed": canonObserved(in.Observed), - "agent_claimed": canonClaimed(in.Claimed), + "declared": canonDeclared(in.Declared), + "observed": canonObserved(in.Observed), + "agent_claimed": canonClaimed(in.Claimed), + "payload_sha256": payloadSHA256, }) } +// PayloadDomain separates payload digests from every other hash. +const PayloadDomain = "clawdlinux.decision.payload.v1" + +// ExecutionPayload is the execute_action argument map. Build it here so the +// digest covers exactly what is sent. +func ExecutionPayload(action string, params map[string]any, confidence string) map[string]any { + return map[string]any{"action": action, "params": params, "confidence": confidence} +} + +// PayloadSHA256 is the hex SHA-256 of the domain, a NUL, and the canonical +// JSON of payload. Numbers are normalized through float64 and object keys +// are sorted, so a JSON round trip of the payload gives the same digest. +func PayloadSHA256(payload map[string]any) (string, error) { + raw, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("receipts: payload: %w", err) + } + var norm any + if err := json.Unmarshal(raw, &norm); err != nil { + return "", fmt.Errorf("receipts: payload: %w", err) + } + canon, err := json.Marshal(norm) + if err != nil { + return "", fmt.Errorf("receipts: payload: %w", err) + } + h := sha256.New() + h.Write([]byte(PayloadDomain)) + h.Write([]byte{0}) + h.Write(canon) + return hex.EncodeToString(h.Sum(nil)), nil +} + func canonDeclared(d input.Declared) map[string]any { return map[string]any{ "purpose": d.Purpose, diff --git a/pkg/receipts/record_test.go b/pkg/receipts/record_test.go index 73c3001..689217a 100644 --- a/pkg/receipts/record_test.go +++ b/pkg/receipts/record_test.go @@ -262,3 +262,42 @@ func TestConfigFromEnv(t *testing.T) { t.Fatal("missing URL accepted") } } + +func TestPayloadDigest(t *testing.T) { + payload := ExecutionPayload("refund", map[string]any{"order": "o-1", "amount": 42}, "0.88") + got, err := PayloadSHA256(payload) + if err != nil { + t.Fatal(err) + } + // sha256("clawdlinux.decision.payload.v1\x00" + canonical JSON) + const want = "dc707fc38b69443231f93c176c2e083b53e0684073bcc5812f5808aa95b43e90" + if got != want { + t.Fatalf("payload digest = %s, want %s", got, want) + } + var roundTrip map[string]any + raw, _ := json.Marshal(payload) + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&roundTrip); err != nil { + t.Fatal(err) + } + if again, _ := PayloadSHA256(roundTrip); again != want { + t.Fatalf("round trip digest = %s, want %s", again, want) + } + + p := testParams() + p.Payload = payload + rec := mustRecord(t, p) + if rec.PayloadSHA256 != want { + t.Fatalf("record payload_sha256 = %s", rec.PayloadSHA256) + } + p.Payload = ExecutionPayload("refund", map[string]any{"order": "o-1", "amount": 4200}, "0.88") + other := mustRecord(t, p) + if other.PayloadSHA256 == want || other.InputHash == rec.InputHash { + t.Fatal("changing the executed params must change payload_sha256 and input_hash") + } + data, _ := json.Marshal(rec) + if strings.Contains(string(data), "o-1") || !strings.Contains(string(data), `"payload_sha256":"`+want+`"`) { + t.Fatalf("record must carry only the payload digest: %s", data) + } +} From befe631af837ba05b7800f3996e301025fa601f7 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:04:51 +0530 Subject: [PATCH 36/54] fix(approval): HMAC the approval stamp The webhook adds clawdlinux.org/approval-mac, an HMAC-SHA256 over the workload UID, pending id, decision digest, approver stamp digest, and pending payload digest. The controller verifies it with hmac.Equal and refuses to act on a missing key or a bad MAC. Approvals fail closed. Key comes from APPROVAL_STAMP_KEY_FILE, wired in the chart as global.approvals.stampKey. Fixes F2. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- api/v1alpha1/agentworkload_admission.go | 27 +++-- api/v1alpha1/agentworkload_admission_test.go | 28 ++++- api/v1alpha1/agentworkload_webhook.go | 4 +- .../templates/deployment.yaml | 22 ++++ charts/tests/approval_stamp_key_test.yaml | 65 +++++++++++ charts/values.schema.json | 16 +++ charts/values.yaml | 9 ++ cmd/main.go | 20 ++++ cmd/main_test.go | 27 +++++ docs/approvals.md | 59 +++++++++- .../controller/agentworkload_controller.go | 1 + .../agentworkload_controller_approval_test.go | 44 +++++++- internal/controller/approvals.go | 15 ++- internal/controller/decision_tracing_test.go | 1 + pkg/approval/approval.go | 106 +++++++++++++++--- pkg/approval/approval_test.go | 78 ++++++++++++- scripts/demo-claims.sh | 7 ++ 17 files changed, 487 insertions(+), 42 deletions(-) create mode 100644 charts/tests/approval_stamp_key_test.yaml diff --git a/api/v1alpha1/agentworkload_admission.go b/api/v1alpha1/agentworkload_admission.go index 738fec0..8228375 100644 --- a/api/v1alpha1/agentworkload_admission.go +++ b/api/v1alpha1/agentworkload_admission.go @@ -19,7 +19,16 @@ import ( // workloadAdmission adapts the AgentWorkload Default and Validate methods to // the controller-runtime webhook builder, and stamps approval decisions. -type workloadAdmission struct{} +type workloadAdmission struct { + stampKey []byte +} + +// approvalStampKey is the HMAC key the webhook stamps decisions with. +var approvalStampKey []byte + +// SetApprovalStampKey sets the approval stamp key before the webhook starts. +// Without it the webhook refuses new approval decisions. +func SetApprovalStampKey(key []byte) { approvalStampKey = append([]byte(nil), key...) } var ( _ admission.Defaulter[*AgentWorkload] = workloadAdmission{} @@ -29,34 +38,34 @@ var ( // Default applies spec defaults, then the approval protocol: a new decision // is stamped with the requesting user, a client-set stamp or a change to a // recorded decision is rejected. See docs/approvals.md. -func (workloadAdmission) Default(ctx context.Context, obj *AgentWorkload) error { +func (a workloadAdmission) Default(ctx context.Context, obj *AgentWorkload) error { obj.Default() req, err := admission.RequestFromContext(ctx) if err != nil { return err } var oldAnn map[string]string - pendingID := "" + var pending approval.Pending if req.Operation == admissionv1.Update { old := &AgentWorkload{} if err := json.Unmarshal(req.OldObject.Raw, old); err != nil { return fmt.Errorf("decode old object: %w", err) } oldAnn = old.Annotations - if old.Status.PendingApproval != nil && old.Status.Phase == PhasePendingApproval { - pendingID = old.Status.PendingApproval.ID + if p := old.Status.PendingApproval; p != nil && old.Status.Phase == PhasePendingApproval { + pending = approval.Pending{ID: p.ID, WorkloadUID: string(old.UID), PayloadSHA256: approval.PendingSHA256(p.Proposal)} } } - return obj.AdmitApproval(oldAnn, pendingID, req.UserInfo.Username, req.UserInfo.Groups) + return obj.AdmitApproval(oldAnn, pending, req.UserInfo.Username, req.UserInfo.Groups, a.stampKey) } // AdmitApproval runs the approval annotation rules against the stored -// annotations and pending action id. -func (r *AgentWorkload) AdmitApproval(oldAnn map[string]string, pendingID, username string, groups []string) error { +// annotations and pending action. +func (r *AgentWorkload) AdmitApproval(oldAnn map[string]string, pending approval.Pending, username string, groups []string, key []byte) error { if r.Annotations == nil { r.Annotations = map[string]string{} } - if err := approval.Admit(oldAnn, r.Annotations, pendingID, username, groups); err != nil { + if err := approval.Admit(oldAnn, r.Annotations, pending, username, groups, key); err != nil { return apierrors.NewForbidden(GroupVersion.WithResource("agentworkloads").GroupResource(), r.Name, err) } if len(r.Annotations) == 0 { diff --git a/api/v1alpha1/agentworkload_admission_test.go b/api/v1alpha1/agentworkload_admission_test.go index 9afcf21..3520660 100644 --- a/api/v1alpha1/agentworkload_admission_test.go +++ b/api/v1alpha1/agentworkload_admission_test.go @@ -36,13 +36,21 @@ func admissionCtx(t *testing.T, op admissionv1.Operation, old *AgentWorkload) co return admission.NewContextWithRequest(context.Background(), req) } +var testStampKey = []byte("0123456789abcdef0123456789abcdef") + +var keyed = workloadAdmission{stampKey: testStampKey} + func pendingWorkload(ann map[string]string) *AgentWorkload { return &AgentWorkload{ - ObjectMeta: metav1.ObjectMeta{Name: "wl", Annotations: ann}, - Status: AgentWorkloadStatus{Phase: PhasePendingApproval, PendingApproval: &PendingApproval{ID: "p1"}}, + ObjectMeta: metav1.ObjectMeta{Name: "wl", UID: "uid-1", Annotations: ann}, + Status: AgentWorkloadStatus{Phase: PhasePendingApproval, PendingApproval: &PendingApproval{ID: "p1", Proposal: `{"action":"scale"}`}}, } } +func testPending() approval.Pending { + return approval.Pending{ID: "p1", WorkloadUID: "uid-1", PayloadSHA256: approval.PendingSHA256(`{"action":"scale"}`)} +} + func TestAdmissionStampsApproval(t *testing.T) { tests := []struct { name string @@ -63,7 +71,7 @@ func TestAdmissionStampsApproval(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { wl := &AgentWorkload{ObjectMeta: metav1.ObjectMeta{Name: "wl", Annotations: tc.ann}} - err := workloadAdmission{}.Default(admissionCtx(t, tc.op, tc.old), wl) + err := keyed.Default(admissionCtx(t, tc.op, tc.old), wl) if tc.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("err = %v, want %q", err, tc.wantErr) @@ -77,7 +85,7 @@ func TestAdmissionStampsApproval(t *testing.T) { t.Fatal("spec defaults not applied") } if tc.wantStamp { - d, ok, err := approval.Read(wl.Annotations, "p1") + d, ok, err := approval.Read(wl.Annotations, testPending(), testStampKey) if !ok || err != nil || d.Approver.Username != "alice" { t.Fatalf("decision = %+v %v %v", d, ok, err) } @@ -87,7 +95,7 @@ func TestAdmissionStampsApproval(t *testing.T) { // A recorded decision for the live pending action cannot change. first := &AgentWorkload{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{approval.AnnotationDecision: "approve"}}} - if err := (workloadAdmission{}).Default(admissionCtx(t, admissionv1.Update, pendingWorkload(nil)), first); err != nil { + if err := keyed.Default(admissionCtx(t, admissionv1.Update, pendingWorkload(nil)), first); err != nil { t.Fatal(err) } second := &AgentWorkload{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}}} @@ -95,12 +103,20 @@ func TestAdmissionStampsApproval(t *testing.T) { second.Annotations[k] = v } second.Annotations[approval.AnnotationDecision] = "reject" - err := workloadAdmission{}.Default(admissionCtx(t, admissionv1.Update, pendingWorkload(first.Annotations)), second) + err := keyed.Default(admissionCtx(t, admissionv1.Update, pendingWorkload(first.Annotations)), second) if err == nil || !strings.Contains(err.Error(), "append-once") { t.Fatalf("changed decision err = %v", err) } } +func TestAdmissionWithoutKeyRefusesDecision(t *testing.T) { + wl := &AgentWorkload{ObjectMeta: metav1.ObjectMeta{Name: "wl", Annotations: map[string]string{approval.AnnotationDecision: "approve"}}} + err := workloadAdmission{}.Default(admissionCtx(t, admissionv1.Update, pendingWorkload(nil)), wl) + if err == nil || !strings.Contains(err.Error(), "APPROVAL_STAMP_KEY_FILE") { + t.Fatalf("err = %v, want stamp key refusal", err) + } +} + func TestAdmissionNeedsRequest(t *testing.T) { if err := (workloadAdmission{}).Default(context.Background(), &AgentWorkload{}); err == nil { t.Fatal("defaulter without admission request must fail") diff --git a/api/v1alpha1/agentworkload_webhook.go b/api/v1alpha1/agentworkload_webhook.go index b3a95bd..a8b57bb 100644 --- a/api/v1alpha1/agentworkload_webhook.go +++ b/api/v1alpha1/agentworkload_webhook.go @@ -43,8 +43,8 @@ var agentworkloadlog = logf.Log.WithName("agentworkload-resource") // Without WithDefaulter and WithValidator the builder registers no handler. func (r *AgentWorkload) SetupWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr, r). - WithDefaulter(workloadAdmission{}). - WithValidator(workloadAdmission{}). + WithDefaulter(workloadAdmission{stampKey: approvalStampKey}). + WithValidator(workloadAdmission{stampKey: approvalStampKey}). Complete() } diff --git a/charts/charts/agentic-operator/templates/deployment.yaml b/charts/charts/agentic-operator/templates/deployment.yaml index 4545a2a..0eb487b 100644 --- a/charts/charts/agentic-operator/templates/deployment.yaml +++ b/charts/charts/agentic-operator/templates/deployment.yaml @@ -90,6 +90,12 @@ spec: value: /etc/decision-model/{{ .key | default "model.json" }} {{- end }} {{- end }} + {{- with ((.Values.global).approvals).stampKey }} + {{- if .existingSecret }} + - name: APPROVAL_STAMP_KEY_FILE + value: /etc/approval-stamp/{{ .key | default "stamp.key" }} + {{- end }} + {{- end }} {{- range $key, $val := .Values.env }} - name: {{ $key }} value: {{ $val | quote }} @@ -145,6 +151,11 @@ spec: readOnly: true {{- end }} {{- end }} + {{- if (((.Values.global).approvals).stampKey).existingSecret }} + - name: approval-stamp-key + mountPath: /etc/approval-stamp + readOnly: true + {{- end }} {{- if ((.Values.global).decisionModel).existingConfigMap }} - name: decision-model mountPath: /etc/decision-model @@ -181,6 +192,17 @@ spec: path: {{ .key | default "model.json" }} {{- end }} {{- end }} + {{- with ((.Values.global).approvals).stampKey }} + {{- if .existingSecret }} + - name: approval-stamp-key + secret: + secretName: {{ .existingSecret }} + defaultMode: 0444 + items: + - key: {{ .key | default "stamp.key" }} + path: {{ .key | default "stamp.key" }} + {{- end }} + {{- end }} securityContext: runAsNonRoot: true runAsUser: 65532 diff --git a/charts/tests/approval_stamp_key_test.yaml b/charts/tests/approval_stamp_key_test.yaml new file mode 100644 index 0000000..c2ce515 --- /dev/null +++ b/charts/tests/approval_stamp_key_test.yaml @@ -0,0 +1,65 @@ +# helm-unittest spec for the approval stamp key mount (docs/approvals.md). +# Run with: helm unittest charts +suite: approval stamp key +templates: + - charts/agentic-operator/templates/deployment.yaml +tests: + - it: sets no key by default, so approvals are refused + set: + license: + key: "test" + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: APPROVAL_STAMP_KEY_FILE + any: true + - notContains: + path: spec.template.spec.volumes + content: + name: approval-stamp-key + any: true + + - it: mounts the key secret and sets APPROVAL_STAMP_KEY_FILE + set: + license: + key: "test" + global: + approvals: + stampKey: + existingSecret: approval-stamp-key + key: hmac.key + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: APPROVAL_STAMP_KEY_FILE + value: /etc/approval-stamp/hmac.key + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: approval-stamp-key + mountPath: /etc/approval-stamp + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: approval-stamp-key + secret: + secretName: approval-stamp-key + defaultMode: 0444 + items: + - key: hmac.key + path: hmac.key + + - it: rejects an unsafe key name + set: + license: + key: "test" + global: + approvals: + stampKey: + existingSecret: approval-stamp-key + key: "../etc/passwd" + asserts: + - failedTemplate: {} diff --git a/charts/values.schema.json b/charts/values.schema.json index 7e8c419..fbd8a73 100644 --- a/charts/values.schema.json +++ b/charts/values.schema.json @@ -37,6 +37,22 @@ "key": { "type": "string", "pattern": "^[-._a-zA-Z0-9]+$" } }, "additionalProperties": false + }, + "approvals": { + "type": "object", + "description": "Human approval protocol. See docs/approvals.md.", + "properties": { + "stampKey": { + "type": "object", + "description": "Secret holding the approval stamp HMAC key. Unset refuses approvals.", + "properties": { + "existingSecret": { "type": "string" }, + "key": { "type": "string", "pattern": "^[-._a-zA-Z0-9]+$" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } } }, diff --git a/charts/values.yaml b/charts/values.yaml index 8a3da5e..0e62ba3 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -108,6 +108,15 @@ global: existingConfigMap: "" key: model.json + # Approval stamp HMAC key. See docs/approvals.md. Without it every human + # approval is refused (fail closed). Create a key of at least 32 bytes: + # kubectl create secret generic approval-stamp-key \ + # --from-literal=stamp.key="$(openssl rand -hex 32)" + approvals: + stampKey: + existingSecret: "" + key: stamp.key + # --------------------------------------------------------------------------- # License (REQUIRED) # --------------------------------------------------------------------------- diff --git a/cmd/main.go b/cmd/main.go index e07a83c..46071ba 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -57,6 +57,7 @@ import ( "github.com/Clawdlinux/agentic-operator-core/internal/controller" "github.com/Clawdlinux/agentic-operator-core/internal/netpolicy" "github.com/Clawdlinux/agentic-operator-core/internal/netpolicy/netprobe" + "github.com/Clawdlinux/agentic-operator-core/pkg/approval" "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" "github.com/Clawdlinux/agentic-operator-core/pkg/decision/learned" "github.com/Clawdlinux/agentic-operator-core/pkg/evaluation" @@ -294,6 +295,8 @@ func main() { } workloadReconciler.Receipts = buildReceiptsConfig(receiptsCfg, setupLog) workloadReconciler.DecisionModel = buildDecisionModelConfig(os.Getenv("DECISION_MODEL_PATH"), setupLog) + workloadReconciler.ApprovalStampKey = buildApprovalStampKey(os.Getenv("APPROVAL_STAMP_KEY_FILE"), setupLog) + agenticv1alpha1.SetApprovalStampKey(workloadReconciler.ApprovalStampKey) if err := workloadReconciler.SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "AgentWorkload") @@ -393,6 +396,23 @@ func buildReceiptsConfig(cfg receipts.Config, logger logr.Logger) controller.Rec return out } +// buildApprovalStampKey loads the approval stamp HMAC key. Without a usable +// key the webhook refuses new decisions and the controller refuses to act on +// any decision. Startup is never blocked. +func buildApprovalStampKey(path string, logger logr.Logger) []byte { + if path == "" { + logger.Info("Approvals refused: APPROVAL_STAMP_KEY_FILE not set") + return nil + } + key, err := approval.LoadKey(path) + if err != nil { + logger.Error(err, "Approval stamp key rejected; approvals are refused", "path", path) + return nil + } + logger.Info("Approval stamp key loaded") + return key +} + // buildDecisionModelConfig loads the decision model artifact. No path means // no model. A bad artifact is logged and also means no model: decisions then // stay exactly as without a model. It never blocks startup. diff --git a/cmd/main_test.go b/cmd/main_test.go index 7b430f1..a422378 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -104,3 +104,30 @@ func TestBuildDecisionModelConfig(t *testing.T) { }) } } + +func TestBuildApprovalStampKey(t *testing.T) { + dir := t.TempDir() + good := dir + "/key" + if err := os.WriteFile(good, []byte("0123456789abcdef0123456789abcdef\n"), 0o600); err != nil { + t.Fatal(err) + } + short := dir + "/short" + if err := os.WriteFile(short, []byte("short"), 0o600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, path string + want bool + }{ + {"unset refuses approvals", "", false}, + {"missing file refuses approvals", dir + "/none", false}, + {"short key refuses approvals", short, false}, + {"good key loads", good, true}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := buildApprovalStampKey(tc.path, logr.Discard()); (len(got) > 0) != tc.want { + t.Fatalf("key loaded = %v, want %v", len(got) > 0, tc.want) + } + }) + } +} diff --git a/docs/approvals.md b/docs/approvals.md index fa9dab3..d847c1a 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -22,6 +22,7 @@ annotations: | `clawdlinux.org/approval-edit` | client | For `edit`: `{"name", "description", "params"}`, at most 16 KiB | | `clawdlinux.org/approval-by` | webhook | JSON `{"username", "groups_sha256"}` from the admission request | | `clawdlinux.org/approval-for` | webhook | The pending action `id` the decision is for | +| `clawdlinux.org/approval-mac` | webhook | Hex HMAC-SHA256 over the stamp, see below | With agentctl: @@ -39,7 +40,8 @@ add the protocol annotations only when a direct-path action is pending. The approver is never taken from the client. The mutating webhook stamps `approval-by` and `approval-for` from the admission request user. It rejects: -- a request that sets either stamp itself, +- a request that sets any stamp (`approval-by`, `approval-for`, + `approval-mac`) itself, - a change or removal of a decision already recorded for the pending action (decisions are append-once per pending action), - a decision when no action is pending. @@ -50,10 +52,57 @@ turns on the existing validating rules, for example `https` MCP endpoints. Before this change the AgentWorkload webhook registered no handler at all. `SetupWebhookWithManager` now wires the defaulter and validator. -Without the webhook, the controller refuses any decision that has no -`approval-by` stamp. It sets condition `ApprovalDecision` to `False` with reason -`MissingApprovalStamp` and does not act. Without the webhook a client could -also forge the stamp. So run the webhook in any cluster where approvals matter. +### Stamp key + +The stamp is tamper-evident. The webhook adds `approval-mac`, an HMAC-SHA256 +with a domain separator over: + +- the workload UID, +- the pending action `id`, +- a digest of the decision, reason, and edit annotations, +- a digest of the `approval-by` stamp, +- a digest of the stored `status.pendingApproval.proposal`. + +The key is a file named by `APPROVAL_STAMP_KEY_FILE`, at least 32 bytes. The +chart mounts it from a secret with `global.approvals.stampKey.existingSecret` +(key `stamp.key` by default): + +```sh +kubectl create secret generic approval-stamp-key \ + --from-literal=stamp.key="$(openssl rand -hex 32)" +``` + +The controller recomputes the MAC from the live object and compares with +`hmac.Equal`. It does not act and sets condition `ApprovalDecision` to `False` +when: + +| Reason | Cause | +|---|---| +| `MissingApprovalStamp` | No `approval-by` stamp | +| `ApprovalStampKeyMissing` | No usable key configured | +| `ApprovalStampInvalid` | MAC missing or wrong: forged stamp, decision changed after stamping, workload UID or pending proposal changed | + +Approvals fail closed. With no key the webhook rejects new decisions and the +controller refuses all of them. With the webhook disabled nobody can mint a +valid MAC, so a patcher cannot forge an approval even with the right +`approval-by` JSON. Run the webhook and set the key in any cluster where +approvals matter. + +### Trust boundary + +The MAC stops annotation forgery. It does not protect `status`. A principal +with `update` or `patch` on `agentworkloads/status` can rewrite the pending +action, its proposal, and the consumed id list. Treat such principals as +inside the trust boundary unless a status admission policy is also enforced. +Only these subjects should hold that verb: + +- the operator manager service account (`manager-role`), +- cluster admins (`cluster-admin`). + +Do not grant `agentworkloads/status` to agents or CI bots. Today the +`agentctl-web` service account (`charts/templates/webui.yaml`) holds `patch` +and `update` on it, so the web UI is inside the trust boundary. Do not expose +it to untrusted users. ## What the controller does diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index bc8b21f..d328143 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -111,6 +111,7 @@ type AgentWorkloadReconciler struct { Recorder events.EventRecorder // Optional Kubernetes event recorder Receipts ReceiptsConfig // Decision receipts; zero value disables them DecisionModel DecisionModelConfig // Decision model; zero value scores nothing + ApprovalStampKey []byte // HMAC key for approval stamps; nil refuses approvals } type quotaChecker interface { diff --git a/internal/controller/agentworkload_controller_approval_test.go b/internal/controller/agentworkload_controller_approval_test.go index 3efc8f8..1b3df67 100644 --- a/internal/controller/agentworkload_controller_approval_test.go +++ b/internal/controller/agentworkload_controller_approval_test.go @@ -111,7 +111,7 @@ func newApprovalEnv(t *testing.T, name, policy, description string, declared *ag WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, wl).Build() writer := &fakeReceiptWriter{log: events, ready: true} ds := &fakeApprovals{} - r := &AgentWorkloadReconciler{Client: k8s, Scheme: scheme, Receipts: ReceiptsConfig{Enabled: true, Required: true, Writer: writer, Approvals: ds}} + r := &AgentWorkloadReconciler{Client: k8s, Scheme: scheme, Receipts: ReceiptsConfig{Enabled: true, Required: true, Writer: writer, Approvals: ds}, ApprovalStampKey: testStampKey} env := &approvalEnv{ctx: ctx, k8s: k8s, r: r, writer: writer, ds: ds, exec: exec, events: events, key: types.NamespacedName{Name: name, Namespace: "default"}} env.reconcile(t) got := env.get(t) @@ -141,8 +141,18 @@ func (e *approvalEnv) get(t *testing.T) *agenticv1alpha1.AgentWorkload { return wl } +var testStampKey = []byte("0123456789abcdef0123456789abcdef") + +// stampTamper changes what the stamp MAC is computed over, as a forger would. +type stampTamper func(p approval.Pending, key []byte) (approval.Pending, []byte) + // decide sets the decision annotations as the webhook would leave them. func (e *approvalEnv) decide(t *testing.T, ann map[string]string, stamp bool, forID string) { + t.Helper() + e.decideTampered(t, ann, stamp, forID, nil) +} + +func (e *approvalEnv) decideTampered(t *testing.T, ann map[string]string, stamp bool, forID string, tamper stampTamper) { t.Helper() wl := e.get(t) if wl.Annotations == nil { @@ -158,6 +168,12 @@ func (e *approvalEnv) decide(t *testing.T, ann map[string]string, stamp bool, fo } wl.Annotations[approval.AnnotationBy] = s wl.Annotations[approval.AnnotationFor] = forID + p, key := pendingRef(wl), testStampKey + p.ID = forID + if tamper != nil { + p, key = tamper(p, key) + } + wl.Annotations[approval.AnnotationMAC] = approval.MAC(key, p, wl.Annotations) } if err := e.k8s.Update(e.ctx, wl); err != nil { t.Fatal(err) @@ -176,6 +192,8 @@ func TestReconcile_ApprovalDecisions(t *testing.T) { unstamped bool staleFor bool failAppend bool + tamper stampTamper + noKey bool wantPhase string wantExecuted []string wantOutcomes []string @@ -196,6 +214,25 @@ func TestReconcile_ApprovalDecisions(t *testing.T) { wantPhase: "PolicyDenied", wantOutcomes: []string{receipts.OutcomeApproved, receipts.OutcomeDeny}, wantExamples: 1, wantLabel: "approve", wantCond: "HumanDecisionBlocked"}, {name: "unstamped decision fails closed", ann: map[string]string{approval.AnnotationDecision: approval.Approve}, unstamped: true, wantPhase: agenticv1alpha1.PhasePendingApproval, wantCond: "MissingApprovalStamp"}, + {name: "forged stamp is rejected", ann: map[string]string{approval.AnnotationDecision: approval.Approve}, + tamper: func(p approval.Pending, _ []byte) (approval.Pending, []byte) { + return p, []byte("attacker-key-attacker-key-attack") + }, + wantPhase: agenticv1alpha1.PhasePendingApproval, wantCond: "ApprovalStampInvalid"}, + {name: "absent key refuses approvals", ann: map[string]string{approval.AnnotationDecision: approval.Approve}, noKey: true, + wantPhase: agenticv1alpha1.PhasePendingApproval, wantCond: "ApprovalStampKeyMissing"}, + {name: "uid mismatch is rejected", ann: map[string]string{approval.AnnotationDecision: approval.Approve}, + tamper: func(p approval.Pending, k []byte) (approval.Pending, []byte) { + p.WorkloadUID = "uid-other" + return p, k + }, + wantPhase: agenticv1alpha1.PhasePendingApproval, wantCond: "ApprovalStampInvalid"}, + {name: "payload digest mismatch is rejected", ann: map[string]string{approval.AnnotationDecision: approval.Approve}, + tamper: func(p approval.Pending, k []byte) (approval.Pending, []byte) { + p.PayloadSHA256 = approval.PendingSHA256(`{"action":"delete_namespace"}`) + return p, k + }, + wantPhase: agenticv1alpha1.PhasePendingApproval, wantCond: "ApprovalStampInvalid"}, {name: "stale decision is ignored", ann: map[string]string{approval.AnnotationDecision: approval.Approve}, staleFor: true, wantPhase: agenticv1alpha1.PhasePendingApproval}, {name: "required receipt failure does not act", ann: map[string]string{approval.AnnotationDecision: approval.Approve}, failAppend: true, @@ -210,7 +247,10 @@ func TestReconcile_ApprovalDecisions(t *testing.T) { if tc.staleFor { forID = "some-older-action" } - env.decide(t, tc.ann, !tc.unstamped, forID) + env.decideTampered(t, tc.ann, !tc.unstamped, forID, tc.tamper) + if tc.noKey { + env.r.ApprovalStampKey = nil + } if tc.failAppend { env.writer.appendErr = errors.New("writer down") } diff --git a/internal/controller/approvals.go b/internal/controller/approvals.go index 30fdb94..270bda3 100644 --- a/internal/controller/approvals.go +++ b/internal/controller/approvals.go @@ -197,11 +197,16 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age "ExecutionOutcomeUnknown", "the operator stopped while executing the approved action; it was not run again", now) } - d, ok, err := approval.Read(wl.Annotations, p.ID) + d, ok, err := approval.Read(wl.Annotations, pendingRef(wl), r.ApprovalStampKey) if err != nil { reason := "InvalidDecision" - if errors.Is(err, approval.ErrUnstamped) { + switch { + case errors.Is(err, approval.ErrUnstamped): reason = "MissingApprovalStamp" + case errors.Is(err, approval.ErrNoKey): + reason = "ApprovalStampKeyMissing" + case errors.Is(err, approval.ErrBadMAC): + reason = "ApprovalStampInvalid" } return r.holdApproval(ctx, wl, reason, "not acting on the approval decision: "+err.Error(), time.Hour) } @@ -465,6 +470,12 @@ func proposalParams(proposal map[string]interface{}) map[string]interface{} { return out } +// pendingRef binds a decision to this workload and its stored proposal. +func pendingRef(wl *agenticv1alpha1.AgentWorkload) approval.Pending { + p := wl.Status.PendingApproval + return approval.Pending{ID: p.ID, WorkloadUID: string(wl.UID), PayloadSHA256: approval.PendingSHA256(p.Proposal)} +} + func receiptSeq(rc *receiptspec.Receipt) int64 { if rc == nil { return 0 diff --git a/internal/controller/decision_tracing_test.go b/internal/controller/decision_tracing_test.go index 6945301..40c6ae8 100644 --- a/internal/controller/decision_tracing_test.go +++ b/internal/controller/decision_tracing_test.go @@ -192,6 +192,7 @@ func TestDecisionTraceCoverage(t *testing.T) { t.Fatal(err) } workload.Annotations[approval.AnnotationBy] = stamp + workload.Annotations[approval.AnnotationMAC] = approval.MAC(testStampKey, pendingRef(workload), workload.Annotations) if err := env.k8s.Update(env.ctx, workload); err != nil { t.Fatal(err) } diff --git a/pkg/approval/approval.go b/pkg/approval/approval.go index c9886c2..962d8eb 100644 --- a/pkg/approval/approval.go +++ b/pkg/approval/approval.go @@ -10,25 +10,38 @@ package approval import ( "bytes" + "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" + "os" "sort" "strings" "unicode/utf8" ) // Annotation keys. Clients set Decision, Reason, and Edit. Only the admission -// webhook sets By and For. +// webhook sets By, For, and MAC. const ( AnnotationDecision = "clawdlinux.org/approval-decision" AnnotationReason = "clawdlinux.org/approval-reason" AnnotationEdit = "clawdlinux.org/approval-edit" AnnotationBy = "clawdlinux.org/approval-by" AnnotationFor = "clawdlinux.org/approval-for" + AnnotationMAC = "clawdlinux.org/approval-mac" +) + +// MinKeyBytes is the shortest stamp key accepted. +const MinKeyBytes = 32 + +// Domain separators for the stamp MAC and the pending payload digest. +const ( + macDomain = "clawdlinux.org/approval-stamp/v1" + pendingDomain = "clawdlinux.org/approval-pending-payload/v1" + decisionDomain = "clawdlinux.org/approval-decision/v1" ) // Decision labels. @@ -52,8 +65,64 @@ var ( ErrForged = errors.New(AnnotationBy + " and " + AnnotationFor + " are set by the admission webhook only") ErrFinal = errors.New("a decision is already recorded for the pending action; decisions are append-once") ErrNoPending = errors.New("no pending action to decide") + ErrNoKey = errors.New("no approval stamp key configured (APPROVAL_STAMP_KEY_FILE); approvals are refused") + ErrBadMAC = errors.New(AnnotationMAC + " does not verify for this workload, pending action, decision, and approver") ) +// Pending identifies the stored pending action a decision is bound to. +type Pending struct { + ID string + WorkloadUID string + PayloadSHA256 string +} + +// PendingSHA256 is the domain-separated digest of the stored proposal JSON. +func PendingSHA256(proposal string) string { + return SHA256Hex(pendingDomain + "\x00" + proposal) +} + +// LoadKey reads the stamp key file. Trailing whitespace is dropped. +func LoadKey(path string) ([]byte, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("approval stamp key: %w", err) + } + b = bytes.TrimRight(b, " \t\r\n") + if len(b) < MinKeyBytes { + return nil, fmt.Errorf("approval stamp key: need at least %d bytes, got %d", MinKeyBytes, len(b)) + } + return b, nil +} + +// decisionSHA256 binds the label, reason, and edit as set by the client. +func decisionSHA256(ann map[string]string) string { + return SHA256Hex(strings.Join([]string{decisionDomain, ann[AnnotationDecision], ann[AnnotationReason], ann[AnnotationEdit]}, "\x00")) +} + +// MAC is the hex HMAC-SHA256 over the workload UID, pending id, decision +// digest, approver stamp digest, and pending payload digest. +func MAC(key []byte, p Pending, ann map[string]string) string { + m := hmac.New(sha256.New, key) + m.Write([]byte(strings.Join([]string{macDomain, p.WorkloadUID, p.ID, decisionSHA256(ann), SHA256Hex(ann[AnnotationBy]), p.PayloadSHA256}, "\x00"))) + return hex.EncodeToString(m.Sum(nil)) +} + +// VerifyMAC checks the stamp MAC in constant time. +func VerifyMAC(key []byte, p Pending, ann map[string]string) error { + if len(key) == 0 { + return ErrNoKey + } + got, err := hex.DecodeString(ann[AnnotationMAC]) + if err != nil || len(got) != sha256.Size { + return ErrBadMAC + } + want, _ := hex.DecodeString(MAC(key, p, ann)) + if !hmac.Equal(got, want) { + return ErrBadMAC + } + return nil +} + // Approver is the identity the webhook stamps from the admission request. type Approver struct { Username string `json:"username"` @@ -170,10 +239,12 @@ func ValidateDecision(ann map[string]string) error { return err } -// Read returns the decision for pendingID. ok is false when no decision is -// set or the decision targets another pending action (stale). An unstamped -// decision returns ErrUnstamped: the controller must not act on it. -func Read(ann map[string]string, pendingID string) (Decision, bool, error) { +// Read returns the decision for the pending action p. ok is false when no +// decision is set or the decision targets another pending action (stale). An +// unstamped decision returns ErrUnstamped, a missing key ErrNoKey, and a MAC +// that does not verify ErrBadMAC: the controller must not act on any of them. +func Read(ann map[string]string, p Pending, key []byte) (Decision, bool, error) { + pendingID := p.ID if ann[AnnotationDecision] == "" || pendingID == "" { return Decision{}, false, nil } @@ -184,6 +255,9 @@ func Read(ann map[string]string, pendingID string) (Decision, bool, error) { if ann[AnnotationFor] != pendingID { return Decision{}, false, nil } + if err := VerifyMAC(key, p, ann); err != nil { + return Decision{}, true, err + } approver, err := ParseApprover(by) if err != nil { return Decision{}, true, err @@ -195,13 +269,15 @@ func Read(ann map[string]string, pendingID string) (Decision, bool, error) { return Decision{Label: label, Reason: ann[AnnotationReason], Edit: edit, Approver: approver, ApproverRaw: by, For: pendingID}, true, nil } -var protocolKeys = []string{AnnotationDecision, AnnotationReason, AnnotationEdit, AnnotationBy, AnnotationFor} +var protocolKeys = []string{AnnotationDecision, AnnotationReason, AnnotationEdit, AnnotationBy, AnnotationFor, AnnotationMAC} // Admit applies the webhook rules to an update from oldAnn to newAnn. -// pendingID is the pending action in the stored object, "" if none. On a new -// decision it stamps approval-by and approval-for into newAnn. It returns an -// error when the request must be rejected. -func Admit(oldAnn, newAnn map[string]string, pendingID, username string, groups []string) error { +// p is the pending action in the stored object, zero if none. On a new +// decision it stamps approval-by, approval-for, and approval-mac into newAnn. +// Without a key it refuses new decisions. It returns an error when the +// request must be rejected. +func Admit(oldAnn, newAnn map[string]string, p Pending, username string, groups []string, key []byte) error { + pendingID := p.ID changed := false for _, k := range protocolKeys { ov, oOK := oldAnn[k] @@ -213,7 +289,7 @@ func Admit(oldAnn, newAnn map[string]string, pendingID, username string, groups if !changed { return nil } - for _, k := range []string{AnnotationBy, AnnotationFor} { + for _, k := range []string{AnnotationBy, AnnotationFor, AnnotationMAC} { if nv := newAnn[k]; nv != "" && nv != oldAnn[k] { return ErrForged } @@ -223,8 +299,8 @@ func Admit(oldAnn, newAnn map[string]string, pendingID, username string, groups } if newAnn[AnnotationDecision] == "" { // Clearing a stale or absent decision. Stamps go with it. - if newAnn[AnnotationBy] != "" || newAnn[AnnotationFor] != "" { - return errors.New("clear " + AnnotationBy + " and " + AnnotationFor + " together with " + AnnotationDecision) + if newAnn[AnnotationBy] != "" || newAnn[AnnotationFor] != "" || newAnn[AnnotationMAC] != "" { + return errors.New("clear " + AnnotationBy + ", " + AnnotationFor + ", and " + AnnotationMAC + " together with " + AnnotationDecision) } return nil } @@ -234,12 +310,16 @@ func Admit(oldAnn, newAnn map[string]string, pendingID, username string, groups if _, _, err := validateFields(newAnn); err != nil { return err } + if len(key) == 0 { + return ErrNoKey + } stamp, err := Stamp(username, groups) if err != nil { return err } newAnn[AnnotationBy] = stamp newAnn[AnnotationFor] = pendingID + newAnn[AnnotationMAC] = MAC(key, p, newAnn) return nil } diff --git a/pkg/approval/approval_test.go b/pkg/approval/approval_test.go index 69030ad..dcc8ff5 100644 --- a/pkg/approval/approval_test.go +++ b/pkg/approval/approval_test.go @@ -7,10 +7,17 @@ package approval import ( "errors" + "os" "strings" "testing" ) +var testKey = []byte("0123456789abcdef0123456789abcdef") + +func pend(id string) Pending { + return Pending{ID: id, WorkloadUID: "uid-1", PayloadSHA256: PendingSHA256(`{"action":"scale"}`)} +} + func stamped(t *testing.T, ann map[string]string, pendingID string) map[string]string { t.Helper() s, err := Stamp("alice", []string{"sre", "dev"}) @@ -19,6 +26,7 @@ func stamped(t *testing.T, ann map[string]string, pendingID string) map[string]s } ann[AnnotationBy] = s ann[AnnotationFor] = pendingID + ann[AnnotationMAC] = MAC(testKey, pend(pendingID), ann) return ann } @@ -55,6 +63,7 @@ func TestAdmit(t *testing.T) { {name: "new decision is stamped", old: nil, new: map[string]string{AnnotationDecision: Approve}, pending: "p1", wantStamp: true}, {name: "client set approval-by rejected", new: map[string]string{AnnotationDecision: Approve, AnnotationBy: `{"username":"root"}`}, pending: "p1", wantErr: ErrForged}, {name: "client set approval-for rejected", new: map[string]string{AnnotationDecision: Approve, AnnotationFor: "p1"}, pending: "p1", wantErr: ErrForged}, + {name: "client set approval-mac rejected", new: map[string]string{AnnotationDecision: Approve, AnnotationMAC: "00"}, pending: "p1", wantErr: ErrForged}, {name: "no pending action rejected", new: map[string]string{AnnotationDecision: Approve}, pending: "", wantErr: ErrNoPending}, {name: "changing a live decision rejected", old: live, new: copyWith(live, AnnotationDecision, Reject), pending: "p1", wantErr: ErrFinal}, {name: "removing a live decision rejected", old: live, new: map[string]string{}, pending: "p1", wantErr: ErrFinal}, @@ -72,7 +81,11 @@ func TestAdmit(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { newAnn := copyWith(tc.new, "", "") - err := Admit(tc.old, newAnn, tc.pending, "alice", []string{"sre"}) + p := Pending{} + if tc.pending != "" { + p = pend(tc.pending) + } + err := Admit(tc.old, newAnn, p, "alice", []string{"sre"}, testKey) switch { case tc.wantErr != nil: if !errors.Is(err, tc.wantErr) { @@ -91,7 +104,7 @@ func TestAdmit(t *testing.T) { if newAnn[AnnotationFor] != tc.pending || !strings.Contains(newAnn[AnnotationBy], `"username":"alice"`) { t.Fatalf("not stamped: %v", newAnn) } - if _, ok, err := Read(newAnn, tc.pending); !ok || err != nil { + if _, ok, err := Read(newAnn, pend(tc.pending), testKey); !ok || err != nil { t.Fatalf("stamped decision unreadable: %v %v", ok, err) } } @@ -116,7 +129,7 @@ func TestRead(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - d, ok, err := Read(tc.ann, tc.pending) + d, ok, err := Read(tc.ann, pend(tc.pending), testKey) if ok != tc.wantOK || !errors.Is(err, tc.wantErr) { t.Fatalf("ok=%v err=%v, want %v %v", ok, err, tc.wantOK, tc.wantErr) } @@ -133,6 +146,65 @@ func TestRead(t *testing.T) { } } +func TestAdmitWithoutKeyRefuses(t *testing.T) { + ann := map[string]string{AnnotationDecision: Approve} + if err := Admit(nil, ann, pend("p1"), "alice", nil, nil); !errors.Is(err, ErrNoKey) { + t.Fatalf("err = %v, want ErrNoKey", err) + } + if ann[AnnotationBy] != "" || ann[AnnotationMAC] != "" { + t.Fatalf("stamped without a key: %v", ann) + } +} + +func TestReadRejectsForgedOrRebound(t *testing.T) { + good := stamped(t, map[string]string{AnnotationDecision: Approve}, "p1") + forged := copyWith(good, AnnotationMAC, MAC([]byte("attacker-key-attacker-key-attack"), pend("p1"), good)) + noMAC := copyWith(good, AnnotationMAC, "") + edited := copyWith(good, AnnotationReason, "changed after stamping") + tests := []struct { + name string + ann map[string]string + p Pending + key []byte + wantErr error + }{ + {name: "absent key refused", ann: good, p: pend("p1"), key: nil, wantErr: ErrNoKey}, + {name: "forged stamp rejected", ann: forged, p: pend("p1"), key: testKey, wantErr: ErrBadMAC}, + {name: "unmac'd stamp rejected", ann: noMAC, p: pend("p1"), key: testKey, wantErr: ErrBadMAC}, + {name: "decision changed after stamp rejected", ann: edited, p: pend("p1"), key: testKey, wantErr: ErrBadMAC}, + {name: "uid mismatch rejected", ann: good, p: Pending{ID: "p1", WorkloadUID: "uid-2", PayloadSHA256: pend("p1").PayloadSHA256}, key: testKey, wantErr: ErrBadMAC}, + {name: "payload digest mismatch rejected", ann: good, p: Pending{ID: "p1", WorkloadUID: "uid-1", PayloadSHA256: PendingSHA256(`{"action":"delete"}`)}, key: testKey, wantErr: ErrBadMAC}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, ok, err := Read(tc.ann, tc.p, tc.key); !ok || !errors.Is(err, tc.wantErr) { + t.Fatalf("ok=%v err=%v, want %v", ok, err, tc.wantErr) + } + }) + } +} + +func TestLoadKey(t *testing.T) { + dir := t.TempDir() + short := dir + "/short" + long := dir + "/long" + if err := os.WriteFile(short, []byte("tooshort\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(long, append(append([]byte(nil), testKey...), '\n'), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadKey(short); err == nil { + t.Fatal("short key accepted") + } + if k, err := LoadKey(long); err != nil || string(k) != string(testKey) { + t.Fatalf("LoadKey = %q, %v", k, err) + } + if _, err := LoadKey(dir + "/missing"); err == nil { + t.Fatal("missing key accepted") + } +} + func TestParseEditLimits(t *testing.T) { for _, s := range []string{ "", "[]", `{"description":"no name"}`, `{"name":"a"} trailing`, diff --git a/scripts/demo-claims.sh b/scripts/demo-claims.sh index d12a78c..d466a0c 100755 --- a/scripts/demo-claims.sh +++ b/scripts/demo-claims.sh @@ -213,6 +213,12 @@ rm -f "${key_file}" kc -n "${NS}" create configmap receipt-trust-root \ --from-file=trust.json="${WORK}/writer-trust.json" >/dev/null note "signing key Secret and pinned trust root ConfigMap created (key not printed, key file removed)" +stamp_file="${WORK}/approval-stamp.key" +(umask 077 && openssl rand -hex 32 >"${stamp_file}") +kc -n "${NS}" create secret generic approval-stamp-key \ + --from-file=stamp.key="${stamp_file}" >/dev/null +rm -f "${stamp_file}" +note "approval stamp HMAC key Secret created (key not printed, key file removed)" image_repo="${IMAGE%%:*}" image_tag="${IMAGE##*:}" # kindnet enforces NetworkPolicy. The chart's default-deny egress has no rule @@ -239,6 +245,7 @@ run helm --kube-context "kind-${CLUSTER_NAME}" upgrade --install "${RELEASE}" "$ --set global.receipts.required=true \ --set global.receipts.signingKey.existingSecret=receipt-signing-key \ --set global.receipts.trustRoot.existingConfigMap=receipt-trust-root \ + --set global.approvals.stampKey.existingSecret=approval-stamp-key \ --set global.receipts.image.repository="${image_repo}" \ --set global.receipts.image.tag="${image_tag}" \ --set global.receipts.image.pullPolicy=IfNotPresent \ From 2fb96991c7de608931521fb968efc4e8cddfb1e1 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:08:31 +0530 Subject: [PATCH 37/54] fix(approval): refuse replay of a consumed pending id Pending ids derive from the decision receipt entry hash and workload UID. Decided ids go to a bounded status.consumedApprovalIDs list and the human receipt records consumed_id_sha256 and pending_payload_sha256. A restored pending action with a consumed id is not acted on. Fixes F3. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- api/v1alpha1/agentworkload_types.go | 6 ++ api/v1alpha1/zz_generated.deepcopy.go | 5 ++ ...agentic.clawdlinux.org_agentworkloads.yaml | 8 +++ docs/approvals.md | 16 +++++ .../agentworkload_controller_approval_test.go | 64 +++++++++++++++++++ internal/controller/approvals.go | 59 ++++++++++++++--- pkg/receipts/record.go | 16 +++++ 7 files changed, 164 insertions(+), 10 deletions(-) diff --git a/api/v1alpha1/agentworkload_types.go b/api/v1alpha1/agentworkload_types.go index 380eebd..6b44a43 100644 --- a/api/v1alpha1/agentworkload_types.go +++ b/api/v1alpha1/agentworkload_types.go @@ -508,6 +508,12 @@ type AgentWorkloadStatus struct { // lastApproval records the last human decision the controller handled. // +optional LastApproval *ApprovalOutcome `json:"lastApproval,omitempty"` + + // consumedApprovalIDs holds the most recent decided pending ids. A pending + // action restored with one of them is never acted on again. + // +kubebuilder:validation:MaxItems=32 + // +optional + ConsumedApprovalIDs []string `json:"consumedApprovalIDs,omitempty"` } // Workload phases set by the direct action path. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 98b43c5..16b1fda 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -614,6 +614,11 @@ func (in *AgentWorkloadStatus) DeepCopyInto(out *AgentWorkloadStatus) { *out = new(ApprovalOutcome) (*in).DeepCopyInto(*out) } + if in.ConsumedApprovalIDs != nil { + in, out := &in.ConsumedApprovalIDs, &out.ConsumedApprovalIDs + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentWorkloadStatus. diff --git a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml index 571eead..d7a808c 100644 --- a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml +++ b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml @@ -556,6 +556,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + consumedApprovalIDs: + description: |- + consumedApprovalIDs holds the most recent decided pending ids. A pending + action restored with one of them is never acted on again. + items: + type: string + maxItems: 32 + type: array executedActions: description: executedActions is a list of actions that were approved and executed diff --git a/docs/approvals.md b/docs/approvals.md index d847c1a..73d4adb 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -125,6 +125,22 @@ On a stamped decision for the current pending `id`: - otherwise: state `Executing`, then `execute_action`, then `Completed` or `Failed` as on the allow path. +Pending ids do not repeat. With receipts on, the id is derived from the +decision receipt entry hash and the workload UID, so a new pending action +always gets a new id and an old stamped decision is stale for it. + +A decided id is consumed. The controller keeps the last 32 decided ids in +`status.consumedApprovalIDs`. The human receipt records +`consumed_id_sha256`, a domain-separated digest of the workload UID and +pending id, and `pending_payload_sha256`, the digest of the stored proposal +the decision was stamped against. If a status write restores a pending action +whose id is consumed, the controller does not act. Condition reason +`ApprovalReplay`. + +The stamp MAC binds the decision to the workload UID and the stored proposal +digest. A decision copied to a recreated workload, or a proposal rewritten +after stamping, gives reason `ApprovalStampInvalid`. + `status.lastApproval` records the outcome: `executed`, `failed`, `rejected`, `denied`, or `unknown`. diff --git a/internal/controller/agentworkload_controller_approval_test.go b/internal/controller/agentworkload_controller_approval_test.go index 1b3df67..d4ebe61 100644 --- a/internal/controller/agentworkload_controller_approval_test.go +++ b/internal/controller/agentworkload_controller_approval_test.go @@ -17,6 +17,7 @@ import ( "strings" "sync" "testing" + "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -25,6 +26,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "github.com/Clawdlinux/agentgate/pkg/receiptspec" + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" "github.com/Clawdlinux/agentic-operator-core/pkg/approval" "github.com/Clawdlinux/agentic-operator-core/pkg/dataset" @@ -390,3 +393,64 @@ func TestReconcile_RejectedIsTerminal(t *testing.T) { t.Fatalf("rejected workload called MCP again: %v", after[len(tools):]) } } + +func TestReconcile_ApprovalConsumedIDReplayDoesNothing(t *testing.T) { + t.Parallel() + env := newApprovalEnv(t, "approval-replay", "", "", nil) + saved := env.get(t).Status.PendingApproval.DeepCopy() + env.decide(t, map[string]string{approval.AnnotationDecision: approval.Approve}, true, env.pendingI) + env.reconcile(t) + if _, executed := env.exec.snapshot(); len(executed) != 1 { + t.Fatalf("setup: executed %v", executed) + } + wl := env.get(t) + if !slices.Contains(wl.Status.ConsumedApprovalIDs, env.pendingI) { + t.Fatalf("consumed ids = %v, want %s", wl.Status.ConsumedApprovalIDs, env.pendingI) + } + human := env.writer.records[len(env.writer.records)-1] + if human.Approval == nil || human.Approval.ConsumedIDSHA256 != receipts.ConsumedIDSHA256(string(wl.UID), env.pendingI) { + t.Fatalf("human receipt does not record the consumed id: %+v", human.Approval) + } + if human.Approval.PendingPayloadSHA256 != approval.PendingSHA256(saved.Proposal) { + t.Fatalf("human receipt pending payload = %q", human.Approval.PendingPayloadSHA256) + } + + // A status writer restores the decided pending action and clears lastApproval. + // The stamped annotations still carry a valid MAC for that id. + saved.State, saved.Decision = "", "" + wl.Status.PendingApproval = saved + wl.Status.Phase = agenticv1alpha1.PhasePendingApproval + wl.Status.LastApproval = nil + if err := env.k8s.Status().Update(env.ctx, wl); err != nil { + t.Fatal(err) + } + records := len(env.writer.records) + env.reconcile(t) + if _, executed := env.exec.snapshot(); len(executed) != 1 || len(env.writer.records) != records { + t.Fatalf("replay acted: executed %v, receipts %d -> %d", executed, records, len(env.writer.records)) + } + c := findCondition(env.get(t).Status.Conditions, approvalDecisionCondition) + if c == nil || c.Reason != "ApprovalReplay" { + t.Fatalf("condition = %#v, want ApprovalReplay", c) + } +} + +func TestPendingIDBindsReceiptAndUID(t *testing.T) { + now := metav1.Now() + rec := decided{receipt: &receiptspec.Receipt{}} + rec.receipt.EntryHash[0] = 1 + a := &agenticv1alpha1.AgentWorkload{ObjectMeta: metav1.ObjectMeta{UID: "uid-a"}} + b := &agenticv1alpha1.AgentWorkload{ObjectMeta: metav1.ObjectMeta{UID: "uid-b"}} + idA := pendingID(a, rec, now, []byte(`{}`)) + if idA != pendingID(a, rec, metav1.NewTime(now.Add(time.Hour)), []byte(`{"x":1}`)) { + t.Fatal("receipt-derived id must depend only on the entry hash and UID") + } + if idA == pendingID(b, rec, now, []byte(`{}`)) { + t.Fatal("id must bind the workload UID") + } + next := decided{receipt: &receiptspec.Receipt{}} + next.receipt.EntryHash[0] = 2 + if idA == pendingID(a, next, now, []byte(`{}`)) { + t.Fatal("a new receipt must give a new id") + } +} diff --git a/internal/controller/approvals.go b/internal/controller/approvals.go index 270bda3..68a9217 100644 --- a/internal/controller/approvals.go +++ b/internal/controller/approvals.go @@ -155,9 +155,8 @@ func newPendingApproval( if err != nil { return nil, err } - sum := sha256.Sum256([]byte(strings.Join([]string{string(wl.UID), recorded.record.InputHash, now.UTC().Format(time.RFC3339Nano), string(prop)}, "\n"))) p := &agenticv1alpha1.PendingApproval{ - ID: hex.EncodeToString(sum[:16]), + ID: pendingID(wl, recorded, now, prop), Action: action.Name, Description: action.Description, Confidence: action.Confidence, @@ -176,6 +175,35 @@ func newPendingApproval( return p, nil } +// pendingID names a pending action. With a receipt it is derived from the +// decision receipt entry hash and the workload UID, so an id cannot repeat +// for a new action. +func pendingID(wl *agenticv1alpha1.AgentWorkload, recorded decided, now metav1.Time, prop []byte) string { + parts := []string{"clawdlinux.org/pending-id/v1", string(wl.UID)} + if recorded.receipt != nil { + parts = append(parts, "receipt", hex.EncodeToString(recorded.receipt.EntryHash[:])) + } else { + parts = append(parts, "no-receipt", recorded.record.InputHash, now.UTC().Format(time.RFC3339Nano), string(prop)) + } + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return hex.EncodeToString(sum[:16]) +} + +// maxConsumedApprovalIDs bounds status.consumedApprovalIDs. +const maxConsumedApprovalIDs = 32 + +func approvalConsumed(wl *agenticv1alpha1.AgentWorkload, id string) bool { + if wl.Status.LastApproval != nil && wl.Status.LastApproval.ID == id { + return true + } + for _, c := range wl.Status.ConsumedApprovalIDs { + if c == id { + return true + } + } + return false +} + // target is the action a human decision applies to. type target struct { name string @@ -191,6 +219,11 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age p := wl.Status.PendingApproval now := metav1.Now() + if approvalConsumed(wl, p.ID) { + // A restored pending action with a decided id. Never act on it again. + return r.holdApproval(ctx, wl, "ApprovalReplay", "pending action "+p.ID+" was already decided; not acting again", time.Hour) + } + if p.State == agenticv1alpha1.ApprovalStateExecuting { // The previous reconcile may have executed. Never run it again. return r.finishApproval(ctx, wl, p.Decision, approvalUnknown, "", 0, "Failed", @@ -302,14 +335,15 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age PolicyPacks: wl.Spec.PolicyPacks, ThresholdMode: mode, Approval: receipts.Approval{ - Label: d.Label, - Approver: d.Approver.Username, - ApproverSHA256: d.ApproverSHA256(), - ReasonSHA256: d.ReasonSHA256(), - PendingID: p.ID, - OriginalSeq: uint64(p.ReceiptSeq), - OriginalInputHash: original.InputHash, - EditedFields: editedFields, + Label: d.Label, + Approver: d.Approver.Username, + ApproverSHA256: d.ApproverSHA256(), + ReasonSHA256: d.ReasonSHA256(), + PendingID: p.ID, + PendingPayloadSHA256: approval.PendingSHA256(p.Proposal), + OriginalSeq: uint64(p.ReceiptSeq), + OriginalInputHash: original.InputHash, + EditedFields: editedFields, }, }) var humanReceipt *receiptspec.Receipt @@ -427,6 +461,11 @@ func (r *AgentWorkloadReconciler) finishApproval( wl.Status.LastApproval = &agenticv1alpha1.ApprovalOutcome{ ID: p.ID, Decision: label, Outcome: outcome, ApproverSHA256: approverSHA, DecisionReceiptSeq: seq, DecidedAt: now, } + consumed := append(wl.Status.ConsumedApprovalIDs, p.ID) + if len(consumed) > maxConsumedApprovalIDs { + consumed = consumed[len(consumed)-maxConsumedApprovalIDs:] + } + wl.Status.ConsumedApprovalIDs = consumed wl.Status.PendingApproval = nil wl.Status.Phase = phase wl.Status.LastReconcileTime = &now diff --git a/pkg/receipts/record.go b/pkg/receipts/record.go index f7ab1b7..0ebbdb6 100644 --- a/pkg/receipts/record.go +++ b/pkg/receipts/record.go @@ -190,6 +190,21 @@ type Approval struct { OriginalSeq uint64 `json:"original_seq"` OriginalInputHash string `json:"original_input_hash"` EditedFields []string `json:"edited_fields"` + // PendingPayloadSHA256 is the digest of the stored pending proposal the + // decision was stamped against. + PendingPayloadSHA256 string `json:"pending_payload_sha256,omitempty"` + // ConsumedIDSHA256 marks the pending id as used. Set by NewHumanRecord. + ConsumedIDSHA256 string `json:"consumed_id_sha256"` +} + +// consumedDomain separates consumed pending id digests from other hashes. +const consumedDomain = "clawdlinux.org/approval-consumed/v1" + +// ConsumedIDSHA256 is the hex SHA-256 of the domain, workload UID, and +// pending id, each NUL separated. +func ConsumedIDSHA256(workloadUID, pendingID string) string { + sum := sha256.Sum256([]byte(consumedDomain + "\x00" + workloadUID + "\x00" + pendingID)) + return hex.EncodeToString(sum[:]) } // Human decision labels, as in the approval-decision annotation. @@ -249,6 +264,7 @@ func NewHumanRecord(p HumanParams) (DecisionRecord, error) { } a := p.Approval a.EditedFields = sortedCopy(a.EditedFields) + a.ConsumedIDSHA256 = ConsumedIDSHA256(p.Workload.UID, a.PendingID) reason := "none" if a.ReasonSHA256 != "" { reason = "sha256:" + a.ReasonSHA256 From 51e93b5113687def6ce8e560f75be8829236d308 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:12:44 +0530 Subject: [PATCH 38/54] fix(agentctl): bind approval patches to the read resourceVersion approve, reject, and edit-approve send the resourceVersion they read when they record a protocol decision. A changed object, such as a new pending action, fails with a conflict and nothing is written. The patch also clears approval-mac with the other stamps. Fixes F9. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- pkg/agentctl/approve.go | 25 +++++++++------ pkg/agentctl/decide.go | 20 ++++++++++-- pkg/agentctl/decide_test.go | 64 +++++++++++++++++++++++++++++++++++++ pkg/agentctl/reject.go | 34 +++++++++++--------- 4 files changed, 115 insertions(+), 28 deletions(-) diff --git a/pkg/agentctl/approve.go b/pkg/agentctl/approve.go index fbb1383..f418747 100644 --- a/pkg/agentctl/approve.go +++ b/pkg/agentctl/approve.go @@ -58,16 +58,21 @@ func (c *Client) ApproveWorkloadWithReason(ctx context.Context, ns, name, approv patchObj["metadata"].(map[string]interface{})["annotations"].(map[string]interface{})[k] = v } } - patchBytes, jsonErr := json.Marshal(patchObj) - if jsonErr != nil { - return nil, fmt.Errorf("marshal patch: %w", jsonErr) - } - - _, err = c.Dynamic.Resource(AgentWorkloadGVR).Namespace(ns).Patch( - ctx, name, types.MergePatchType, patchBytes, metav1.PatchOptions{}, - ) - if err != nil { - return nil, fmt.Errorf("patch workload %q: %w", name, err) + if decided { + ann := patchObj["metadata"].(map[string]interface{})["annotations"].(map[string]interface{}) + if err := c.patchDecision(ctx, ns, name, wl.GetResourceVersion(), ann); err != nil { + return nil, err + } + } else { + patchBytes, jsonErr := json.Marshal(patchObj) + if jsonErr != nil { + return nil, fmt.Errorf("marshal patch: %w", jsonErr) + } + if _, err = c.Dynamic.Resource(AgentWorkloadGVR).Namespace(ns).Patch( + ctx, name, types.MergePatchType, patchBytes, metav1.PatchOptions{}, + ); err != nil { + return nil, fmt.Errorf("patch workload %q: %w", name, err) + } } result := &ApproveResult{ diff --git a/pkg/agentctl/decide.go b/pkg/agentctl/decide.go index 3b075bf..b295eef 100644 --- a/pkg/agentctl/decide.go +++ b/pkg/agentctl/decide.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" @@ -37,6 +38,7 @@ func decisionAnnotations(label, reason, edit string) (map[string]interface{}, er approval.AnnotationEdit: nil, approval.AnnotationBy: nil, approval.AnnotationFor: nil, + approval.AnnotationMAC: nil, } if reason != "" { ann[approval.AnnotationReason] = reason @@ -82,18 +84,30 @@ func (c *Client) EditApproveWorkload(ctx context.Context, ns, name, editJSON, re if err != nil { return nil, err } - if err := c.patchAnnotations(ctx, ns, name, ann); err != nil { + if err := c.patchDecision(ctx, ns, name, wl.GetResourceVersion(), ann); err != nil { return nil, err } return &ApproveResult{Name: name, Namespace: ns, PreviousPhase: phase, DecisionRecorded: true}, nil } -func (c *Client) patchAnnotations(ctx context.Context, ns, name string, ann map[string]interface{}) error { - patch, err := json.Marshal(map[string]interface{}{"metadata": map[string]interface{}{"annotations": ann}}) +// patchDecision merge-patches annotations with the resourceVersion read +// before the pending action was checked. Any change since then, including a +// new pending action, fails the patch with a conflict. +func (c *Client) patchDecision(ctx context.Context, ns, name, resourceVersion string, ann map[string]interface{}) error { + if resourceVersion == "" { + return fmt.Errorf("workload %q has no resourceVersion; refusing an unconditional decision", name) + } + patch, err := json.Marshal(map[string]interface{}{"metadata": map[string]interface{}{ + "resourceVersion": resourceVersion, + "annotations": ann, + }}) if err != nil { return fmt.Errorf("marshal patch: %w", err) } if _, err := c.Dynamic.Resource(AgentWorkloadGVR).Namespace(ns).Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}); err != nil { + if apierrors.IsConflict(err) { + return fmt.Errorf("workload %q changed since it was read; the pending action may differ, review it and retry: %w", name, err) + } return fmt.Errorf("patch workload %q: %w", name, err) } return nil diff --git a/pkg/agentctl/decide_test.go b/pkg/agentctl/decide_test.go index 58b9c2c..dc73f7c 100644 --- a/pkg/agentctl/decide_test.go +++ b/pkg/agentctl/decide_test.go @@ -7,11 +7,16 @@ package agentctl import ( "context" + "encoding/json" + "errors" "strings" "testing" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + k8stesting "k8s.io/client-go/testing" "github.com/Clawdlinux/agentic-operator-core/pkg/approval" ) @@ -21,6 +26,7 @@ func pendingObject(name string, pending bool) *unstructured.Unstructured { if pending { _ = unstructured.SetNestedField(obj.Object, "p1", "status", "pendingApproval", "id") } + obj.SetResourceVersion("1") // A stale stamp from an older decision must be cleared by the patch. _ = unstructured.SetNestedField(obj.Object, "old", "metadata", "annotations", approval.AnnotationFor) return obj @@ -110,3 +116,61 @@ func TestApprovalDecisionAnnotations(t *testing.T) { }) } } + +func TestApprovalDecisionFailsWhenPendingChanged(t *testing.T) { + t.Parallel() + ctx := context.Background() + runs := map[string]func(c *Client) error{ + "approve": func(c *Client) error { + _, err := c.ApproveWorkloadWithReason(ctx, "team-a", "wl", "agentctl", "checked") + return err + }, + "reject": func(c *Client) error { + _, err := c.RejectWorkload(ctx, "team-a", "wl", "", "no", "agentctl") + return err + }, + "edit": func(c *Client) error { + _, err := c.EditApproveWorkload(ctx, "team-a", "wl", `{"name":"scale"}`, "") + return err + }, + } + for name, run := range runs { + t.Run(name, func(t *testing.T) { + t.Parallel() + // The stored object already holds a newer pending action. + current := pendingObject("wl", true) + _ = unstructured.SetNestedField(current.Object, "p2", "status", "pendingApproval", "id") + current.SetResourceVersion("2") + dyn := newAgentctlDynamicClient(current) + // The client read the older pending action p1 at resourceVersion 1. + observed := pendingObject("wl", true) + dyn.PrependReactor("get", "agentworkloads", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, observed.DeepCopy(), nil + }) + // The fake tracker ignores resourceVersion on patch. The API server does not. + dyn.PrependReactor("patch", "agentworkloads", func(a k8stesting.Action) (bool, runtime.Object, error) { + var body struct { + Metadata struct { + ResourceVersion string `json:"resourceVersion"` + } `json:"metadata"` + } + _ = json.Unmarshal(a.(k8stesting.PatchAction).GetPatch(), &body) + if rv := body.Metadata.ResourceVersion; rv != "" && rv != current.GetResourceVersion() { + return true, nil, apierrors.NewConflict(AgentWorkloadGVR.GroupResource(), "wl", errors.New("resourceVersion mismatch")) + } + return false, nil, nil + }) + err := run(&Client{Dynamic: dyn}) + if err == nil || !strings.Contains(err.Error(), "changed since it was read") { + t.Fatalf("err = %v, want a conflict", err) + } + got, gerr := dyn.Tracker().Get(AgentWorkloadGVR, "team-a", "wl") + if gerr != nil { + t.Fatal(gerr) + } + if ann := got.(*unstructured.Unstructured).GetAnnotations(); ann[approval.AnnotationDecision] != "" { + t.Fatalf("decision written for a changed pending action: %v", ann) + } + }) + } +} diff --git a/pkg/agentctl/reject.go b/pkg/agentctl/reject.go index d4c86e4..52331ba 100644 --- a/pkg/agentctl/reject.go +++ b/pkg/agentctl/reject.go @@ -58,21 +58,25 @@ func (c *Client) RejectWorkload(ctx context.Context, ns, name, rule, reason, rej } } - patchObj := map[string]interface{}{ - "metadata": map[string]interface{}{ - "annotations": annotations, - }, - } - patchBytes, err := json.Marshal(patchObj) - if err != nil { - return nil, fmt.Errorf("marshal patch: %w", err) - } - - _, err = c.Dynamic.Resource(AgentWorkloadGVR).Namespace(ns).Patch( - ctx, name, types.MergePatchType, patchBytes, metav1.PatchOptions{}, - ) - if err != nil { - return nil, fmt.Errorf("patch workload %q: %w", name, err) + if decided { + if err := c.patchDecision(ctx, ns, name, wl.GetResourceVersion(), annotations); err != nil { + return nil, err + } + } else { + patchObj := map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": annotations, + }, + } + patchBytes, err := json.Marshal(patchObj) + if err != nil { + return nil, fmt.Errorf("marshal patch: %w", err) + } + if _, err = c.Dynamic.Resource(AgentWorkloadGVR).Namespace(ns).Patch( + ctx, name, types.MergePatchType, patchBytes, metav1.PatchOptions{}, + ); err != nil { + return nil, fmt.Errorf("patch workload %q: %w", name, err) + } } return &RejectResult{ From 8f804088c2639f2b2500f645cf2b7b0cc9f70a44 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:14:29 +0530 Subject: [PATCH 39/54] fix(model): treat a failed artifact load as unavailable A configured DECISION_MODEL_PATH that fails to load now gives an explicit unavailable model instead of no model. Escalate workloads then require approval as on a scorer error. Shadow only logs. Fixes F10. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- cmd/main.go | 9 ++++---- cmd/main_test.go | 18 +++++++++------ docs/architecture/decision-model.md | 8 +++++-- .../agentworkload_controller_model_test.go | 10 ++++++++ internal/controller/decision_model.go | 23 +++++++++++++++---- 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 46071ba..4d3b2da 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -414,8 +414,9 @@ func buildApprovalStampKey(path string, logger logr.Logger) []byte { } // buildDecisionModelConfig loads the decision model artifact. No path means -// no model. A bad artifact is logged and also means no model: decisions then -// stay exactly as without a model. It never blocks startup. +// no model. A configured artifact that fails to load gives an unavailable +// model: escalate workloads then require approval, as on a scorer error. It +// never blocks startup. func buildDecisionModelConfig(path string, logger logr.Logger) controller.DecisionModelConfig { if path == "" { logger.Info("Decision model off: DECISION_MODEL_PATH not set") @@ -423,8 +424,8 @@ func buildDecisionModelConfig(path string, logger logr.Logger) controller.Decisi } m, err := learned.Load(path) if err != nil { - logger.Error(err, "Decision model artifact rejected; running without a model", "path", path) - return controller.DecisionModelConfig{} + logger.Error(err, "Decision model artifact rejected; escalate workloads require approval, shadow only logs", "path", path) + return controller.DecisionModelConfig{Unavailable: err} } a := m.Artifact() agenticv1alpha1.SetDecisionModelThreshold(m.ThresholdMicro()) diff --git a/cmd/main_test.go b/cmd/main_test.go index a422378..2c3d3f7 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -86,14 +86,15 @@ func TestBuildDecisionModelConfig(t *testing.T) { t.Fatal(err) } tests := []struct { - name string - path string - wantModel bool + name string + path string + wantModel bool + wantUnavailable bool }{ - {"unset means no model", "", false}, - {"missing file means no model", t.TempDir() + "/none.json", false}, - {"bad artifact means no model", bad, false}, - {"shipped artifact loads", shipped, true}, + {"unset means no model", "", false, false}, + {"missing file means unavailable model", t.TempDir() + "/none.json", false, true}, + {"bad artifact means unavailable model", bad, false, true}, + {"shipped artifact loads", shipped, true, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -101,6 +102,9 @@ func TestBuildDecisionModelConfig(t *testing.T) { if (got.Scorer != nil) != tc.wantModel { t.Fatalf("scorer loaded = %v, want %v", got.Scorer != nil, tc.wantModel) } + if (got.Unavailable != nil) != tc.wantUnavailable { + t.Fatalf("unavailable = %v, want %v", got.Unavailable, tc.wantUnavailable) + } }) } } diff --git a/docs/architecture/decision-model.md b/docs/architecture/decision-model.md index 3c9af30..596a233 100644 --- a/docs/architecture/decision-model.md +++ b/docs/architecture/decision-model.md @@ -66,8 +66,12 @@ version. An artifact with another spec version or feature order is refused. - `artifact_sha256`: SHA-256 of the artifact as compact JSON, sorted keys, this field removed -A hash mismatch, spec mismatch, bad decimal, or unknown field means no model. -The operator logs the error and decides exactly as without a model. +A hash mismatch, spec mismatch, bad decimal, unknown field, or unreadable +file means the configured model is unavailable. The operator logs the error and +keeps an explicit unavailable state. It is handled like a scorer error: +workloads in `escalate` mode require approval for actions the other layers +allow, `shadow` only logs, `off` is unchanged. No `DECISION_MODEL_PATH` at all +still means no model. ## Determinism and replay diff --git a/internal/controller/agentworkload_controller_model_test.go b/internal/controller/agentworkload_controller_model_test.go index 783aa2e..7dc93a6 100644 --- a/internal/controller/agentworkload_controller_model_test.go +++ b/internal/controller/agentworkload_controller_model_test.go @@ -46,6 +46,7 @@ func TestReconcile_DecisionModel(t *testing.T) { name string confidence string scorer *fakeScorer + unavailable bool mode string override *int64 wantPhase string @@ -73,6 +74,12 @@ func TestReconcile_DecisionModel(t *testing.T) { wantPhase: "PendingApproval", wantCalls: 1, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true, wantError: true}, {name: "shadow scorer error never blocks", confidence: "0.98", scorer: &fakeScorer{err: errors.New("artifact read failed")}, mode: "shadow", wantPhase: "Completed", wantExecute: true, wantCalls: 1, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold, wantBlock: true, wantError: true}, + {name: "escalate unavailable model requires approval", confidence: "0.98", unavailable: true, mode: "escalate", + wantPhase: "PendingApproval", wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true, wantError: true}, + {name: "shadow unavailable model only logs", confidence: "0.98", unavailable: true, mode: "shadow", + wantPhase: "Completed", wantExecute: true, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold, wantBlock: true, wantError: true}, + {name: "off ignores unavailable model", confidence: "0.98", unavailable: true, mode: "off", + wantPhase: "Completed", wantExecute: true, wantOutcome: receipts.OutcomeAllow, wantLayer: receipts.LayerThreshold}, {name: "stricter override escalates", confidence: "0.98", scorer: &fakeScorer{risk: 400000, threshold: 500000}, mode: "escalate", override: i(300000), wantPhase: "PendingApproval", wantCalls: 2, wantOutcome: receipts.OutcomeRequireApproval, wantLayer: receipts.LayerModel, wantBlock: true}, {name: "looser override ignored", confidence: "0.98", scorer: &fakeScorer{risk: 600000, threshold: 500000}, mode: "escalate", override: i(900000), @@ -110,6 +117,9 @@ func TestReconcile_DecisionModel(t *testing.T) { if tc.scorer != nil { reconciler.DecisionModel = DecisionModelConfig{Scorer: tc.scorer} } + if tc.unavailable { + reconciler.DecisionModel = DecisionModelConfig{Unavailable: errors.New("decision model artifact rejected")} + } if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: "default"}}); err != nil { t.Fatalf("reconcile: %v", err) } diff --git a/internal/controller/decision_model.go b/internal/controller/decision_model.go index 49ef65f..f7b5032 100644 --- a/internal/controller/decision_model.go +++ b/internal/controller/decision_model.go @@ -7,6 +7,7 @@ package controller import ( "context" + "errors" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -17,10 +18,14 @@ import ( "github.com/Clawdlinux/agentic-operator-core/pkg/tracing/decisiontrace" ) -// DecisionModelConfig holds the loaded decision model. A nil Scorer means no -// artifact is configured: nothing is scored and decisions are unchanged. +// DecisionModelConfig holds the loaded decision model. A nil Scorer and nil +// Unavailable means no artifact is configured: nothing is scored and +// decisions are unchanged. Unavailable is set when an artifact is configured +// but did not load. It is handled like a scorer error: escalate requires +// approval, shadow only logs. type DecisionModelConfig struct { - Scorer decision.Scorer + Scorer decision.Scorer + Unavailable error } // applyDecisionModel scores the action after the non-model decision. Shadow @@ -35,7 +40,8 @@ func (r *AgentWorkloadReconciler) applyDecisionModel( base decision.Result, ) (decision.Result, *receipts.Model) { mode := decision.ModelMode(wl.Spec.DecisionModelMode()) - if r.DecisionModel.Scorer == nil || mode == decision.ModelOff { + unavailable := r.DecisionModel.Unavailable + if (r.DecisionModel.Scorer == nil && unavailable == nil) || mode == decision.ModelOff { return base, nil } parentCtx := ctx @@ -57,7 +63,14 @@ func (r *AgentWorkloadReconciler) applyDecisionModel( override = wl.Spec.DecisionModel.ThresholdMicro } // Claims may only tighten: the risk is never below the no-claim risk. - s, err := decision.ScoreClaimSafe(ctx, r.DecisionModel.Scorer, fi) + var s decision.Score + err := unavailable + if err == nil && r.DecisionModel.Scorer == nil { + err = errors.New("decision model unavailable") + } + if err == nil { + s, err = decision.ScoreClaimSafe(ctx, r.DecisionModel.Scorer, fi) + } if err != nil { s = decision.Score{FeatureSpec: f.SpecVersion, FeatureHash: f.Hash()} } else { From 2353ce955d6ada055c7369b7a9a196d569441b64 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:15:30 +0530 Subject: [PATCH 40/54] fix(receipts): store approver identity as a digest Human records store a domain-separated sha256 digest of the approver username in approval.approver, the approver reason, and human_principal. The raw username stays only in the status condition message. Fixes F13. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- docs/approvals.md | 7 +++++-- docs/receipts.md | 12 ++++++++++-- .../agentworkload_controller_approval_test.go | 2 +- pkg/receipts/human_test.go | 4 ++-- pkg/receipts/record.go | 16 ++++++++++++++-- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/approvals.md b/docs/approvals.md index 73d4adb..51a2ec8 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -112,8 +112,11 @@ On a stamped decision for the current pending `id`: action that would execute. Edit runs them on the edited action. 2. It records the decision in `status.pendingApproval.state` (`Recording`). 3. It writes a layer `human` receipt: outcome `approved`, `rejected`, or - `edited`. The receipt `human_principal` is the approver username. Reasons - hold the approver, `sha256:` of the reason, and the pending `id`. + `edited`. The receipt `human_principal` is the approver identity digest + (see [receipts](receipts.md)), never the raw username. Reasons hold the + same digest, `sha256:` of the reason, and the pending `id`. The raw + username appears only in the `ApprovalDecision` condition message, so an + operator can see which RBAC subject decided. 4. With `RECEIPTS_REQUIRED=true` and a failed write, it stops. Condition reason `ReceiptFailed`. The action stays pending. 5. It appends the dataset example. diff --git a/docs/receipts.md b/docs/receipts.md index f581793..acbd014 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -35,7 +35,7 @@ Each receipt is a v1 AgentGate receipt: | `policy_decision` | `allow` for allow. `deny` for deny and require_approval | | `status_code` | 200 allow, 202 require_approval, 403 deny | | `params_sha256` | SHA-256 of the canonical decision record | -| `human_principal` | `system:clawdlinux-operator`, or the approver username for layer `human` | +| `human_principal` | `system:clawdlinux-operator`, or the approver identity digest for layer `human` | | `agent_key_id` | workload UID | The decision record sits beside the receipt in `records.jsonl`. Fields: @@ -45,7 +45,15 @@ The decision record sits beside the receipt in `records.jsonl`. Fields: `claimed`, `policy_packs`, `threshold_mode`, `replay_hint`. A `model` block is added whenever the decision model scored the action. See [model block](#model-block). Human decisions add an `approval` -block and set `human_principal` to the approver. See [approvals](approvals.md). +block and set `human_principal` to the approver identity digest. See +[approvals](approvals.md). + +Records never hold a raw Kubernetes username. Usernames are often emails. The +approver is stored as `sha256:` plus the hex SHA-256 of +`clawdlinux.org/human-identity/v1`, a NUL, and the username. The same digest +goes in `approval.approver`, the `approver` reason, and `human_principal`. To +check a known user, compute the digest and compare. The digest is +pseudonymous, not anonymous: a guessed username can be confirmed. `input_hash` is the SHA-256 of the canonical JSON of the full decision input: declared, observed, agent-claimed (labelled `agent_claimed`), and diff --git a/internal/controller/agentworkload_controller_approval_test.go b/internal/controller/agentworkload_controller_approval_test.go index d4ebe61..0bc1739 100644 --- a/internal/controller/agentworkload_controller_approval_test.go +++ b/internal/controller/agentworkload_controller_approval_test.go @@ -274,7 +274,7 @@ func TestReconcile_ApprovalDecisions(t *testing.T) { var outcomes []string for _, rec := range env.writer.records[before:after] { outcomes = append(outcomes, rec.Outcome) - if strings.Contains(rec.Action, "AKIA") || rec.Approval != nil && rec.Approval.Approver != "alice" { + if strings.Contains(rec.Action, "AKIA") || rec.Approval != nil && rec.Approval.Approver != receipts.IdentityDigest("alice") { t.Fatalf("bad record %+v", rec) } } diff --git a/pkg/receipts/human_test.go b/pkg/receipts/human_test.go index eff91e8..8ed239e 100644 --- a/pkg/receipts/human_test.go +++ b/pkg/receipts/human_test.go @@ -53,11 +53,11 @@ func TestNewHumanRecord(t *testing.T) { if err != nil { t.Fatal(err) } - if f.HumanPrincipal != "alice" || f.PolicyDecision != tc.pd { + if f.HumanPrincipal != IdentityDigest("alice") || f.PolicyDecision != tc.pd { t.Fatalf("fields = %+v", f) } raw, _ := json.Marshal(rec) - for _, leak := range []string{"AKIA", "a@b.io"} { + for _, leak := range []string{"AKIA", "a@b.io", "alice"} { if strings.Contains(string(raw), leak) { t.Fatalf("human record leaked %q", leak) } diff --git a/pkg/receipts/record.go b/pkg/receipts/record.go index 0ebbdb6..45d33f3 100644 --- a/pkg/receipts/record.go +++ b/pkg/receipts/record.go @@ -179,8 +179,9 @@ type DecisionRecord struct { ReplayHint string `json:"replay_hint"` } -// Approval is set on layer "human" records. Approver is the username the -// admission webhook stamped. The full stamp and the reason are hashed only. +// Approval is set on layer "human" records. Approver is the pseudonymous +// digest of the username the admission webhook stamped (IdentityDigest), +// never the raw username. The full stamp and the reason are hashed only. type Approval struct { Label string `json:"label"` Approver string `json:"approver"` @@ -197,6 +198,16 @@ type Approval struct { ConsumedIDSHA256 string `json:"consumed_id_sha256"` } +// identityDomain separates identity digests from every other hash. +const identityDomain = "clawdlinux.org/human-identity/v1" + +// IdentityDigest is the pseudonymous form of a Kubernetes username stored in +// records: "sha256:" and the hex SHA-256 of the domain, a NUL, and the name. +func IdentityDigest(username string) string { + sum := sha256.Sum256([]byte(identityDomain + "\x00" + username)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + // consumedDomain separates consumed pending id digests from other hashes. const consumedDomain = "clawdlinux.org/approval-consumed/v1" @@ -265,6 +276,7 @@ func NewHumanRecord(p HumanParams) (DecisionRecord, error) { a := p.Approval a.EditedFields = sortedCopy(a.EditedFields) a.ConsumedIDSHA256 = ConsumedIDSHA256(p.Workload.UID, a.PendingID) + a.Approver = IdentityDigest(a.Approver) reason := "none" if a.ReasonSHA256 != "" { reason = "sha256:" + a.ReasonSHA256 From a106d9846ad018920ae9f4d509dabdd2263d59ad Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:19:01 +0530 Subject: [PATCH 41/54] fix(controller): never log raw MCP values or error bodies MCP client errors carry a typed code and HTTP status only, never the response body or server error text. Invalid health and confidence log a code and the value type. parseFlexibleFloat no longer quotes the input. A canary test plants a credential and an email in invalid MCP replies and checks no log line holds them. Fixes F14. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- .../controller/agentworkload_controller.go | 10 +- internal/controller/mcp_log_canary_test.go | 119 ++++++++++++++++++ pkg/mcp/client.go | 31 ++++- 3 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 internal/controller/mcp_log_canary_test.go diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index d328143..d75c962 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -509,7 +509,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques if rawHealth, ok := status["cluster_health"]; ok { health, err := parseFlexibleFloat(rawHealth) if err != nil { - log.Info("Warning: MCP status has invalid 'cluster_health' field, using default", "default", clusterHealth, "value", rawHealth) + log.Info("Warning: MCP status has invalid 'cluster_health' field, using default", "default", clusterHealth, "code", "invalid_cluster_health", "valueType", fmt.Sprintf("%T", rawHealth)) } else { clusterHealth = health claimedHealth = &health @@ -576,7 +576,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques confidence, err := parseFlexibleFloat(rawConfidence) if err != nil { - log.Error(err, "failed to parse confidence", "confidence", rawConfidence) + log.Error(err, "failed to parse confidence", "code", "invalid_confidence", "valueType", fmt.Sprintf("%T", rawConfidence)) workload.Status.Phase = "Failed" if err := r.Status().Update(ctx, &workload); err != nil { log.Error(err, "failed to update workload status") @@ -585,7 +585,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques } if confidence < 0 || confidence > 1 { - log.Error(nil, "confidence value out of range", "confidence", confidence) + log.Error(nil, "confidence value out of range", "code", "confidence_out_of_range") workload.Status.Phase = "Failed" if err := r.Status().Update(ctx, &workload); err != nil { log.Error(err, "failed to update workload status") @@ -777,7 +777,7 @@ func parseFlexibleFloat(value interface{}) (float64, error) { case json.Number: parsed, err := v.Float64() if err != nil { - return 0, fmt.Errorf("invalid numeric value %q: %w", v.String(), err) + return 0, fmt.Errorf("invalid numeric value") } return parsed, nil case float64: @@ -802,7 +802,7 @@ func parseFlexibleFloat(value interface{}) (float64, error) { } parsed, err := strconv.ParseFloat(v, 64) if err != nil { - return 0, fmt.Errorf("invalid numeric string %q: %w", v, err) + return 0, fmt.Errorf("invalid numeric string") } return parsed, nil default: diff --git a/internal/controller/mcp_log_canary_test.go b/internal/controller/mcp_log_canary_test.go new file mode 100644 index 0000000..e5d468d --- /dev/null +++ b/internal/controller/mcp_log_canary_test.go @@ -0,0 +1,119 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/go-logr/logr/funcr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/mcp" +) + +const ( + canaryCredential = "AKIAIOSFODNN7CANARY1" + canaryEmail = "canary.person@example.com" +) + +func TestReconcile_InvalidMCPRepliesNeverLogValues(t *testing.T) { + t.Parallel() + canary := canaryCredential + " " + canaryEmail + okStatus := mcp.ToolResponse{Success: true, Result: map[string]interface{}{"cluster_health": 90.0}} + tests := []struct { + name string + reply func(tool string, w http.ResponseWriter) + }{ + {name: "invalid cluster health", reply: func(tool string, w http.ResponseWriter) { + if tool == "get_status" { + writeTool(w, mcp.ToolResponse{Success: true, Result: map[string]interface{}{"cluster_health": canary}}) + return + } + writeTool(w, mcp.ToolResponse{Success: true, Result: map[string]interface{}{"action": "noop", "description": "d", "confidence": "0.10"}}) + }}, + {name: "invalid confidence", reply: func(tool string, w http.ResponseWriter) { + if tool == "get_status" { + writeTool(w, okStatus) + return + } + writeTool(w, mcp.ToolResponse{Success: true, Result: map[string]interface{}{"action": "noop", "description": "d", "confidence": canary}}) + }}, + {name: "error body", reply: func(_ string, w http.ResponseWriter) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(canary)) + }}, + {name: "tool error text", reply: func(tool string, w http.ResponseWriter) { + if tool == "get_status" { + writeTool(w, okStatus) + return + } + writeTool(w, mcp.ToolResponse{Success: false, Error: canary}) + }}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req mcp.ToolRequest + _ = json.NewDecoder(r.Body).Decode(&req) + tc.reply(req.Tool, w) + })) + t.Cleanup(srv.Close) + + var mu sync.Mutex + var out strings.Builder + logger := funcr.New(func(prefix, args string) { + mu.Lock() + defer mu.Unlock() + out.WriteString(prefix + " " + args + "\n") + }, funcr.Options{Verbosity: 10}) + + scheme := newControllerTestScheme(t) + objective := "optimize" + policy := "strict" + wl := &agenticv1alpha1.AgentWorkload{ + ObjectMeta: metav1.ObjectMeta{Name: "canary", Namespace: "default", UID: types.UID("uid-canary")}, + Spec: agenticv1alpha1.AgentWorkloadSpec{MCPServerEndpoint: &srv.URL, Objective: &objective, OPAPolicy: &policy}, + } + k8s := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&agenticv1alpha1.AgentWorkload{}). + WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, wl).Build() + r := &AgentWorkloadReconciler{Client: k8s, Scheme: scheme} + ctx := logf.IntoContext(context.Background(), logger) + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "canary", Namespace: "default"}}); err != nil { + t.Fatalf("reconcile: %v", err) + } + mu.Lock() + logs := out.String() + mu.Unlock() + if logs == "" { + t.Fatal("no log output captured") + } + for _, leak := range []string{canaryCredential, canaryEmail} { + if strings.Contains(logs, leak) { + t.Fatalf("log output contains %q:\n%s", leak, logs) + } + } + }) + } +} + +func writeTool(w http.ResponseWriter, resp mcp.ToolResponse) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/pkg/mcp/client.go b/pkg/mcp/client.go index 4841461..50e4560 100644 --- a/pkg/mcp/client.go +++ b/pkg/mcp/client.go @@ -119,8 +119,8 @@ func (c *MCPClient) ListTools() ([]string, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("MCP server returned status %d: %s", resp.StatusCode, string(body)) + _, _ = io.Copy(io.Discard, resp.Body) + return nil, &CallError{Code: CodeHTTPStatus, Status: resp.StatusCode} } var toolResp toolListResponseFlex @@ -164,8 +164,8 @@ func (c *MCPClient) CallTool(toolName string, params map[string]interface{}) (ma defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("MCP server returned status %d: %s", resp.StatusCode, string(body)) + _, _ = io.Copy(io.Discard, resp.Body) + return nil, &CallError{Code: CodeHTTPStatus, Status: resp.StatusCode} } var toolResp ToolResponse @@ -174,12 +174,33 @@ func (c *MCPClient) CallTool(toolName string, params map[string]interface{}) (ma } if !toolResp.Success { - return nil, fmt.Errorf("tool execution failed: %s", toolResp.Error) + return nil, &CallError{Code: CodeToolFailed} } return toolResp.Result, nil } +// MCP call error codes. +const ( + CodeHTTPStatus = "http_status" + CodeToolFailed = "tool_failed" +) + +// CallError is an MCP call failure. It never carries the response body or +// the server error text: MCP replies can hold credentials or personal data +// and errors end up in logs. +type CallError struct { + Code string + Status int +} + +func (e *CallError) Error() string { + if e.Status != 0 { + return fmt.Sprintf("mcp: %s %d (response body withheld)", e.Code, e.Status) + } + return "mcp: " + e.Code + " (server error text withheld)" +} + func retryableStatus(status int) bool { return status >= http.StatusInternalServerError && status <= 599 } From 04cd6a75e1c61d2cecae6f5bcdba9ef10a68695a Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:21:10 +0530 Subject: [PATCH 42/54] docs: record review findings, dataset completeness caveat, fixed groups Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- BLOCKERS.md | 12 ++++++++++++ CHANGELOG.md | 13 +++++++++++++ docs/approvals.md | 1 + docs/architecture/decision-architecture.md | 1 + docs/receipts.md | 1 + 5 files changed, 28 insertions(+) diff --git a/BLOCKERS.md b/BLOCKERS.md index e83968c..bcf161a 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -49,6 +49,18 @@ with `scripts/demo/Dockerfile.demo`, because the repo Dockerfile cannot build while the receiptspec `replace` is in `go.mod`. Once v0.1.4 is pinned, switch the demo to the repo Dockerfile targets and delete `Dockerfile.demo`. +## Known review findings + +An independent review of this branch found these gaps. They are documented, not fixed. + +- **F7. Dataset content is not bound.** `pkg/dataset/dataset.go` (`Validate`) checks identity and label fields and the original seq. It does not bind features, content hashes, captured params, timestamps or edit content. Someone who can edit an export can poison training features and `agentctl dataset verify` still passes. Fix: bind each example to its verified original decision record and rebuild features from it. +- **F11. Writer recovery trusts local files.** `pkg/receipts/local.go` checks hashes and the head KID on restart. It does not verify signatures or keep a committed-head checkpoint. Removing the tail of the chain rewinds the sequence and the writer can fork an already issued chain. Fix: verify signatures on recovery and persist an external checkpoint of the committed head. +- **F12. Deleted approval rows go unnoticed.** `pkg/dataset/verify.go` checks the examples it is given. Without a signed dataset manifest (count, order, digest) it cannot tell that rows were removed. Fix: sign a dataset manifest and verify completeness against it. + +## Demo needs a live re-run + +The review fixes changed what the demo must do: a pinned writer key, a signed receipt manifest, and an approval stamp key. Re-run `scripts/demo-claims.sh` twice on a fresh kind cluster before quoting its results. + ## Known gaps (not blockers for this branch) - Runtime-adapter parity: `reconcileViaRuntime` runs no invariants, packs, diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ed25d..f49912a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,19 @@ covered yet. See [decision architecture](docs/architecture/decision-architecture - Status-only updates no longer re-trigger Reconcile. Before, each status write re-ran the direct-path action in a hot loop. - The controller no longer logs MCP status, proposal, or execution values. They can carry personal data or credentials. +### Fixed (review findings) +- An incomplete data scan (budget, depth or size limit) now denies through INV-06 instead of failing open. +- Agent-claimed inputs can only raise model risk. The model scores with and without claims and takes the higher value. +- The direct path reserves execution with a status update before it runs an allowed action, so a stale reconcile cannot run after the spec tightens. +- The remote receipt writer client verifies each receipt under a pinned writer key. +- `agentctl receipts verify` and `agentctl dataset verify` need a signed manifest by default. `--allow-prefix` is the weaker mode. +- Receipts bind the executed payload by digest (`payload_sha256`). +- The approval stamp carries an HMAC, and a consumed approval cannot be replayed. +- `agentctl` approval patches carry the resourceVersion and fail on conflict. +- A configured model that fails to load is treated as unavailable. Escalate mode then requires approval. +- Approver identities are stored as digests in receipts. +- MCP error bodies and invalid values are no longer logged. + ### Added - Added `scripts/demo-claims.sh`, a no-API-key claims demo on a fresh kind cluster with a mock MCP server. It builds a demo-only image from host binaries until the receiptspec dependency is tagged. - Added P7 decision spans on direct proposals and stamped human approve, reject, and edit decisions. diff --git a/docs/approvals.md b/docs/approvals.md index 51a2ec8..d187241 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -230,3 +230,4 @@ or FAIL lines and exits 1 on any failure. - `POST /v1/approvals` binding check scans the chain files. Fine for now, slow for very long chains. - No synthetic seed set yet. The dataset starts empty. +- Dataset examples are bound to receipts by identity and label only. Feature edits and deleted rows in an export pass `dataset verify` (BLOCKERS.md, F7 and F12). diff --git a/docs/architecture/decision-architecture.md b/docs/architecture/decision-architecture.md index 9137b5d..a1cbf4a 100644 --- a/docs/architecture/decision-architecture.md +++ b/docs/architecture/decision-architecture.md @@ -70,6 +70,7 @@ model id and version, input hash, option set and order, per-option probabilities `networkPolicy.additionalAllowedHosts`). - Unchanged: a Completed direct-path workload is reconciled again every 30s and proposes a new action each time. +- The approval dataset is not yet cryptographically complete. Examples are bound to receipts by identity and label only. Row deletion and feature edits in an export are not detected. See BLOCKERS.md (F7, F12). ## Build order 1. Measure, stop trusting agent self-report (observed inputs). See diff --git a/docs/receipts.md b/docs/receipts.md index acbd014..ccab9dd 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -259,3 +259,4 @@ That checks receipts only, not decision records. NetworkPolicy and bearer token. No TLS yet. - Token rotation needs a writer restart. - No receipt-writer image is published yet. See `BLOCKERS.md`. +- Writer recovery does not verify signatures or keep an external committed-head checkpoint. Removing the chain tail can fork an issued chain (BLOCKERS.md, F11). From 6b48f410c8885d02d5b6fa62620e73bef8f4c157 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:27:04 +0530 Subject: [PATCH 43/54] feat(demo): oversize mock scenario, identity digest and manifest strip helpers Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- scripts/demo/claims_check.py | 28 ++++++++++++++++++++++++++++ scripts/demo/mockmcp/main.go | 6 ++++++ 2 files changed, 34 insertions(+) diff --git a/scripts/demo/claims_check.py b/scripts/demo/claims_check.py index 75c72d8..a5069e3 100644 --- a/scripts/demo/claims_check.py +++ b/scripts/demo/claims_check.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Read-only checks for scripts/demo-claims.sh. Stdlib only. Demo only.""" +import hashlib import json import os import sys @@ -114,6 +115,31 @@ def cmd_flip(path, needle): return 0 +def cmd_digest(username): + """IdentityDigest from pkg/receipts/record.go.""" + data = ("clawdlinux.org/human-identity/v1\x00" + username).encode() + print("sha256:" + hashlib.sha256(data).hexdigest()) + + +def cmd_strip_manifest(path): + """Drop the signed export manifest line from a receipts.jsonl.""" + with open(path, encoding="utf-8") as f: + lines = f.readlines() + kept = [] + for line in lines: + try: + if json.loads(line).get("type") == "manifest": + continue + except ValueError: + pass + kept.append(line) + with open(path, "w", encoding="utf-8") as f: + f.writelines(kept) + removed = len(lines) - len(kept) + print(f"removed {removed} manifest line(s)") + return 0 if removed else 1 + + def main(argv): cmds = { "records": (cmd_records, 2), @@ -122,6 +148,8 @@ def main(argv): "model": (cmd_model, 3), "approvals": (cmd_approvals, 2), "flip": (cmd_flip, 2), + "digest": (cmd_digest, 1), + "strip-manifest": (cmd_strip_manifest, 1), } if len(argv) < 2 or argv[1] not in cmds or len(argv) - 2 != cmds[argv[1]][1]: print("usage: claims_check.py " + "|".join(cmds) + " args...", file=sys.stderr) diff --git a/scripts/demo/mockmcp/main.go b/scripts/demo/mockmcp/main.go index ae9349c..d99a02f 100644 --- a/scripts/demo/mockmcp/main.go +++ b/scripts/demo/mockmcp/main.go @@ -16,6 +16,7 @@ import ( "net/http" "os" "regexp" + "strings" "sync" "time" ) @@ -83,6 +84,11 @@ func proposal(scenario string) map[string]interface{} { p["action"] = "decide_kyc_refund" p["description"] = "refund decision for a verified customer" p["params"] = map[string]interface{}{"scenario": scenario, "customer_id": testAadhaar} + case "s9-oversize": + // The key sits past the 64 KiB per-string scan window. + p["action"] = "upload_report" + p["description"] = "upload a large report" + p["params"] = map[string]interface{}{"scenario": scenario, "report": strings.Repeat("x", 70<<10) + " " + testAWSKeyID} } return p } From 58c164407aea5a7b2b7c1b61996fdb24c3afcd0a Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:28:12 +0530 Subject: [PATCH 44/54] feat(demo): prove pin, manifest, replay, digest, and INV-06 fixes Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- scripts/demo-claims.sh | 138 +++++++++++++++++++++++++++++++++++------ 1 file changed, 120 insertions(+), 18 deletions(-) diff --git a/scripts/demo-claims.sh b/scripts/demo-claims.sh index d466a0c..81ad430 100755 --- a/scripts/demo-claims.sh +++ b/scripts/demo-claims.sh @@ -101,14 +101,18 @@ Plan (cluster ${CLUSTER_NAME}): 4. dpdp-in@v0.1.0, decisionType automated, personal data, permissive mode: PendingApproval 5. forged approval-by rejected by the webhook, agentctl approve stamped with - the real identity, human receipt plus approvals.jsonl example; then a - human edit that adds a credential is denied by INV-01 - 6. receipts export and verify, tamper checks, AgentGate verifier agreement, - dataset verify + the real identity (HMAC stamp key Secret), human receipt carries the + identity digest, approvals.jsonl example; a restored consumed approval + does not run again; a human edit that adds a credential is denied by INV-01 + 6. receipts export and verify under the pin made at install, manifest + removal and a wrong pin rejected, tamper checks, AgentGate verifier + agreement, dataset verify 7. decision model in shadow via the chart value, receipt model block, agentctl decision eval WARNING - 8. trace coverage via make trace-coverage (no collector) - 9. cleanup (kind delete) unless --keep + 8. a credential past the 64 KiB scan window: INV-06 deny + 9. operator restarted with a wrong writer pin: INV-05 deny, nothing runs + 10. trace coverage via make trace-coverage (no collector) + 11. cleanup (kind delete) unless --keep EOF } @@ -478,15 +482,19 @@ s4() { } s5() { - local me stamp out pending + local me stamp out pending msg + local me_digest me="$(kc auth whoami -o jsonpath='{.status.userInfo.username}')" + me_digest="$(python3 "${CHECK}" digest "${me}")" note "kubectl identity: ${me}" + note "identity digest (sha256 of clawdlinux.org/human-identity/v1, NUL, name): ${me_digest}" say "-- 5a: forged stamp, then a real approve" apply_workload s5-approve s5-approve permissive "${DPDP_INTENT}" wait_phase s5-approve PendingApproval pending="$(kc -n "${DEMO_NS}" get agentworkload s5-approve -o jsonpath='{.status.pendingApproval.id}')" note "pending id: ${pending}" + kc -n "${DEMO_NS}" get agentworkload s5-approve -o jsonpath='{.status.pendingApproval}' >"${WORK}/s5-pending.json" printf '$ kubectl annotate agentworkload s5-approve clawdlinux.org/approval-decision=approve %s\n' \ "'clawdlinux.org/approval-by={\"username\":\"mallory\",\"groups_sha256\":\"x\"}'" if out="$(kc -n "${DEMO_NS}" annotate agentworkload s5-approve clawdlinux.org/approval-decision=approve \ @@ -503,15 +511,41 @@ s5() { stamp="$(kc -n "${DEMO_NS}" get agentworkload s5-approve -o jsonpath='{.metadata.annotations.clawdlinux\.org/approval-by}')" note "approval-by: ${stamp}" check "webhook stamped the real kubectl identity" contains "${stamp}" "\"username\":\"${me}\"" + check "webhook stamped an HMAC over the decision" \ + test -n "$(kc -n "${DEMO_NS}" get agentworkload s5-approve -o jsonpath='{.metadata.annotations.clawdlinux\.org/approval-mac}')" + msg="$(condition_message s5-approve ApprovalDecision)" + note "ApprovalDecision message: ${msg}" + check "status message names the approver" contains "${msg}" "after human approve by ${me}" check "execute_action called once after approval" eq "$(calls s5-approve execute_action)" 1 export_dataset "${WORK}/s5" run python3 "${CHECK}" records "${WORK}/s5" s5-approve - check "layer human receipt with the approver as principal" \ - bash -c "python3 '${CHECK}' records '${WORK}/s5' s5-approve | grep -q 'layer=human outcome=approved policy_decision=allow principal=${me} '" + check "layer human receipt with the approver identity digest as principal" \ + bash -c "python3 '${CHECK}' records '${WORK}/s5' s5-approve | grep -q 'layer=human outcome=approved policy_decision=allow principal=${me_digest} '" + check "no receipt or record carries the raw username" \ + bash -c "! grep -Fq '\"${me}\"' '${WORK}/s5/receipts.jsonl' '${WORK}/s5/records.jsonl'" run python3 "${CHECK}" approvals "${WORK}/s5/approvals.jsonl" s5-approve check "one approvals.jsonl example" eq "$(python3 "${CHECK}" approvals "${WORK}/s5/approvals.jsonl" s5-approve | tail -1)" 1 note "example line:" grep -F '"s5-approve"' "${WORK}/s5/approvals.jsonl" | head -1 | sed 's/^/ | /' + + say "-- 5c: a restored, already consumed approval does not run again" + python3 -c 'import json,sys; print(json.dumps({"status": {"phase": "PendingApproval", "pendingApproval": json.load(open(sys.argv[1]))}}))' \ + "${WORK}/s5-pending.json" >"${WORK}/s5-replay.json" + note "restore the decided pending action; the stamped approval annotations are still on the object" + run kubectl --context "kind-${CLUSTER_NAME}" -n "${DEMO_NS}" patch agentworkload s5-approve \ + --subresource=status --type=merge --patch-file "${WORK}/s5-replay.json" + run kubectl --context "kind-${CLUSTER_NAME}" -n "${DEMO_NS}" annotate agentworkload s5-approve demo.clawdlinux.org/nudge=1 + local end reason="" + end=$(( $(date +%s) + 90 )) + while (( $(date +%s) < end )); do + reason="$(kc -n "${DEMO_NS}" get agentworkload s5-approve -o jsonpath='{.status.conditions[?(@.type=="ApprovalDecision")].reason}')" + [[ "${reason}" == "ApprovalReplay" ]] && break + sleep 2 + done + note "ApprovalDecision ${reason}: $(condition_message s5-approve ApprovalDecision)" + check "controller refused the consumed pending id" eq "${reason}" "ApprovalReplay" + sleep 5 + check "execute_action still called exactly once" eq "$(calls s5-approve execute_action)" 1 delete_workload s5-approve say "-- 5b: a human edit cannot bypass an invariant" @@ -523,7 +557,6 @@ JSON sed 's/^/ | /' "${WORK}/edit.json" run agentctl -n "${DEMO_NS}" edit-approve s5-edit --edit-file "${WORK}/edit.json" --reason "use backup creds" wait_phase s5-edit PolicyDenied - local msg msg="$(condition_message s5-edit PolicyDenied)" note "PolicyDenied message: ${msg}" check "edited action denied by INV-01" contains "${msg}" "INV-01" @@ -544,11 +577,26 @@ expect_fail() { } s6() { - local ev="${WORK}/s6" pinned="${WORK}/pinned-trust.json" + local ev="${WORK}/s6" pinned="${WORK}/writer-trust.json" out export_dataset "${ev}" - cp "${ev}/trust.json" "${pinned}" - note "pinned trust root copied to ${pinned}" - run agentctl receipts verify "${ev}" --trust-root "${pinned}" + note "pinned trust root made at install from the signing key: ${pinned}" + run agentctl receipts verify "${ev}" --trust-root "${pinned}" | tee "${WORK}/s6-verify.txt" + out="$(cat "${WORK}/s6-verify.txt")" + check "completeness proven against the signed export manifest" contains "${out}" "completeness: proven" + + cp -R "${ev}" "${WORK}/s6-no-manifest" + run python3 "${CHECK}" strip-manifest "${WORK}/s6-no-manifest/receipts.jsonl" + expect_fail "agentctl rejects an export with the manifest removed" \ + "${BIN}/agentctl" receipts verify "${WORK}/s6-no-manifest" --trust-root "${pinned}" + run agentctl receipts verify "${WORK}/s6-no-manifest" --trust-root "${pinned}" --allow-prefix + note "ok: --allow-prefix accepts it and warns that completeness is not proven" + + local wrong_key="${WORK}/wrong-key.hex" + (umask 077 && openssl rand -hex 32 >"${wrong_key}") + "${BIN}/agentctl" receipts trust-root --signing-key-file "${wrong_key}" >"${WORK}/wrong-trust.json" + rm -f "${wrong_key}" + expect_fail "agentctl rejects the export under a wrong pin" \ + "${BIN}/agentctl" receipts verify "${ev}" --trust-root "${WORK}/wrong-trust.json" local agentgate=false if [[ -x "${BIN}/agentgate-verify" ]]; then agentgate=true @@ -600,7 +648,7 @@ s7() { local out run agentctl decision eval --dataset "${WORK}/s7" --model "${MODEL_JSON}" \ - --trust-root "${WORK}/pinned-trust.json" --baseline | tee "${WORK}/eval-export.txt" + --trust-root "${WORK}/writer-trust.json" --baseline | tee "${WORK}/eval-export.txt" out="$(cat "${WORK}/eval-export.txt")" check "eval on the exported dataset prints a WARNING" contains "${out}" "WARNING" run agentctl decision eval --dataset "${SYNTH_APPROVALS}" --model "${MODEL_JSON}" \ @@ -610,6 +658,52 @@ s7() { check "eval on the shipped training set warns the data is synthetic" contains "${out}" "are synthetic" } +s9() { + note "propose_action returns a 70 KiB string with an AKIA key after it." + apply_workload s9-oversize s9-oversize strict "" + wait_phase s9-oversize PolicyDenied + local msg + msg="$(condition_message s9-oversize PolicyDenied)" + note "PolicyDenied message: ${msg}" + check "condition names INV-06 (scan incomplete)" contains "${msg}" "INV-06" + export_receipts "${WORK}/s9" + run python3 "${CHECK}" records "${WORK}/s9" s9-oversize + check "deny receipt with INV-06" \ + bash -c "python3 '${CHECK}' records '${WORK}/s9' s9-oversize | grep -q 'outcome=deny .*INV-06'" + check "execute_action was never called" eq "$(calls s9-oversize execute_action)" 0 + check "operator log does not contain the key" operator_log_clean "${FAKE_AWS_KEY}" + delete_workload s9-oversize +} + +s10() { + note "Repin the operator to a throwaway key. The real writer still signs with its own key." + local wrong_key="${WORK}/wrong-pin.hex" deploy + (umask 077 && openssl rand -hex 32 >"${wrong_key}") + "${BIN}/agentctl" receipts trust-root --signing-key-file "${wrong_key}" >"${WORK}/wrong-pin.json" + rm -f "${wrong_key}" + kc -n "${NS}" create configmap receipt-trust-root --from-file=trust.json="${WORK}/wrong-pin.json" \ + --dry-run=client -o yaml | kc apply -f - >/dev/null + deploy="$(kc -n "${NS}" get deployment -l "${OPERATOR_SEL}" -o name)" + run kubectl --context "kind-${CLUSTER_NAME}" -n "${NS}" rollout restart "${deploy}" + kc -n "${NS}" rollout status "${deploy}" --timeout="${HELM_TIMEOUT}" >/dev/null + apply_workload s10-wrong-pin s10-wrong-pin strict " declaredIntent: + purpose: \"scale the web tier\" + decisionType: assisted + allowedDataClasses: [] + allowedDestinations: [\"${MOCK_HOST}\"]" + wait_phase s10-wrong-pin PolicyDenied + local msg + msg="$(condition_message s10-wrong-pin PolicyDenied)" + note "PolicyDenied message: ${msg}" + check "condition names INV-05 (receipt required)" contains "${msg}" "INV-05" + check "execute_action was never called" eq "$(calls s10-wrong-pin execute_action)" 0 + kc -n "${NS}" logs -l "${OPERATOR_SEL}" --tail=-1 >"${WORK}/operator-wrong-pin.log" + grep -F "unpinned key" "${WORK}/operator-wrong-pin.log" | tail -1 | cut -c1-300 || true + check "operator refused the writer receipt as signed by an unpinned key" \ + grep -Fq "unpinned key" "${WORK}/operator-wrong-pin.log" + delete_workload s10-wrong-pin +} + s8() { note "No collector here. The Go coverage test asserts the controller emits decision spans." (cd "${REPO_ROOT}" && run make trace-coverage) | tee "${WORK}/trace.txt" | grep -vE '^=== RUN|^--- PASS|^PASS$|^ok ' || true @@ -624,7 +718,9 @@ scenario 4 "dpdp-in pack escalation in permissive mode" s4 scenario 5 "human approve with a stamped identity, edit cannot bypass invariants" s5 scenario 6 "offline verification and tamper detection" s6 scenario 7 "decision model in shadow mode" s7 -scenario 8 "decision tracing coverage (Go test, no collector)" s8 +scenario 8 "credential past the scan window, INV-06" s9 +scenario 9 "operator pinned to the wrong writer key, INV-05" s10 +scenario 10 "decision tracing coverage (Go test, no collector)" s8 heading "Results ($(( $(date +%s) - T0 ))s of scenarios)" cat "${RESULTS}" @@ -636,10 +732,16 @@ Proven by this demo, on a kind cluster: - a signed receipt is stored before the action runs, and denied actions are never executed - a policy pack approval finding holds an action even in permissive mode -- the webhook stamps the approver and rejects a forged stamp +- the webhook stamps the approver under an HMAC and rejects a forged stamp +- receipts carry the approver identity digest, not the username +- a restored, already consumed approval does not run again - a human edit is re-checked and cannot bypass an invariant -- receipts verify offline, tamper is detected, and AgentGate verifies the +- receipts verify offline under a pin made at install, a removed manifest + and a wrong pin fail, tamper is detected, and AgentGate verifies the same chain +- a credential past the 64 KiB scan window is denied by INV-06 +- an operator pinned to the wrong writer key refuses every receipt and + denies by INV-05 - the decision model scores in shadow without changing the outcome - decision spans are covered by a Go test From d112e6dfafbdaf68d0f5f0ba3feb240dea0d7638 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:39:34 +0530 Subject: [PATCH 45/54] fix(demo): let a restarted operator pod settle before the next scenario Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- scripts/demo-claims.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/demo-claims.sh b/scripts/demo-claims.sh index 81ad430..b774a56 100755 --- a/scripts/demo-claims.sh +++ b/scripts/demo-claims.sh @@ -370,6 +370,15 @@ operator_log_clean() { ! grep -Fq "${value}" "${log}" } +# settle_operator waits after an operator restart. On kind a new pod's first +# connections to the writer and the mock can stall for about 40s. The operator +# then fails closed with INV-05, which is correct but not what a scenario tests. +settle_operator() { + local secs="${OPERATOR_SETTLE_SECS:-45}" + note "waiting ${secs}s for the new operator pod's egress to settle" + sleep "${secs}" +} + export_receipts() { rm -rf "$1" run agentctl receipts export --writer "${WRITER}" --token-file "${TOKEN_FILE}" --out "$1" >/dev/null @@ -635,6 +644,7 @@ s7() { sleep 2 done kc -n "${NS}" logs -l "${OPERATOR_SEL}" --tail=-1 | grep "Decision model loaded" | tail -1 | cut -c1-400 + settle_operator apply_workload s7-shadow s7-shadow strict " declaredIntent: purpose: \"scale the web tier\" decisionType: assisted @@ -686,6 +696,7 @@ s10() { deploy="$(kc -n "${NS}" get deployment -l "${OPERATOR_SEL}" -o name)" run kubectl --context "kind-${CLUSTER_NAME}" -n "${NS}" rollout restart "${deploy}" kc -n "${NS}" rollout status "${deploy}" --timeout="${HELM_TIMEOUT}" >/dev/null + settle_operator apply_workload s10-wrong-pin s10-wrong-pin strict " declaredIntent: purpose: \"scale the web tier\" decisionType: assisted From 2f4705dbe181d153350087e7ca6e1b1e541ab7d7 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:50:05 +0530 Subject: [PATCH 46/54] docs: record claims demo re-run with the review fixes Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- BLOCKERS.md | 4 ---- CHANGELOG.md | 2 +- README.md | 24 +++++++++++++++++------- scripts/demo-claims.sh | 2 ++ 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/BLOCKERS.md b/BLOCKERS.md index bcf161a..03541eb 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -57,10 +57,6 @@ An independent review of this branch found these gaps. They are documented, not - **F11. Writer recovery trusts local files.** `pkg/receipts/local.go` checks hashes and the head KID on restart. It does not verify signatures or keep a committed-head checkpoint. Removing the tail of the chain rewinds the sequence and the writer can fork an already issued chain. Fix: verify signatures on recovery and persist an external checkpoint of the committed head. - **F12. Deleted approval rows go unnoticed.** `pkg/dataset/verify.go` checks the examples it is given. Without a signed dataset manifest (count, order, digest) it cannot tell that rows were removed. Fix: sign a dataset manifest and verify completeness against it. -## Demo needs a live re-run - -The review fixes changed what the demo must do: a pinned writer key, a signed receipt manifest, and an approval stamp key. Re-run `scripts/demo-claims.sh` twice on a fresh kind cluster before quoting its results. - ## Known gaps (not blockers for this branch) - Runtime-adapter parity: `reconcileViaRuntime` runs no invariants, packs, diff --git a/CHANGELOG.md b/CHANGELOG.md index f49912a..07d27bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ covered yet. See [decision architecture](docs/architecture/decision-architecture - MCP error bodies and invalid values are no longer logged. ### Added -- Added `scripts/demo-claims.sh`, a no-API-key claims demo on a fresh kind cluster with a mock MCP server. It builds a demo-only image from host binaries until the receiptspec dependency is tagged. +- Added `scripts/demo-claims.sh`, a no-API-key claims demo on a fresh kind cluster with a mock MCP server. It runs with a pinned writer key and an approval stamp key, and proves the review fixes: wrong pin, removed manifest, approval replay, identity digests, and INV-06. Two fresh runs passed in 252s and 297s. It builds a demo-only image from host binaries until the receiptspec dependency is tagged. - Added P7 decision spans on direct proposals and stamped human approve, reject, and edit decisions. Successful receipt appends bind spans to writer-returned sequence and entry hash. Added privacy canary tests, no-op checks, write-ahead assertions, and `make trace-coverage` with an 11-path matrix. diff --git a/README.md b/README.md index e1dfa26..1205aec 100644 --- a/README.md +++ b/README.md @@ -212,14 +212,24 @@ curl. scripts/demo-claims.sh # --keep, --skip-build, --cluster-name, --dry-run, --help ``` -Runtime: about 3 minutes end to end, build included. Two runs from a fresh -cluster took 151s and 165s on an Apple Silicon laptop (kind v0.31, images -already pulled). - -It proves invariants, write-ahead receipts, pack escalation, webhook-stamped -approvals, offline verification, and the shadow decision model. It does not +Runtime: about 5 minutes end to end, build included. Two runs from a fresh +cluster took 252s and 297s on an Apple Silicon laptop (kind v0.31, images +already pulled). About 90s of that is a settle wait after each of the two +operator restarts. A new pod's first connections stall on kind, and the +operator then fails closed with INV-05. + +The demo runs the secure configuration. It creates the receipt signing key, +pins the writer public key from it, and mounts an approval stamp HMAC key. It +never sets `RECEIPTS_INSECURE_NO_PIN`. + +It proves invariants (INV-01, INV-03, INV-06), write-ahead receipts, pack +escalation, HMAC-stamped approvals, identity digests in receipts, replay +refusal for a consumed approval, offline verification with a signed manifest, +rejection of a removed manifest and a wrong pin, an operator pinned to the +wrong writer key denying by INV-05, and the shadow decision model. It does not prove the runtime-adapter path, Argo approval gates, execution outcome -receipts, production model quality, packet-level egress, or caller identity. +receipts, production model quality, packet-level egress, caller identity, +escalate mode on a failed model load, or `agentctl` resourceVersion conflicts. The script ends with the full list. It builds a demo-only image from host binaries until the receiptspec dependency is tagged. See [BLOCKERS.md](BLOCKERS.md). diff --git a/scripts/demo-claims.sh b/scripts/demo-claims.sh index b774a56..1b4a397 100755 --- a/scripts/demo-claims.sh +++ b/scripts/demo-claims.sh @@ -766,6 +766,8 @@ NOT proven by this demo: egress to the mock on the pod CIDR, and INV-02 checks the configured MCP endpoint only) - caller identity observation (observed.callerIdentity is empty) +- escalate mode on a model that fails to load, and agentctl resourceVersion + conflicts (unit tests only) EOF if grep -q '^FAIL' "${RESULTS}"; then From 9312c84ab0631a069af064abc97127babe74fe71 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 04:51:34 +0530 Subject: [PATCH 47/54] docs(blockers): record non-terminal denied workloads Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- BLOCKERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BLOCKERS.md b/BLOCKERS.md index 03541eb..fd677c5 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -71,3 +71,4 @@ An independent review of this branch found these gaps. They are documented, not - `callerIdentity` is always empty. - `TestControllers` needs envtest binaries (`bin/k8s` or `/usr/local/kubebuilder/bin`). It fails locally without them. +- **Denied direct-path workloads are not terminal.** A `PolicyDenied` or `Completed` direct-path workload is proposed again on any later reconcile (stale event, hourly or 30s requeue). Each proposal is fully re-decided and receipted, so no guard is skipped. But a deny caused by a temporary condition, such as an unreachable receipt writer under INV-05, can be followed by an execution seconds later. The demo hit this once. This predates the branch. Fix: make terminal phases sticky until the spec generation changes. From 2452f6475ecc162b0fce0f0bf782b47443921c83 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 10:07:23 +0530 Subject: [PATCH 48/54] chore(deps): pin agentgate v0.1.4, drop local replace Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- BLOCKERS.md | 43 +++++-------------------------------------- go.mod | 4 +--- go.sum | 2 ++ 3 files changed, 8 insertions(+), 41 deletions(-) diff --git a/BLOCKERS.md b/BLOCKERS.md index fd677c5..32bd901 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -2,52 +2,19 @@ Open items that need a founder action before this branch can ship. -## receiptspec dependency - -`go.mod` requires `github.com/Clawdlinux/agentgate v0.1.3` with a local -`replace` to `../agentgate-receiptspec`. That directory only exists on the -founder laptop. `pkg/receipts`, `cmd/receipt-writer`, and `agentctl receipts` -import `github.com/Clawdlinux/agentgate/pkg/receiptspec`, which is not in any -pushed or tagged AgentGate release yet. - -Until this is fixed, CI and `docker build` fail. Both run without the sibling -directory, so the `replace` target is missing. - -Fix, in order: - -```sh -# 1. AgentGate: push, merge, tag. -cd ../agentgate-receiptspec -git push -u origin feat/receiptspec -gh pr create --base main --head feat/receiptspec --fill -gh pr merge --squash --delete-branch -git fetch origin && git checkout main && git pull --ff-only -git tag -a v0.1.4 -m "v0.1.4: public pkg/receiptspec" && git push origin v0.1.4 - -# 2. Operator: drop the replace and pin the tag. -cd ../operator-decision -go mod edit -dropreplace=github.com/Clawdlinux/agentgate -go mod edit -require=github.com/Clawdlinux/agentgate@v0.1.4 -go mod tidy -go build ./... && go test ./pkg/receipts/... ./cmd/receipt-writer/... ./cmd/agentctl/... -git commit -s -am "chore(deps): pin agentgate v0.1.4, drop local replace" -``` - -Then delete this entry. - ## receipt-writer image The chart `receipts` block points at `ghcr.io/clawdlinux/agentic-operator-core/receipt-writer`. No CI job publishes that image yet. Build it with `docker build --target receipt-writer .` and push -it, or add a release job. Blocked on the receiptspec entry above. +it, or add a release job. ## Demo-only image -`scripts/demo-claims.sh` builds linux binaries on the host and packages them -with `scripts/demo/Dockerfile.demo`, because the repo Dockerfile cannot build -while the receiptspec `replace` is in `go.mod`. Once v0.1.4 is pinned, switch -the demo to the repo Dockerfile targets and delete `Dockerfile.demo`. +`scripts/demo-claims.sh` still builds linux binaries on the host and packages +them with `scripts/demo/Dockerfile.demo`. The repo Dockerfile now builds +(agentgate v0.1.4 is pinned). Switch the demo to the repo Dockerfile targets +and delete `Dockerfile.demo` when convenient. ## Known review findings diff --git a/go.mod b/go.mod index f21320c..24cd161 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect - github.com/Clawdlinux/agentgate v0.1.3 + github.com/Clawdlinux/agentgate v0.1.4 github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect @@ -132,5 +132,3 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect ) - -replace github.com/Clawdlinux/agentgate => ../agentgate-receiptspec diff --git a/go.sum b/go.sum index 2d3baf4..544e723 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Clawdlinux/agent-native-format v0.3.0 h1:DqynrNxrbqINIBe0/dcyQTdrHVA162EidyZEBrJLSGU= github.com/Clawdlinux/agent-native-format v0.3.0/go.mod h1:NEXezR2T6iyfZ49ZRRMBb3bENXas8tquo1syeMcxnIw= +github.com/Clawdlinux/agentgate v0.1.4 h1:6xYdeoudh05Fr5LVwGmjPChmPS9LPrwIv+aw3yryZtY= +github.com/Clawdlinux/agentgate v0.1.4/go.mod h1:sH83pHXoY6NdyIeVdQ5fUxR48pKasMYXqrvSfZJXl4Q= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= From bc3be633adbf71d28159571416ef07a56c04e251 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 10:08:55 +0530 Subject: [PATCH 49/54] fix(samples): reword declared-intent purpose to pass OSS boundary guard Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- config/samples/agentworkload_declared_intent.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/samples/agentworkload_declared_intent.yaml b/config/samples/agentworkload_declared_intent.yaml index 2dedce8..bf63715 100644 --- a/config/samples/agentworkload_declared_intent.yaml +++ b/config/samples/agentworkload_declared_intent.yaml @@ -14,7 +14,7 @@ spec: autoApproveThreshold: "0.95" mcpServerEndpoint: "https://mcp.refunds.internal:8443" declaredIntent: - purpose: "Triage refund tickets. No payment data leaves the cluster." + purpose: "Triage refund tickets. No customer data leaves the cluster." decisionType: "refund-triage" allowedDataClasses: - email From 0c1b5b7e279ec10d358a2192e801eb0831e80df5 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 10:22:44 +0530 Subject: [PATCH 50/54] fix: address Copilot review (log redaction, key validation, lock, docs) Redact credential and personal values from action-derived log fields and status messages. Validate that a 64-byte Ed25519 key matches its seed in both loaders. Fail receipt chain locking on non-unix builds. Allowlist fake credential fixtures in the secret scanner. Correct INV-06, OPA, threat model and stale replace notes in docs. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- CHANGELOG.md | 4 +-- ROADMAP.md | 2 +- cmd/receipt-writer/main.go | 10 +++--- cmd/receipt-writer/main_test.go | 8 ++++- docs/SHOWCASE-DEMO-WALKTHROUGH.md | 2 +- docs/security/threat-model.md | 4 +-- .../controller/agentworkload_controller.go | 18 +++++----- internal/controller/approvals.go | 2 +- internal/controller/logsafe.go | 36 +++++++++++++++++++ internal/controller/logsafe_test.go | 31 ++++++++++++++++ internal/controller/mcp_log_canary_test.go | 7 ++++ internal/controller/receipts.go | 4 +-- pkg/decision/features_test.go | 33 +++++++++++++++++ pkg/receipts/key.go | 30 ++++++++++++++++ pkg/receipts/key_test.go | 35 ++++++++++++++++++ pkg/receipts/lock_other.go | 12 ++++--- pkg/receipts/trustroot.go | 11 ++---- scripts/scan_secrets.sh | 4 ++- 18 files changed, 215 insertions(+), 38 deletions(-) create mode 100644 internal/controller/logsafe.go create mode 100644 internal/controller/logsafe_test.go create mode 100644 pkg/decision/features_test.go create mode 100644 pkg/receipts/key.go create mode 100644 pkg/receipts/key_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d27bd..a7172ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,8 @@ covered yet. See [decision architecture](docs/architecture/decision-architecture - Added a working booth prepare/present flow with real provider and cost proof. - Added `agentctl doctor sandbox` to verify RuntimeClass and ready-node sandbox evidence before workload deployment. - Added optional `spec.declaredIntent` on `AgentWorkload` and observed decision inputs. The legacy action path now denies or escalates actions that send undeclared data classes or call undeclared hosts. Agent-claimed confidence and cluster health can only tighten. New packages: `pkg/dataclass`, `pkg/decision`, `pkg/decision/input`, `pkg/decision/observe`. See `docs/policy-input.md`. -- Added invariants (`pkg/invariants`, INV-01 to INV-05), an embedded OPA rule engine (`pkg/rules/engine`, OPA v1.19.1), and opt-in policy packs `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` via `spec.policyPacks`. Packs only add deny or require approval. Packs are engineering controls, not legal advice. The `credential` data class is now detected and always denied (INV-01), even without `declaredIntent`. Direct action path only. See `docs/policy-packs.md`. -- Added opt-in signed decision receipts (`pkg/receipts`, `cmd/receipt-writer`). Each direct-path decision gets an AgentGate-format Ed25519 receipt before the action runs. `RECEIPTS_REQUIRED=true` makes INV-05 deny when no receipt can be written. Added `agentctl receipts export` and `agentctl receipts verify`, and a `global.receipts` chart block. Records hold hashes and class names, not raw params or matched data. Execution outcome and the runtime-adapter path are not receipted yet. Uses a local `replace` for `github.com/Clawdlinux/agentgate` until v0.1.4 is tagged (see `BLOCKERS.md`). See `docs/receipts.md`. +- Added invariants (`pkg/invariants`, INV-01 to INV-06; INV-06 denies a payload the data class scan could not fully read), an embedded OPA rule engine (`pkg/rules/engine`, OPA v1.19.1), and opt-in policy packs `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` via `spec.policyPacks`. Packs only add deny or require approval. Packs are engineering controls, not legal advice. The `credential` data class is now detected and always denied (INV-01), even without `declaredIntent`. Direct action path only. See `docs/policy-packs.md`. +- Added opt-in signed decision receipts (`pkg/receipts`, `cmd/receipt-writer`). Each direct-path decision gets an AgentGate-format Ed25519 receipt before the action runs. `RECEIPTS_REQUIRED=true` makes INV-05 deny when no receipt can be written. Added `agentctl receipts export` and `agentctl receipts verify`, and a `global.receipts` chart block. Records hold hashes and class names, not raw params or matched data. Execution outcome and the runtime-adapter path are not receipted yet. Uses the public `pkg/receiptspec` from `github.com/Clawdlinux/agentgate` v0.1.4. See `docs/receipts.md`. - Added human approvals on the direct action path (`pkg/approval`, `pkg/dataset`). A held action is stored in `status.pendingApproval`. Humans decide with `clawdlinux.org/approval-decision` (approve, reject, edit). The webhook stamps the approver; the controller refuses unstamped decisions. Each decision gets a layer `human` receipt and an example in `approvals.jsonl` on the receipt-writer (`POST|GET /v1/approvals`). Edited and approved actions re-run invariants and packs first. Added `spec.approvalCapture.content` (none, redacted, full; default redacted), phase `Rejected`, `agentctl edit-approve`, `agentctl approve --reason`, and `agentctl dataset export|verify`. See `docs/approvals.md`. - Added the decision model, phase A (`pkg/decision` features and `ApplyModel`, `pkg/decision/learned`, `pkg/decision/baseline`, `pkg/decision/eval`). Escalate-only: it can move allow to require_approval and never loosens. `spec.decisionModel.mode` (off, shadow, escalate; default shadow) and a stricter-only `thresholdMicro`. The operator loads an artifact from `DECISION_MODEL_PATH`; without one nothing is scored. Scored decisions carry a receipt `model` block with integer micro-unit probabilities. Added `agentctl decision eval`, `make decision-train` (stdlib Python, byte-identical reruns), and `global.decisionModel` in the chart. The shipped artifact is trained on 30 DRAFT synthetic scenarios and is not production-validated. Approval examples gain an optional `source` field; `synthetic` rows never verify. See `docs/architecture/decision-model.md`. diff --git a/ROADMAP.md b/ROADMAP.md index de796c9..caffb85 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,7 +52,7 @@ Strong CNCF base runtimes for agents on Kubernetes exist and are improving. Claw ## Shipped (decision architecture) -- [x] Rule engine (OPA Go library) with invariants and policy packs. 5 invariants, `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` packs. Direct action path only; orchestrated runtimes reject packs. See [policy packs](docs/policy-packs.md) +- [x] Rule engine (OPA Go library) with invariants and policy packs. 6 invariants (INV-01 to INV-06), `dpdp-in@v0.1.0` and `gdpr-eu@v0.1.0` packs. Direct action path only; orchestrated runtimes reject packs. See [policy packs](docs/policy-packs.md) - [x] Signed decision receipts, opt-in. AgentGate receipt format, `receipt-writer` service, write-ahead, fail closed with `RECEIPTS_REQUIRED`, `agentctl receipts export|verify`. Direct action path only. See [receipts](docs/receipts.md) - [x] Human approval protocol and signed approval dataset. Webhook-stamped approver, append-once decisions, edit re-validated against invariants, `spec.approvalCapture`, `approvals.jsonl` on the receipt-writer, `agentctl dataset export|verify`. Direct action path only; Argo approval gates not covered. See [approvals](docs/approvals.md) - [x] Decision model phase A: logistic regression with integer-only inference, escalate-only, `spec.decisionModel.mode` shadow by default, `DECISION_MODEL_PATH`, receipt `model` block, `agentctl decision eval` with a hand-weighted baseline, stdlib trainer with byte-identical reruns. Shipped artifact trained on synthetic DRAFT scenarios, not production-validated. Phase B not built. See [decision model](docs/architecture/decision-model.md) diff --git a/cmd/receipt-writer/main.go b/cmd/receipt-writer/main.go index f212193..198bb12 100644 --- a/cmd/receipt-writer/main.go +++ b/cmd/receipt-writer/main.go @@ -160,13 +160,11 @@ func loadKey(path string, generate bool) (ed25519.PrivateKey, error) { if err != nil { return nil, fmt.Errorf("key file %s: not hex", path) } - switch len(raw) { - case ed25519.SeedSize: - return ed25519.NewKeyFromSeed(raw), nil - case ed25519.PrivateKeySize: - return ed25519.PrivateKey(raw), nil + priv, err := receipts.ParsePrivateKey(raw) + if err != nil { + return nil, fmt.Errorf("key file %s: %w", path, err) } - return nil, fmt.Errorf("key file %s: want %d or %d bytes, got %d", path, ed25519.SeedSize, ed25519.PrivateKeySize, len(raw)) + return priv, nil } func generateKey(path string) (ed25519.PrivateKey, error) { diff --git a/cmd/receipt-writer/main_test.go b/cmd/receipt-writer/main_test.go index af8df90..eb1c3b3 100644 --- a/cmd/receipt-writer/main_test.go +++ b/cmd/receipt-writer/main_test.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "crypto/ed25519" + "encoding/hex" "encoding/json" "io" "log" @@ -195,10 +196,15 @@ func TestLoadKey(t *testing.T) { t.Fatalf("reload changed the key: %v", err) } full := filepath.Join(dir, "full") - _ = os.WriteFile(full, []byte(strings.Repeat("ab", 64)), 0o600) + _ = os.WriteFile(full, []byte(hex.EncodeToString(k1)), 0o600) if _, err := loadKey(full, false); err != nil { t.Fatalf("64-byte hex key rejected: %v", err) } + mismatch := filepath.Join(dir, "mismatch") + _ = os.WriteFile(mismatch, []byte(strings.Repeat("ab", 64)), 0o600) + if _, err := loadKey(mismatch, false); err == nil { + t.Fatal("64-byte key with a mismatched public half accepted") + } for name, content := range map[string]string{"not hex": "zz", "short": "abcd"} { p := filepath.Join(dir, strings.ReplaceAll(name, " ", "-")) _ = os.WriteFile(p, []byte(content), 0o600) diff --git a/docs/SHOWCASE-DEMO-WALKTHROUGH.md b/docs/SHOWCASE-DEMO-WALKTHROUGH.md index ee2b6c9..70d3230 100644 --- a/docs/SHOWCASE-DEMO-WALKTHROUGH.md +++ b/docs/SHOWCASE-DEMO-WALKTHROUGH.md @@ -236,7 +236,7 @@ Protect the closing slide. Under time pressure, cut the opener slide first. - Do not say replay is live. - Do not claim same-run signed attestation is complete. - Do not claim customers, revenue, deployments, or production adoption. -- If asked about OPA: the Rego files are samples that nothing evaluates. The action path is a Go threshold check on agent-claimed inputs. +- If asked about OPA: policy packs (`dpdp-in@v0.1.0`, `gdpr-eu@v0.1.0`) are Rego and run through the embedded OPA engine on the direct action path. The sample Rego files under `config/policies/samples/` are not evaluated. The threshold check still runs after packs. Orchestrated runtimes reject packs. See `docs/policy-packs.md`. - Do not call configuration evidence runtime proof. ## One-line close diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 81c758e..e288c7c 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -90,8 +90,8 @@ This document describes the security boundaries, threat vectors, and mitigations | Threat | Mitigation | Status | |--------|-----------|--------| -| Agent self-reported inputs (`confidence`, `cluster_health`) drive the action decision | Treated as agent-claimed. They may tighten a decision, never loosen it. Platform-observed inputs are planned. See [decision architecture](../architecture/decision-architecture.md) | Open | -| Prompt injection moves a model gate verdict | A model never owns the floor. Invariants in code bound every decision. No model gate exists today | Design | +| Agent self-reported inputs (`confidence`, `cluster_health`) drive the action decision | Treated as agent-claimed. They may tighten a decision, never loosen it. The operator also records platform-observed inputs: destination host, data classes found by `pkg/dataclass`, tool, and per-workload history. Invariants INV-01 to INV-06 and policy packs act on observed facts. Direct action path only. See [decision architecture](../architecture/decision-architecture.md) and [policy input](../policy-input.md) | Mitigated on the direct path. Runtime-adapter path open | +| Prompt injection moves a model gate verdict | A model never owns the floor. Invariants in code bound every decision. The decision model is escalate-only: it can move allow to require_approval and never loosens. Shadow by default. The shipped artifact is trained on synthetic data and is not production-validated. See [decision model](../architecture/decision-model.md) | Mitigated by design. Model not production-validated | ## Network Security diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index d75c962..667190f 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -634,7 +634,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques result, recorded := r.recordDecision(ctx, &workload, actionName, decisionInput, executeParams, layers, result, opaPolicyMode, modelBlock) decisiontrace.SetResult(decisionSpan, result.Layer, string(result.Outcome)) decisionSpan.End() - log.Info("decision", "outcome", result.Outcome, "layer", result.Layer, "destination", decisionInput.Observed.Destination, "dataClasses", decisionInput.Observed.DataClasses, "reasons", result.Reasons) + log.Info("decision", "outcome", result.Outcome, "layer", result.Layer, "destination", decisionInput.Observed.Destination, "dataClasses", decisionInput.Observed.DataClasses, "reasons", safeLogTexts(result.Reasons)) // Step 5: Handle action execution or approval pending action := agenticv1alpha1.Action{ @@ -651,26 +651,26 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques // effect, and the action is decided again on the fresh object. if err := r.reserveExecution(ctx, &workload, action.Name, recorded, now); err != nil { if apierrors.IsConflict(err) { - log.Info("workload changed after the decision; not executing, deciding again", "action", action.Name) + log.Info("workload changed after the decision; not executing, deciding again", "action", safeLogText(action.Name)) return ctrl.Result{RequeueAfter: time.Second}, nil } - log.Error(err, "execution reservation failed; not executing", "action", action.Name) + log.Error(err, "execution reservation failed; not executing", "action", safeLogText(action.Name)) return ctrl.Result{}, err } // Step 5a: Execute approved action via MCP - log.Info("OPA approved action, executing", "action", action.Name) + log.Info("OPA approved action, executing", "action", safeLogText(action.Name)) execution, err := mcpClient.CallTool("execute_action", executeParams) if err != nil { - log.Error(err, "failed to execute action", "action", action.Name) + log.Error(err, "failed to execute action", "action", safeLogText(action.Name)) workload.Status.Phase = "Failed" action.Approved = boolPtr(false) workload.Status.ProposedActions = append(workload.Status.ProposedActions, action) prunedProposed := pruneActions(workload.Status.ProposedActions, maxActionsInStatus) workload.Status.ProposedActions = prunedProposed } else { - log.Info("Action executed successfully", "action", action.Name, "resultFields", len(execution)) + log.Info("Action executed successfully", "action", safeLogText(action.Name), "resultFields", len(execution)) action.Approved = boolPtr(true) workload.Status.ExecutedActions = append(workload.Status.ExecutedActions, action) prunedExecuted := pruneActions(workload.Status.ExecutedActions, maxActionsInStatus) @@ -679,7 +679,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques } } else { // Step 5b: Mark for human approval - log.Info("action not allowed, requiring human approval", "action", action.Name, "outcome", result.Outcome, "reasons", result.Reasons) + log.Info("action not allowed, requiring human approval", "action", safeLogText(action.Name), "outcome", result.Outcome, "reasons", safeLogTexts(result.Reasons)) action.Approved = boolPtr(false) workload.Status.ProposedActions = append(workload.Status.ProposedActions, action) prunedProposed := pruneActions(workload.Status.ProposedActions, maxActionsInStatus) @@ -697,7 +697,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques Status: metav1.ConditionTrue, ObservedGeneration: workload.Generation, Reason: reason, - Message: fmt.Sprintf("action %q needs human approval: %s", action.Name, strings.Join(result.Reasons, "; ")), + Message: fmt.Sprintf("action %q needs human approval: %s", safeLogText(action.Name), safeLogText(strings.Join(result.Reasons, "; "))), LastTransitionTime: now, }) case workload.Spec.OPAPolicy != nil && *workload.Spec.OPAPolicy == "strict": @@ -707,7 +707,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques Status: metav1.ConditionTrue, ObservedGeneration: workload.Generation, Reason: "OPADenied", - Message: fmt.Sprintf("OPA denied action %q (%s layer): %s", action.Name, result.Layer, strings.Join(result.Reasons, "; ")), + Message: fmt.Sprintf("OPA denied action %q (%s layer): %s", safeLogText(action.Name), result.Layer, safeLogText(strings.Join(result.Reasons, "; "))), LastTransitionTime: now, }) default: diff --git a/internal/controller/approvals.go b/internal/controller/approvals.go index 68a9217..ff761e4 100644 --- a/internal/controller/approvals.go +++ b/internal/controller/approvals.go @@ -390,7 +390,7 @@ func (r *AgentWorkloadReconciler) reconcileApproval(ctx context.Context, wl *age _, execErr := mcp.NewMCPClient(endpoint).CallTool("execute_action", execPayload) executed := agenticv1alpha1.Action{Name: tgt.name, Description: tgt.description, Confidence: p.Confidence, Timestamp: &now} if execErr != nil { - log.Error(execErr, "failed to execute approved action", "action", tgt.name) + log.Error(execErr, "failed to execute approved action", "action", safeLogText(tgt.name)) executed.Approved = boolPtr(false) wl.Status.ProposedActions = pruneActions(append(wl.Status.ProposedActions, executed), maxActionsInStatus) return r.finishApproval(ctx, wl, d.Label, approvalFailed, d.ApproverSHA256(), receiptSeq(humanReceipt), diff --git a/internal/controller/logsafe.go b/internal/controller/logsafe.go new file mode 100644 index 0000000..41494e4 --- /dev/null +++ b/internal/controller/logsafe.go @@ -0,0 +1,36 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "strings" + + "github.com/Clawdlinux/agentic-operator-core/pkg/dataclass" +) + +const maxLogTextBytes = 160 + +// safeLogText returns s for logs and status messages. Action names and the +// reasons built from them come from the MCP server, so they are untrusted. A +// value that carries a personal or credential data class is replaced by the +// class names, and everything is capped in length. +func safeLogText(s string) string { + if classes := dataclass.Detect(s); len(classes) > 0 { + return "[redacted:" + strings.Join(dataclass.Strings(classes), ",") + "]" + } + if len(s) > maxLogTextBytes { + return s[:maxLogTextBytes] + "...[truncated]" + } + return s +} + +func safeLogTexts(in []string) []string { + out := make([]string, len(in)) + for i, s := range in { + out[i] = safeLogText(s) + } + return out +} diff --git a/internal/controller/logsafe_test.go b/internal/controller/logsafe_test.go new file mode 100644 index 0000000..fc7cedb --- /dev/null +++ b/internal/controller/logsafe_test.go @@ -0,0 +1,31 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package controller + +import ( + "strings" + "testing" +) + +func TestSafeLogText(t *testing.T) { + if got := safeLogText("restart_pod"); got != "restart_pod" { + t.Fatalf("plain name changed: %q", got) + } + for _, in := range []string{"use key AKIAIOSFODNN7EXAMPLE", "mail canary.person@example.com now"} { + got := safeLogText(in) + if strings.Contains(got, "AKIA") || strings.Contains(got, "@") || !strings.HasPrefix(got, "[redacted:") { + t.Fatalf("sensitive text not redacted: %q", got) + } + } + long := strings.Repeat("a", 1000) + if got := safeLogText(long); len(got) > maxLogTextBytes+20 { + t.Fatalf("long text not capped: %d bytes", len(got)) + } + out := safeLogTexts([]string{"ok", "AKIAIOSFODNN7EXAMPLE"}) + if out[0] != "ok" || strings.Contains(out[1], "AKIA") { + t.Fatalf("safeLogTexts = %v", out) + } +} diff --git a/internal/controller/mcp_log_canary_test.go b/internal/controller/mcp_log_canary_test.go index e5d468d..9d1f227 100644 --- a/internal/controller/mcp_log_canary_test.go +++ b/internal/controller/mcp_log_canary_test.go @@ -53,6 +53,13 @@ func TestReconcile_InvalidMCPRepliesNeverLogValues(t *testing.T) { } writeTool(w, mcp.ToolResponse{Success: true, Result: map[string]interface{}{"action": "noop", "description": "d", "confidence": canary}}) }}, + {name: "credential in action name", reply: func(tool string, w http.ResponseWriter) { + if tool == "get_status" { + writeTool(w, okStatus) + return + } + writeTool(w, mcp.ToolResponse{Success: true, Result: map[string]interface{}{"action": "delete " + canary, "description": "d", "confidence": "0.10"}}) + }}, {name: "error body", reply: func(_ string, w http.ResponseWriter) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(canary)) diff --git a/internal/controller/receipts.go b/internal/controller/receipts.go index a4e6003..5b2833c 100644 --- a/internal/controller/receipts.go +++ b/internal/controller/receipts.go @@ -84,10 +84,10 @@ func (r *AgentWorkloadReconciler) recordDecision( } } if !r.Receipts.Required { - log.Error(err, "decision receipt not written; receipts not required, continuing", "action", action, "outcome", result.Outcome) + log.Error(err, "decision receipt not written; receipts not required, continuing", "action", safeLogText(action), "outcome", result.Outcome) return result, out } - log.Error(err, "decision receipt not written; receipts required, failing closed", "action", action) + log.Error(err, "decision receipt not written; receipts required, failing closed", "action", safeLogText(action)) for _, res := range invariants.Check(in, invariants.Context{ReceiptsRequired: true}) { if res.ID == invariants.ReceiptsOrFailClosed && !slices.Contains(layers.Invariants, res.String()) { layers.Invariants = append(layers.Invariants, res.String()) diff --git a/pkg/decision/features_test.go b/pkg/decision/features_test.go new file mode 100644 index 0000000..c29ac7e --- /dev/null +++ b/pkg/decision/features_test.go @@ -0,0 +1,33 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package decision + +import ( + "slices" + "testing" + + "github.com/Clawdlinux/agentic-operator-core/pkg/decision/input" +) + +// Dataset examples do not carry the declared purpose, so the feature vector +// must not depend on it. Live scoring and training share ExtractFeatures. +func TestExtractFeaturesIgnoresPurpose(t *testing.T) { + obs := input.Observed{Destination: "https://api.example.test/x", DataClasses: []string{"email"}} + withPurpose := FeatureInput{Input: input.Input{ + Declared: input.Declared{Purpose: "refund triage"}, + Observed: obs, + }} + without := FeatureInput{Input: input.Input{Observed: obs}} + + a, b := ExtractFeatures(withPurpose), ExtractFeatures(without) + if !slices.Equal(a.Values, b.Values) { + t.Fatalf("purpose changed the feature vector:\n%v\n%v", a.Values, b.Values) + } + idx := slices.Index(FeatureNames, "declared_present") + if a.Values[idx] != 0 { + t.Fatalf("declared_present = %d for a purpose-only declaration, want 0", a.Values[idx]) + } +} diff --git a/pkg/receipts/key.go b/pkg/receipts/key.go new file mode 100644 index 0000000..17852e2 --- /dev/null +++ b/pkg/receipts/key.go @@ -0,0 +1,30 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "bytes" + "crypto/ed25519" + "fmt" +) + +// ParsePrivateKey builds an Ed25519 private key from a 32-byte seed or a +// 64-byte private key. A 64-byte key is seed plus public half, so the public +// half must match the one derived from the seed. A mismatched key would sign +// receipts that no trust root derived from it can verify. +func ParsePrivateKey(raw []byte) (ed25519.PrivateKey, error) { + switch len(raw) { + case ed25519.SeedSize: + return ed25519.NewKeyFromSeed(raw), nil + case ed25519.PrivateKeySize: + derived := ed25519.NewKeyFromSeed(raw[:ed25519.SeedSize]) + if !bytes.Equal(derived, raw) { + return nil, fmt.Errorf("receipts: private key public half does not match its seed") + } + return ed25519.PrivateKey(append([]byte(nil), raw...)), nil + } + return nil, fmt.Errorf("receipts: signing key must be %d or %d bytes, got %d", ed25519.SeedSize, ed25519.PrivateKeySize, len(raw)) +} diff --git a/pkg/receipts/key_test.go b/pkg/receipts/key_test.go new file mode 100644 index 0000000..93bb84e --- /dev/null +++ b/pkg/receipts/key_test.go @@ -0,0 +1,35 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receipts + +import ( + "bytes" + "crypto/ed25519" + "testing" +) + +func TestParsePrivateKey(t *testing.T) { + seed := bytes.Repeat([]byte{7}, ed25519.SeedSize) + full := ed25519.NewKeyFromSeed(seed) + + fromSeed, err := ParsePrivateKey(seed) + if err != nil || !bytes.Equal(fromSeed, full) { + t.Fatalf("seed: key %x err %v", fromSeed, err) + } + fromFull, err := ParsePrivateKey(full) + if err != nil || !bytes.Equal(fromFull, full) { + t.Fatalf("full key: %v", err) + } + + bad := append([]byte(nil), full...) + bad[len(bad)-1] ^= 0xff + if _, err := ParsePrivateKey(bad); err == nil { + t.Fatal("accepted a key whose public half does not match the seed") + } + if _, err := ParsePrivateKey(make([]byte, 10)); err == nil { + t.Fatal("accepted a wrong-length key") + } +} diff --git a/pkg/receipts/lock_other.go b/pkg/receipts/lock_other.go index d05e2a8..c1346e2 100644 --- a/pkg/receipts/lock_other.go +++ b/pkg/receipts/lock_other.go @@ -7,9 +7,13 @@ Licensed under the Apache License, Version 2.0. package receipts -import "os" +import ( + "errors" + "os" +) -// lockFile only opens the lock file. Cross-process locking needs unix flock. -func lockFile(path string) (*os.File, error) { - return os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) +// lockFile fails. Two writers on one chain would fork it, and only unix flock +// is implemented, so refuse to start rather than run unlocked. +func lockFile(string) (*os.File, error) { + return nil, errors.New("receipts: chain locking is only implemented on unix platforms") } diff --git a/pkg/receipts/trustroot.go b/pkg/receipts/trustroot.go index 28bed93..4414275 100644 --- a/pkg/receipts/trustroot.go +++ b/pkg/receipts/trustroot.go @@ -23,14 +23,9 @@ func TrustRootFromKeyHex(keyHex string) (json.RawMessage, error) { if err != nil { return nil, fmt.Errorf("receipts: signing key is not hex") } - var priv ed25519.PrivateKey - switch len(raw) { - case ed25519.SeedSize: - priv = ed25519.NewKeyFromSeed(raw) - case ed25519.PrivateKeySize: - priv = ed25519.PrivateKey(raw) - default: - return nil, fmt.Errorf("receipts: signing key must be %d or %d bytes", ed25519.SeedSize, ed25519.PrivateKeySize) + priv, err := ParsePrivateKey(raw) + if err != nil { + return nil, err } pub := priv.Public().(ed25519.PublicKey) return TrustRootJSON(receiptspec.TrustedKey{KID: receiptspec.ComputeKID(pub), PublicKey: pub}) diff --git a/scripts/scan_secrets.sh b/scripts/scan_secrets.sh index 9187e4e..b4e0537 100755 --- a/scripts/scan_secrets.sh +++ b/scripts/scan_secrets.sh @@ -4,7 +4,9 @@ set -euo pipefail MODE="${1:-local}" PATTERN='(dop_v1_[A-Za-z0-9_-]{10,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{70,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|-----BEGIN (RSA|EC|OPENSSH|DSA)? ?PRIVATE KEY-----|xox[baprs]-[A-Za-z0-9-]{10,}|sk_live_[0-9a-zA-Z]{16,}|AIza[0-9A-Za-z\-_]{35})' -ALLOWLIST_PATHS_REGEX='^(agents/tests/test_credential_sanitizer\.py):' +# Fixtures that hold fake credentials (AWS's documented example key and +# similar) to prove detectors, redaction and denial paths work. +ALLOWLIST_PATHS_REGEX='^(agents/tests/test_credential_sanitizer\.py|internal/controller/(agentworkload_controller_approval_test|agentworkload_controller_opa_test|decision_tracing_test|mcp_log_canary_test)\.go|pkg/dataclass/dataclass_test\.go|pkg/dataset/dataset_test\.go|pkg/decision/observe/observe_test\.go|pkg/tracing/decisiontrace/decisiontrace_test\.go|scripts/demo-claims\.sh|scripts/demo/mockmcp/main\.go):' collect_files() { if [[ "$MODE" == "--ci" ]]; then From d742befa81be3bd80d5204f1b0ac0d94a0428f5f Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 10:30:06 +0530 Subject: [PATCH 51/54] fix: redact threshold reasons in the OPA evaluation log, allowlist logsafe test fixtures Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- internal/controller/agentworkload_controller.go | 2 +- internal/controller/mcp_log_canary_test.go | 4 ++-- scripts/scan_secrets.sh | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index 667190f..27bb613 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -604,7 +604,7 @@ func (r *AgentWorkloadReconciler) Reconcile(ctx context.Context, req ctrl.Reques decisiontrace.SetRules(decisionSpan, nil, workload.Spec.PolicyPacks) opaResult := traceThreshold(ctx, opaPolicyMode, actionName, confidence, clusterHealth) - log.Info("OPA evaluation result", "allowed", opaResult.Allowed, "confidence", opaResult.Confidence, "reasons", opaResult.Reasons) + log.Info("OPA evaluation result", "allowed", opaResult.Allowed, "confidence", opaResult.Confidence, "reasons", safeLogTexts(opaResult.Reasons)) // Both threshold inputs come from the agent's MCP server, not the platform. log.Info("agent-claimed decision inputs", "claimedConfidence", confidence, "claimedClusterHealth", claimedHealth, "clusterHealthUsed", clusterHealth) diff --git a/internal/controller/mcp_log_canary_test.go b/internal/controller/mcp_log_canary_test.go index 9d1f227..0d5f574 100644 --- a/internal/controller/mcp_log_canary_test.go +++ b/internal/controller/mcp_log_canary_test.go @@ -53,12 +53,12 @@ func TestReconcile_InvalidMCPRepliesNeverLogValues(t *testing.T) { } writeTool(w, mcp.ToolResponse{Success: true, Result: map[string]interface{}{"action": "noop", "description": "d", "confidence": canary}}) }}, - {name: "credential in action name", reply: func(tool string, w http.ResponseWriter) { + {name: "credential in action name and threshold reason", reply: func(tool string, w http.ResponseWriter) { if tool == "get_status" { writeTool(w, okStatus) return } - writeTool(w, mcp.ToolResponse{Success: true, Result: map[string]interface{}{"action": "delete " + canary, "description": "d", "confidence": "0.10"}}) + writeTool(w, mcp.ToolResponse{Success: true, Result: map[string]interface{}{"action": "get_" + canary, "description": "d", "confidence": "0.10"}}) }}, {name: "error body", reply: func(_ string, w http.ResponseWriter) { w.WriteHeader(http.StatusBadRequest) diff --git a/scripts/scan_secrets.sh b/scripts/scan_secrets.sh index b4e0537..9670a7d 100755 --- a/scripts/scan_secrets.sh +++ b/scripts/scan_secrets.sh @@ -6,7 +6,7 @@ MODE="${1:-local}" PATTERN='(dop_v1_[A-Za-z0-9_-]{10,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{70,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|-----BEGIN (RSA|EC|OPENSSH|DSA)? ?PRIVATE KEY-----|xox[baprs]-[A-Za-z0-9-]{10,}|sk_live_[0-9a-zA-Z]{16,}|AIza[0-9A-Za-z\-_]{35})' # Fixtures that hold fake credentials (AWS's documented example key and # similar) to prove detectors, redaction and denial paths work. -ALLOWLIST_PATHS_REGEX='^(agents/tests/test_credential_sanitizer\.py|internal/controller/(agentworkload_controller_approval_test|agentworkload_controller_opa_test|decision_tracing_test|mcp_log_canary_test)\.go|pkg/dataclass/dataclass_test\.go|pkg/dataset/dataset_test\.go|pkg/decision/observe/observe_test\.go|pkg/tracing/decisiontrace/decisiontrace_test\.go|scripts/demo-claims\.sh|scripts/demo/mockmcp/main\.go):' +ALLOWLIST_PATHS_REGEX='^(agents/tests/test_credential_sanitizer\.py|internal/controller/(logsafe_test|agentworkload_controller_approval_test|agentworkload_controller_opa_test|decision_tracing_test|mcp_log_canary_test)\.go|pkg/dataclass/dataclass_test\.go|pkg/dataset/dataset_test\.go|pkg/decision/observe/observe_test\.go|pkg/tracing/decisiontrace/decisiontrace_test\.go|scripts/demo-claims\.sh|scripts/demo/mockmcp/main\.go):' collect_files() { if [[ "$MODE" == "--ci" ]]; then From a4f616ff6e82e7ec87ef07ee2ff643e1267abedd Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 10:40:54 +0530 Subject: [PATCH 52/54] fix: bind approvals to spec, require pinned trust root, tighten secret allowlist The webhook rejects changes to declaredIntent, policyPacks, opaPolicy, decisionModel and approvalCapture while an approval is pending. agentctl dataset verify requires a pinned trust root, and decision eval counts data as verified only with one. The secret scanner allows exact fixture values instead of whole files. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- CHANGELOG.md | 1 + api/v1alpha1/agentworkload_webhook.go | 38 ++++++++++++++++++++ api/v1alpha1/approval_freeze_test.go | 50 +++++++++++++++++++++++++++ cmd/agentctl/dataset.go | 12 +++++-- cmd/agentctl/dataset_test.go | 31 ++++++++++++++--- cmd/agentctl/decision.go | 4 ++- cmd/agentctl/decision_test.go | 12 +++++-- docs/approvals.md | 14 +++++++- docs/architecture/decision-model.md | 4 ++- scripts/scan_secrets.sh | 37 +++++++++++++++++--- 10 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 api/v1alpha1/approval_freeze_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a7172ea..7b12e94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ covered yet. See [decision architecture](docs/architecture/decision-architecture - Agent-claimed inputs can only raise model risk. The model scores with and without claims and takes the higher value. - The direct path reserves execution with a status update before it runs an allowed action, so a stale reconcile cannot run after the spec tightens. - The remote receipt writer client verifies each receipt under a pinned writer key. +- `agentctl dataset verify` requires `--trust-root` (or `--allow-embedded-trust` for a consistency-only check). `agentctl decision eval` marks data verified only with a pinned `--trust-root`. The webhook rejects changes to `declaredIntent`, `policyPacks`, `opaPolicy`, `decisionModel` and `approvalCapture` while an approval is pending. - `agentctl receipts verify` and `agentctl dataset verify` need a signed manifest by default. `--allow-prefix` is the weaker mode. - Receipts bind the executed payload by digest (`payload_sha256`). - The approval stamp carries an HMAC, and a consumed approval cannot be replayed. diff --git a/api/v1alpha1/agentworkload_webhook.go b/api/v1alpha1/agentworkload_webhook.go index a8b57bb..4ca03d1 100644 --- a/api/v1alpha1/agentworkload_webhook.go +++ b/api/v1alpha1/agentworkload_webhook.go @@ -28,6 +28,7 @@ import ( "time" "unicode/utf8" + apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/validation/field" @@ -127,9 +128,46 @@ func (r *AgentWorkload) ValidateUpdate(old runtime.Object) error { ) } + if err := r.validateApprovalFreeze(oldWorkload); err != nil { + return err + } + return nil } +// validateApprovalFreeze rejects changes to the fields that shape a decision +// while an action is held for a human or a decision is being recorded. The +// stamp binds an approval to the proposal, not to the spec. Without this an +// approved proposal could run under a different declared intent, policy pack +// set, policy mode, or model setting than the reviewer saw. +func (r *AgentWorkload) validateApprovalFreeze(old *AgentWorkload) error { + p := old.Status.PendingApproval + if p == nil || (old.Status.Phase != PhasePendingApproval && p.State == "") { + return nil + } + var changed []string + if !apiequality.Semantic.DeepEqual(old.Spec.DeclaredIntent, r.Spec.DeclaredIntent) { + changed = append(changed, "declaredIntent") + } + if !apiequality.Semantic.DeepEqual(old.Spec.PolicyPacks, r.Spec.PolicyPacks) { + changed = append(changed, "policyPacks") + } + if !stringPtrEqual(old.Spec.OPAPolicy, r.Spec.OPAPolicy) { + changed = append(changed, "opaPolicy") + } + if !apiequality.Semantic.DeepEqual(old.Spec.DecisionModel, r.Spec.DecisionModel) { + changed = append(changed, "decisionModel") + } + if !apiequality.Semantic.DeepEqual(old.Spec.ApprovalCapture, r.Spec.ApprovalCapture) { + changed = append(changed, "approvalCapture") + } + if len(changed) == 0 { + return nil + } + return apierrors.NewForbidden(GroupVersion.WithResource("agentworkloads").GroupResource(), r.Name, + fmt.Errorf("spec.%s cannot change while an approval is pending. Approve, reject or edit the pending action first", strings.Join(changed, ", spec."))) +} + // ValidateDelete validates the resource on deletion func (r *AgentWorkload) ValidateDelete() error { agentworkloadlog.Info("validate delete", "name", r.Name) diff --git a/api/v1alpha1/approval_freeze_test.go b/api/v1alpha1/approval_freeze_test.go new file mode 100644 index 0000000..ab776e1 --- /dev/null +++ b/api/v1alpha1/approval_freeze_test.go @@ -0,0 +1,50 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package v1alpha1 + +import ( + "strings" + "testing" +) + +func TestValidateApprovalFreeze(t *testing.T) { + base := func() *AgentWorkload { + return &AgentWorkload{Spec: AgentWorkloadSpec{ + MCPServerEndpoint: stringPtr("https://localhost:8000"), + OPAPolicy: stringPtr("strict"), + PolicyPacks: []string{"gdpr-eu@v0.1.0"}, + }} + } + pending := base() + pending.Status.Phase = PhasePendingApproval + pending.Status.PendingApproval = &PendingApproval{ID: "p1"} + + same := base() + if err := same.validateApprovalFreeze(pending); err != nil { + t.Fatalf("unchanged spec rejected: %v", err) + } + + loosened := base() + loosened.Spec.OPAPolicy = stringPtr("permissive") + loosened.Spec.PolicyPacks = nil + err := loosened.validateApprovalFreeze(pending) + if err == nil || !strings.Contains(err.Error(), "spec.opaPolicy") || !strings.Contains(err.Error(), "spec.policyPacks") { + t.Fatalf("spec change while pending not rejected: %v", err) + } + + idle := base() + idle.Status.Phase = "Running" + if err := loosened.validateApprovalFreeze(idle); err != nil { + t.Fatalf("change with nothing pending rejected: %v", err) + } + + recording := base() + recording.Status.Phase = "Running" + recording.Status.PendingApproval = &PendingApproval{ID: "p1", State: ApprovalStateRecording} + if err := loosened.validateApprovalFreeze(recording); err == nil { + t.Fatal("change while a decision is being recorded not rejected") + } +} diff --git a/cmd/agentctl/dataset.go b/cmd/agentctl/dataset.go index 12a8e1e..06dc23b 100644 --- a/cmd/agentctl/dataset.go +++ b/cmd/agentctl/dataset.go @@ -79,16 +79,20 @@ func newDatasetExportCommand() *cobra.Command { func newDatasetVerifyCommand() *cobra.Command { var trustRoot string - var allowPrefix bool + var allowPrefix, allowEmbeddedTrust bool cmd := &cobra.Command{ Use: "verify ", Short: "Verify an approval dataset export offline", Long: "Verifies the receipt chain and decision records like 'agentctl receipts verify', then checks " + "that every example's receipt_seq and entry hash exist in the chain and that its label matches " + "the human decision receipt. A pass requires a signed export manifest that proves completeness " + - "unless --allow-prefix is set.", + "unless --allow-prefix is set. Pass --trust-root with a trust file pinned out of band. Without it the export's own " + + "trust.json would decide who is trusted, so verification refuses unless --allow-embedded-trust is set.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if trustRoot == "" && !allowEmbeddedTrust { + return fmt.Errorf("--trust-root is required: an export that carries its own trust root can be replaced with an attacker-signed copy and still pass. Pin a trust file out of band, or pass --allow-embedded-trust for a consistency-only check") + } var trusted []receiptspec.TrustedKey if trustRoot != "" { data, err := os.ReadFile(trustRoot) @@ -104,6 +108,9 @@ func newDatasetVerifyCommand() *cobra.Command { return err } stdout, stderr := cmd.OutOrStdout(), cmd.ErrOrStderr() + if trustRoot == "" { + fmt.Fprintln(stderr, "WARNING: trust root taken from the export itself (--allow-embedded-trust). This proves internal consistency only, not who signed it.") + } rep, err := dataset.Verify(e, approvals, trusted) if receipts.IsManifestError(err) { fmt.Fprintf(stderr, "FAIL: manifest: %v\n", err) @@ -132,5 +139,6 @@ func newDatasetVerifyCommand() *cobra.Command { } cmd.Flags().StringVar(&trustRoot, "trust-root", "", "trust file pinned out of band (receiptspec trust file format)") cmd.Flags().BoolVar(&allowPrefix, "allow-prefix", false, allowPrefixHelp) + cmd.Flags().BoolVar(&allowEmbeddedTrust, "allow-embedded-trust", false, "use the export's own trust.json when --trust-root is not given; proves consistency only, not authenticity") return cmd } diff --git a/cmd/agentctl/dataset_test.go b/cmd/agentctl/dataset_test.go index 070a132..a7f0e40 100644 --- a/cmd/agentctl/dataset_test.go +++ b/cmd/agentctl/dataset_test.go @@ -113,7 +113,7 @@ func TestDatasetVerify(t *testing.T) { t.Fatal(err) } _ = os.WriteFile(filepath.Join(dir, dataset.ApprovalsFile), []byte(approvals), 0o600) - out, errOut, err := runAgentctl(t, "dataset", "verify", dir) + out, errOut, err := runAgentctl(t, "dataset", "verify", "--allow-embedded-trust", dir) if tc.wantErr != "" { if !errors.Is(err, errVerifyFailed) || !strings.Contains(errOut, tc.wantErr) { t.Fatalf("err = %v, stderr = %q, want %q", err, errOut, tc.wantErr) @@ -132,16 +132,16 @@ func TestDatasetVerify(t *testing.T) { t.Fatal(err) } _ = os.WriteFile(filepath.Join(stripped, dataset.ApprovalsFile), []byte(approvals), 0o600) - if _, errOut, err := runAgentctl(t, "dataset", "verify", stripped); !errors.Is(err, errVerifyFailed) || !strings.Contains(errOut, "FAIL: completeness not proven") { + if _, errOut, err := runAgentctl(t, "dataset", "verify", "--allow-embedded-trust", stripped); !errors.Is(err, errVerifyFailed) || !strings.Contains(errOut, "FAIL: completeness not proven") { t.Fatalf("manifest-free dataset err = %v, stderr = %q", err, errOut) } - if _, errOut, err := runAgentctl(t, "dataset", "verify", "--allow-prefix", stripped); err != nil || !strings.Contains(errOut, "WARNING: completeness NOT proven") { + if _, errOut, err := runAgentctl(t, "dataset", "verify", "--allow-embedded-trust", "--allow-prefix", stripped); err != nil || !strings.Contains(errOut, "WARNING: completeness NOT proven") { t.Fatalf("allow-prefix err = %v, stderr = %q", err, errOut) } dir := filepath.Join(t.TempDir(), "noapprovals") _ = e.WriteDir(dir) - if _, _, err := runAgentctl(t, "dataset", "verify", dir); err == nil || !strings.Contains(err.Error(), dataset.ApprovalsFile) { + if _, _, err := runAgentctl(t, "dataset", "verify", "--allow-embedded-trust", dir); err == nil || !strings.Contains(err.Error(), dataset.ApprovalsFile) { t.Fatalf("missing approvals file err = %v", err) } } @@ -172,7 +172,7 @@ func TestDatasetExport(t *testing.T) { if err != nil || !strings.Contains(stdout, "exported 1 approval examples") { t.Fatalf("export: %v %q", err, stdout) } - if stdout, _, err := runAgentctl(t, "dataset", "verify", out); err != nil || !strings.Contains(stdout, "PASS: 1 approval examples") { + if stdout, _, err := runAgentctl(t, "dataset", "verify", "--allow-embedded-trust", out); err != nil || !strings.Contains(stdout, "PASS: 1 approval examples") { t.Fatalf("verify exported dir: %v %q", err, stdout) } _ = os.WriteFile(tokenFile, []byte("wrong\n"), 0o600) @@ -196,3 +196,24 @@ func TestApprovalCommandsHelp(t *testing.T) { } } } + +func TestDatasetVerifyRequiresPinnedTrustRoot(t *testing.T) { + e, approvals := datasetExport(t) + dir := filepath.Join(t.TempDir(), "export") + if err := e.WriteDir(dir); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, dataset.ApprovalsFile), []byte(approvals), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := runAgentctl(t, "dataset", "verify", dir); err == nil || !strings.Contains(err.Error(), "--trust-root is required") { + t.Fatalf("verify without a trust root err = %v", err) + } + pinned := filepath.Join(t.TempDir(), "pinned-trust.json") + if err := os.WriteFile(pinned, e.TrustRoot, 0o600); err != nil { + t.Fatal(err) + } + if stdout, _, err := runAgentctl(t, "dataset", "verify", dir, "--trust-root", pinned); err != nil || !strings.Contains(stdout, "PASS") { + t.Fatalf("verify with a pinned trust root: %v\n%s", err, stdout) + } +} diff --git a/cmd/agentctl/decision.go b/cmd/agentctl/decision.go index 8983ea5..3502eda 100644 --- a/cmd/agentctl/decision.go +++ b/cmd/agentctl/decision.go @@ -141,7 +141,9 @@ func loadEvalDataset(path, trustRoot string) ([]dataset.Example, bool, error) { if err != nil { return nil, false, err } - return exs, verr == nil && rep.OK(), nil + // Verified means authenticated: only an out-of-band trust root counts. + // An export that carries its own trust.json can be replaced wholesale. + return exs, trustRoot != "" && verr == nil && rep.OK(), nil } func micro(v int64) string { diff --git a/cmd/agentctl/decision_test.go b/cmd/agentctl/decision_test.go index 52ae4b2..0176a79 100644 --- a/cmd/agentctl/decision_test.go +++ b/cmd/agentctl/decision_test.go @@ -81,7 +81,15 @@ func TestDecisionEvalVerifiedExport(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, dataset.ApprovalsFile), []byte(approvals), 0o600); err != nil { t.Fatal(err) } - out, _, err := runAgentctl(t, "decision", "eval", "--dataset", dir, "--model", shippedModel) + // An export that carries its own trust root is not authenticated. + if _, _, err := runAgentctl(t, "decision", "eval", "--dataset", dir, "--model", shippedModel); err == nil || !strings.Contains(err.Error(), "not verified") { + t.Fatalf("export with only its own trust root err = %v", err) + } + pinned := filepath.Join(t.TempDir(), "pinned-trust.json") + if err := os.WriteFile(pinned, e.TrustRoot, 0o600); err != nil { + t.Fatal(err) + } + out, _, err := runAgentctl(t, "decision", "eval", "--dataset", dir, "--model", shippedModel, "--trust-root", pinned) if err != nil { t.Fatal(err) } @@ -90,7 +98,7 @@ func TestDecisionEvalVerifiedExport(t *testing.T) { } // A tampered label no longer verifies and is refused. _ = os.WriteFile(filepath.Join(dir, dataset.ApprovalsFile), []byte(strings.Replace(approvals, `"label":"approve"`, `"label":"reject"`, 1)), 0o600) - if _, _, err := runAgentctl(t, "decision", "eval", "--dataset", dir, "--model", shippedModel); err == nil || !strings.Contains(err.Error(), "not verified") { + if _, _, err := runAgentctl(t, "decision", "eval", "--dataset", dir, "--model", shippedModel, "--trust-root", pinned); err == nil || !strings.Contains(err.Error(), "not verified") { t.Fatalf("tampered export err = %v", err) } } diff --git a/docs/approvals.md b/docs/approvals.md index d187241..0e8d1d1 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -208,7 +208,10 @@ agentctl dataset export --writer http://localhost:8080 \ agentctl dataset verify ./dataset --trust-root ./pinned-trust.json ``` -`verify` runs the same chain and record checks as `agentctl receipts verify`, +`verify` requires `--trust-root`, a trust file pinned out of band. The export's own +`trust.json` is not trusted, because anyone who can replace the export can replace +it too. `--allow-embedded-trust` runs a consistency-only check and prints a +WARNING. `verify` runs the same chain and record checks as `agentctl receipts verify`, including the signed manifest completeness check. `--allow-prefix` works the same way and prints the same WARNING. Then for each example it checks that `receipt_seq` and `receipt_entry_hash` @@ -231,3 +234,12 @@ or FAIL lines and exits 1 on any failure. for very long chains. - No synthetic seed set yet. The dataset starts empty. - Dataset examples are bound to receipts by identity and label only. Feature edits and deleted rows in an export pass `dataset verify` (BLOCKERS.md, F7 and F12). + +## Spec is frozen while an approval is pending + +The stamp binds a decision to the stored proposal, not to the spec. So the +webhook rejects changes to `spec.declaredIntent`, `spec.policyPacks`, +`spec.opaPolicy`, `spec.decisionModel` and `spec.approvalCapture` while an +action is pending or a decision is being recorded. `spec.mcpServerEndpoint` is +always immutable. Approve, reject or edit the pending action first, then change +the spec. diff --git a/docs/architecture/decision-model.md b/docs/architecture/decision-model.md index 596a233..f6967dc 100644 --- a/docs/architecture/decision-model.md +++ b/docs/architecture/decision-model.md @@ -158,7 +158,9 @@ agentctl decision eval --dataset tools/decision-train/out/approvals.jsonl \ ``` `eval` verifies an export dir like `agentctl dataset verify` and refuses -unverified data unless `--allow-unverified`. It prints a WARNING header when +unverified data unless `--allow-unverified`. Data counts as verified only with +`--trust-root` pinned out of band. An export that carries its own trust file +is treated as unverified. It prints a WARNING header when any row is synthetic or there are fewer than 200 human labels. Positive means a human rejected or edited. Recall is the share of those the model escalates. Extra review is the share of human approvals the model escalates. diff --git a/scripts/scan_secrets.sh b/scripts/scan_secrets.sh index 9670a7d..b53b169 100755 --- a/scripts/scan_secrets.sh +++ b/scripts/scan_secrets.sh @@ -4,9 +4,28 @@ set -euo pipefail MODE="${1:-local}" PATTERN='(dop_v1_[A-Za-z0-9_-]{10,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{70,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|-----BEGIN (RSA|EC|OPENSSH|DSA)? ?PRIVATE KEY-----|xox[baprs]-[A-Za-z0-9-]{10,}|sk_live_[0-9a-zA-Z]{16,}|AIza[0-9A-Za-z\-_]{35})' -# Fixtures that hold fake credentials (AWS's documented example key and -# similar) to prove detectors, redaction and denial paths work. -ALLOWLIST_PATHS_REGEX='^(agents/tests/test_credential_sanitizer\.py|internal/controller/(logsafe_test|agentworkload_controller_approval_test|agentworkload_controller_opa_test|decision_tracing_test|mcp_log_canary_test)\.go|pkg/dataclass/dataclass_test\.go|pkg/dataset/dataset_test\.go|pkg/decision/observe/observe_test\.go|pkg/tracing/decisiontrace/decisiontrace_test\.go|scripts/demo-claims\.sh|scripts/demo/mockmcp/main\.go):' +ALLOWLIST_PATHS_REGEX='^(agents/tests/test_credential_sanitizer\.py):' +# Exact fake values used by test fixtures. A matching line is allowed only if +# nothing else on it matches PATTERN once these values are removed, so a real +# token added next to a fixture still fails. +FIXTURE_VALUES='AKIAIOSFODNN7EXAMPLE|AKIAIOSFODNN7CANARY1|AKIAABCDEFGHIJKLMNOP' +# The fake PEM headers in the data class detector test, allowed in that file only. +FIXTURE_PEM_FILE='pkg/dataclass/dataclass_test.go' +PEM_DASH='-----' +FIXTURE_PEM_VALUES="${PEM_DASH}BEGIN RSA PRIVATE KEY${PEM_DASH}|${PEM_DASH}BEGIN PRIVATE KEY${PEM_DASH}" + +only_fixture_values() { + # Reads one `git grep -n` line (path:line:text) from $1. + local line="$1" path text + path="${line%%:*}" + text="${line#*:}" + text="${text#*:}" + text="$(printf '%s' "$text" | sed -E "s/($FIXTURE_VALUES)//g")" + if [[ "$path" == "$FIXTURE_PEM_FILE" ]]; then + text="$(printf '%s' "$text" | sed -E "s/($FIXTURE_PEM_VALUES)//g")" + fi + ! printf '%s' "$text" | grep -qE "$PATTERN" +} collect_files() { if [[ "$MODE" == "--ci" ]]; then @@ -72,10 +91,18 @@ fi tmp_results="$(mktemp)" tmp_filtered_results="$(mktemp)" -trap 'rm -f "$tmp_results" "$tmp_filtered_results"' EXIT +tmp_path_filtered="$(mktemp)" +trap 'rm -f "$tmp_results" "$tmp_filtered_results" "$tmp_path_filtered"' EXIT if git grep -I -nE "$PATTERN" -- "${scan_files[@]}" >"$tmp_results"; then - grep -E -v "$ALLOWLIST_PATHS_REGEX" "$tmp_results" >"$tmp_filtered_results" || true + grep -E -v "$ALLOWLIST_PATHS_REGEX" "$tmp_results" >"$tmp_path_filtered" || true + : >"$tmp_filtered_results" + while IFS= read -r hit; do + if ! only_fixture_values "$hit"; then + printf '%s +' "$hit" >>"$tmp_filtered_results" + fi + done <"$tmp_path_filtered" if [[ ! -s "$tmp_filtered_results" ]]; then echo "✅ Secret scan passed (allowlisted test fixture matches ignored)" From 5e3a646863dae113f19302c0d8811af254d725ba Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 10:48:21 +0530 Subject: [PATCH 53/54] fix(chart): keep receipt-writer out of the egress allow selector, reject required without enabled The writer pods no longer carry part-of agentic-operator, which the broad egress allow policy selects. NetworkPolicy rules add up, so the writer's own deny could not revoke it. global.receipts.required=true now fails the render unless enabled=true, so the operator cannot run with receipts silently off. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- charts/templates/_helpers.tpl | 3 ++- charts/templates/receipt-writer.yaml | 3 +++ charts/tests/receipts_test.yaml | 34 ++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl index 47c9076..2ebb03d 100644 --- a/charts/templates/_helpers.tpl +++ b/charts/templates/_helpers.tpl @@ -107,6 +107,7 @@ helm.sh/chart: {{ include "agentic-operator.chart" . }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} -app.kubernetes.io/part-of: agentic-operator +{{- /* Not part-of agentic-operator: that label is the broad egress allow selector, and NetworkPolicy rules add up. */}} +app.kubernetes.io/part-of: agentic-operator-receipt-writer app.kubernetes.io/component: receipt-writer {{- end }} diff --git a/charts/templates/receipt-writer.yaml b/charts/templates/receipt-writer.yaml index eee1b8a..ba655e1 100644 --- a/charts/templates/receipt-writer.yaml +++ b/charts/templates/receipt-writer.yaml @@ -5,6 +5,9 @@ Runs in the release namespace, next to the operator Deployment. The signing key must come from an existing Secret. The chart never makes one. */ -}} {{- $r := .Values.global.receipts | default dict -}} +{{- if and $r.required (not $r.enabled) -}} +{{- fail "global.receipts.required=true needs global.receipts.enabled=true. Without the writer the operator would run with receipts off instead of failing closed." -}} +{{- end -}} {{- if $r.enabled -}} {{- if not ($r.signingKey).existingSecret -}} {{- fail "global.receipts.signingKey.existingSecret is required when global.receipts.enabled=true. The chart never generates the signing key. See docs/receipts.md." -}} diff --git a/charts/tests/receipts_test.yaml b/charts/tests/receipts_test.yaml index 53ec49e..4ea4473 100644 --- a/charts/tests/receipts_test.yaml +++ b/charts/tests/receipts_test.yaml @@ -282,3 +282,37 @@ tests: name: RECEIPTS_INSECURE_NO_PIN value: "true" template: charts/agentic-operator/templates/deployment.yaml + + - it: fails when receipts are required but the writer is not enabled + set: + license: + key: "test" + global: + receipts: + enabled: false + required: true + asserts: + - failedTemplate: + errorPattern: "required=true needs global.receipts.enabled=true" + template: receipt-writer.yaml + + - it: keeps the writer out of the operator egress allow selector + 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: + - equal: + path: spec.template.metadata.labels["app.kubernetes.io/part-of"] + value: agentic-operator-receipt-writer + documentIndex: 2 + template: receipt-writer.yaml From c4e1edc4b329a48dba136d2f60ac3c0ce0f3de84 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <007ssancheti@gmail.com> Date: Fri, 2 Oct 2026 10:55:57 +0530 Subject: [PATCH 54/54] fix: resync receipt head after ambiguous failures, match fixtures as whole tokens The remote writer client treats the head as unknown from the POST until a verified reply extends it, so a lost reply forces a resync instead of stacking another receipt on a stale head. The secret scanner removes fixture values only when they are whole tokens, so a longer token that starts with one still fails. Signed-off-by: Shreyansh Sancheti <007ssancheti@gmail.com> --- pkg/receipts/remote.go | 6 ++++ pkg/receipts/remote_pin_test.go | 51 +++++++++++++++++++++++++++++++++ scripts/scan_secrets.sh | 6 +++- 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/pkg/receipts/remote.go b/pkg/receipts/remote.go index 6dbf651..1e604ca 100644 --- a/pkg/receipts/remote.go +++ b/pkg/receipts/remote.go @@ -128,6 +128,11 @@ func (w *RemoteWriter) Append(ctx context.Context, rec DecisionRecord) (receipts if err != nil { return receiptspec.Receipt{}, err } + // The POST can commit before the reply reaches us. Until a verified reply + // extends the head, the head is unknown, so a failure of any kind forces + // a resync before the next append instead of stacking receipts on a head + // the writer has already moved past. + w.headKnown = false resp, err := w.do(ctx, http.MethodPost, "/v1/records", body) if err != nil { return receiptspec.Receipt{}, err @@ -157,6 +162,7 @@ func (w *RemoteWriter) Append(ctx context.Context, rec DecisionRecord) (receipts return receiptspec.Receipt{}, fmt.Errorf("receipts: writer receipt seq %d does not extend head %d", r.Seq, w.headSeq) } w.headSeq, w.headHash = r.Seq, r.EntryHash + w.headKnown = true return r, nil } diff --git a/pkg/receipts/remote_pin_test.go b/pkg/receipts/remote_pin_test.go index 347bfe1..5016165 100644 --- a/pkg/receipts/remote_pin_test.go +++ b/pkg/receipts/remote_pin_test.go @@ -191,3 +191,54 @@ func TestTrustRootFromKeyHexMatchesWriter(t *testing.T) { t.Fatal("bad hex accepted") } } + +// A POST can commit on the writer while the client sees an error. The next +// append must resync the head instead of committing another receipt that +// then fails continuity. +func TestRemoteWriterResyncsAfterAmbiguousFailure(t *testing.T) { + lw := openTest(t, t.TempDir(), 1) + t.Cleanup(func() { _ = lw.Close() }) + posts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/head" { + seq, h := lw.Head() + _ = json.NewEncoder(w).Encode(HeadResponse{Seq: seq, EntryHash: hex.EncodeToString(h[:]), SignerKID: lw.KID()}) + return + } + var rec DecisionRecord + if err := json.NewDecoder(r.Body).Decode(&rec); err != nil { + http.Error(w, "bad", http.StatusBadRequest) + return + } + posts++ + rc, err := lw.Append(r.Context(), rec) + if err != nil { + http.Error(w, "append", http.StatusInternalServerError) + return + } + if posts == 2 { + http.Error(w, "reply lost after commit", http.StatusBadGateway) + return + } + line, _ := receiptspec.MarshalJSONLReceipt(rc) + _ = json.NewEncoder(w).Encode(AppendResponse{Receipt: line}) + })) + t.Cleanup(srv.Close) + rw, _ := NewRemoteWriter(srv.URL, staticToken("tok"), time.Second) + if err := rw.Pin([]receiptspec.TrustedKey{lw.TrustedKey()}); err != nil { + t.Fatal(err) + } + rec := mustRecord(t, testParams()) + if _, err := rw.Append(context.Background(), rec); err != nil { + t.Fatal(err) + } + if _, err := rw.Append(context.Background(), rec); err == nil { + t.Fatal("lost reply not reported") + } + if _, err := rw.Append(context.Background(), rec); err != nil { + t.Fatalf("append after a lost reply did not resync: %v", err) + } + if posts != 3 { + t.Fatalf("posts = %d, want 3", posts) + } +} diff --git a/scripts/scan_secrets.sh b/scripts/scan_secrets.sh index b53b169..3c4224c 100755 --- a/scripts/scan_secrets.sh +++ b/scripts/scan_secrets.sh @@ -20,7 +20,11 @@ only_fixture_values() { path="${line%%:*}" text="${line#*:}" text="${text#*:}" - text="$(printf '%s' "$text" | sed -E "s/($FIXTURE_VALUES)//g")" + # Remove only whole tokens. A fixture value followed or preceded by a token + # character (AKIA...EXAMPLEEXTRA) is not a fixture. Run twice so adjacent + # values, which share a delimiter, are both removed. + local re="(^|[^A-Za-z0-9_-])($FIXTURE_VALUES)([^A-Za-z0-9_-]|$)" + text="$(printf '%s' "$text" | sed -E "s/$re/\\1\\3/g" | sed -E "s/$re/\\1\\3/g")" if [[ "$path" == "$FIXTURE_PEM_FILE" ]]; then text="$(printf '%s' "$text" | sed -E "s/($FIXTURE_PEM_VALUES)//g")" fi