From 44fc0e11fbae7fb129227a6b8fba8a96925b4185 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:27:03 +0400 Subject: [PATCH 01/18] phase 10: improve README with control plane capability index --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 564fc25..34c4abb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,31 @@ agents safely at scale. See [docs/product-positioning.md](docs/product-positioning.md) and [docs/architecture.md](docs/architecture.md) for the full picture. +## The Control Plane + +The `clawforge-controlplane` crate adds the layers an organisation needs to run +agents safely at scale. Each is a self-contained, SQLite-backed, fully tested +domain module: + +| Capability | What it does | Docs | +|------------|--------------|------| +| **Agent Registry** | Single source of truth for every agent (owner, tools, MCP, data access, risk, lifecycle) | [registry.md](docs/registry.md) | +| **Governance Engine** | Human approval workflow with department ownership, change history, and audit | [governance.md](docs/governance.md) | +| **Observability** | Execution events → task/cost/latency/failure/risk metrics, per-agent and fleet-wide | [observability.md](docs/observability.md) | +| **Security Gateway** | Pre-execution checks on every action (tool/MCP/model/data/budget/approval) + risk score | [security-gateway.md](docs/security-gateway.md) | +| **MCP Governance** | Registry, approval, health, and usage tracking for MCP servers | [mcp-governance.md](docs/mcp-governance.md) | +| **Agent Marketplace** | Verified, reusable internal agent templates with compliance badges | [marketplace.md](docs/marketplace.md) | +| **Enterprise Integrations** | Governed connectors (DBs, SSO, GIS, ITSM) — credentials referenced, never stored | [enterprise-integrations.md](docs/enterprise-integrations.md) | +| **Government Compliance** | PII classification, retention, approval chains, audit evidence, reporting (UAE PDPL-aware) | [government-compliance.md](docs/government-compliance.md) | + +See [docs/architecture.md](docs/architecture.md) for how these fit together, and +[docs/use-cases.md](docs/use-cases.md) for end-to-end government and enterprise +walkthroughs. + +> **ClawForge is an enterprise-grade AI agent control plane for governing, +> securing, monitoring, auditing, and operating AI agents and MCP servers across +> government and enterprise environments.** + ## Install & Quick Start Requires **Rust ≥ 1.80** and **Node ≥ 20** (for frontend UI). From eeb2e37e7dbd62810ee17c507efb9eeb13d6f025 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:27:17 +0400 Subject: [PATCH 02/18] phase 10: add product screenshots placeholder --- docs/screenshots/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/screenshots/README.md diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md new file mode 100644 index 0000000..c6ab8c1 --- /dev/null +++ b/docs/screenshots/README.md @@ -0,0 +1,17 @@ +# Screenshots + +The ClawForge web dashboard lives in [`frontend/`](../../frontend) (React, run +with `npm run dev`). This folder is a placeholder for control-plane UI captures. + +Planned views (add PNGs here as the control-plane UI is built): + +- `registry.png` — Agent Registry list with risk/lifecycle badges +- `governance.png` — Approval queue and change history +- `observability.png` — Fleet dashboard (task/cost/latency/risk) +- `gateway.png` — Security decision detail with denial reasons +- `mcp.png` — MCP server catalogue and health +- `marketplace.png` — Verified agent templates +- `compliance.png` — Department compliance summary + +> Placeholders only — no images are committed yet. Drop captures in this folder +> and reference them from the README once the control-plane screens are live. From 0849eccc6a9f320b8dfffc26aa495cd1b600739b Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:27:31 +0400 Subject: [PATCH 03/18] phase 10: add architecture diagram --- docs/diagrams.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/diagrams.md diff --git a/docs/diagrams.md b/docs/diagrams.md new file mode 100644 index 0000000..3bf5c64 --- /dev/null +++ b/docs/diagrams.md @@ -0,0 +1,34 @@ +# ClawForge Diagrams + +Mermaid diagrams for the control plane. (GitHub renders these inline.) + +## Control-plane architecture + +How the control-plane modules relate to each other and to the agent runtime. + +```mermaid +flowchart TB + subgraph CP["ClawForge Control Plane"] + REG[Agent Registry] + GOV[Governance Engine] + GW[Security Gateway] + OBS[Observability] + MCP[MCP Governance] + MKT[Marketplace] + INT[Enterprise Integrations] + COMP[Compliance Pack] + end + + MKT -- install --> REG + GOV -- approves --> REG + GW -- reads --> REG + GW -- denials --> OBS + MCP -- governs --> GW + INT -- governs --> GW + REG -- subjects --> COMP + OBS -- metrics --> COMP + GOV -- decisions --> COMP + + CP -. authorises actions .-> RT[Agent Runtime] + RT -. execution events .-> OBS +``` From 538ad58e3fef0c910d60225637121d449dc5e455 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:27:42 +0400 Subject: [PATCH 04/18] phase 10: add agent lifecycle diagram --- docs/diagrams.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/diagrams.md b/docs/diagrams.md index 3bf5c64..f84c7c1 100644 --- a/docs/diagrams.md +++ b/docs/diagrams.md @@ -32,3 +32,25 @@ flowchart TB CP -. authorises actions .-> RT[Agent Runtime] RT -. execution events .-> OBS ``` + +## Agent lifecycle + +The registry state machine. An agent can only become operational by passing +through approval — a direct `Draft → Active` jump is rejected. + +```mermaid +stateDiagram-v2 + [*] --> Draft + Draft --> PendingApproval: submit + PendingApproval --> Active: approve + PendingApproval --> Draft: reject + Active --> Suspended: suspend + Suspended --> Active: resume + Active --> Blocked: block + Blocked --> Active: unblock + Draft --> Deactivated: retire + Active --> Deactivated: retire + Suspended --> Deactivated: retire + Blocked --> Deactivated: retire + Deactivated --> [*] +``` From e457d37732a09e51546f04cc287820d04ad2b2f4 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:27:52 +0400 Subject: [PATCH 05/18] phase 10: add governance workflow diagram --- docs/diagrams.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/diagrams.md b/docs/diagrams.md index f84c7c1..f793128 100644 --- a/docs/diagrams.md +++ b/docs/diagrams.md @@ -54,3 +54,23 @@ stateDiagram-v2 Blocked --> Deactivated: retire Deactivated --> [*] ``` + +## Governance approval workflow + +```mermaid +sequenceDiagram + participant Dev as Requester + participant GOV as Governance Engine + participant App as Approver + participant REG as Agent Registry + + Dev->>GOV: submit(kind, subject, justification) + GOV-->>GOV: record "submitted" (Pending) + App->>GOV: approve / reject (reason required) + GOV-->>GOV: record decision + history + alt approved + GOV-->>REG: move agent → Active + else rejected + GOV-->>Dev: rejected with reason + end +``` From d3d6f491baefd84643d74d93cc00bda7af860606 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:28:03 +0400 Subject: [PATCH 06/18] phase 10: add MCP governance diagram --- docs/diagrams.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/diagrams.md b/docs/diagrams.md index f793128..ec8fc98 100644 --- a/docs/diagrams.md +++ b/docs/diagrams.md @@ -74,3 +74,16 @@ sequenceDiagram GOV-->>Dev: rejected with reason end ``` + +## MCP governance flow + +```mermaid +flowchart LR + R[register] --> P{{pending_approval}} + P -->|review risk + sensitive tools| A[approve] + P -->|reject| B[blocked] + A --> ACT{{active}} + ACT -->|record_usage / record_health| ACT + ACT -->|block| B + ACT -. consulted by .-> GW[Security Gateway] +``` From bc77dad39a076a124861c960f0066ea94cf529b5 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:28:21 +0400 Subject: [PATCH 07/18] phase 10: add example use cases --- docs/use-cases.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/use-cases.md diff --git a/docs/use-cases.md b/docs/use-cases.md new file mode 100644 index 0000000..f3f5bde --- /dev/null +++ b/docs/use-cases.md @@ -0,0 +1,39 @@ +# Use Cases + +How the control-plane modules combine into end-to-end workflows. + +## The general flow + +Every ClawForge deployment follows the same governed lifecycle, regardless of +industry: + +1. **Publish or register** — a team registers an agent (or installs a verified + one from the **Marketplace**), landing it in the **Registry** as `Draft`. +2. **Govern** — the agent is submitted to the **Governance Engine**; an approver + signs off with a recorded reason. On approval it moves to `Active`. +3. **Classify** — a **Compliance Policy** sets the agent's PII classification, + retention, export control, and (for sensitive data) an approval chain. +4. **Run under guard** — at execution time the **Security Gateway** checks each + action (tool/MCP/model/data/budget/approval) and emits an allow/deny decision + with a risk score; denials are logged. +5. **Observe** — every action emits **Observability** events feeding per-agent + and fleet dashboards. +6. **Audit & report** — governance history, blocked-execution logs, integration + audit trails, and signed **Audit Evidence** roll up into **Compliance + Reports**. + +```rust +// Sketch — see each module's doc for the full API. +let agent = marketplace.install(&listing.id, ®istry, "Permit Bot", "team-a", "Licensing")?; +let req = governance.submit(/* approval request for agent */)?; +governance.approve(&req.id, "ciso", "meets data-access policy")?; +registry.set_status(&agent.id, LifecycleStatus::PendingApproval)?; +registry.set_status(&agent.id, LifecycleStatus::Active)?; + +let decision = gateway.evaluate(&action_request); // checked before every action +observability.log_event(NewExecutionEvent::task(&agent.id, decision.allowed, 120, 0.02))?; +let report = ComplianceReport::generate(&policy, &evidence, Some(&chain)); +``` + +See [government-municipality.md](government-municipality.md) and +[enterprise.md](enterprise.md) for concrete, worked scenarios. From ff3aecd59d3a8328081e5823554b71441183ffb2 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:28:39 +0400 Subject: [PATCH 08/18] phase 10: add government municipality use case --- docs/government-municipality.md | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/government-municipality.md diff --git a/docs/government-municipality.md b/docs/government-municipality.md new file mode 100644 index 0000000..f9f59d7 --- /dev/null +++ b/docs/government-municipality.md @@ -0,0 +1,59 @@ +# Use Case — Government Municipality + +**Scenario:** A municipality wants an AI agent to triage building-permit +applications. The agent must read resident records (regulated PII) and is +therefore high-risk. + +## 1. Register / install + +The Licensing team installs the verified **"Permit Intake Assistant"** template +from the Marketplace. It lands in the Registry as `Draft`, owned by +`licensing-platform`, with `data_access_level = internal` and +`risk_level = high`. + +## 2. Classify for PDPL + +A compliance policy is attached: + +```rust +let mut policy = CompliancePolicy::pdpl(&agent.id); +policy.pii_classification = PiiClassification::Pii; +policy.data_retention_days = 365; // statutory retention +policy.export_control = ExportControl::Prohibited; // data stays in-country +``` + +## 3. Multi-party approval + +Because the agent touches resident PII, governance requires a chain: + +```rust +let mut chain = ApprovalChain::from_roles(&agent.id, &["data-owner", "dpo", "ciso"]); +chain.approve_next("data.owner@municipality", now); +chain.approve_next("dpo@municipality", now); +chain.approve_next("ciso@municipality", now); // chain complete +``` + +A matching `governance.submit(...)` / `approve(...)` records the decision and +history; the agent then moves `Draft → PendingApproval → Active`. + +## 4. Guarded execution + +When the agent tries to look up a resident record, the Security Gateway checks: +agent is `Active`, the `records-mcp` server is allow-listed, the data access is +within clearance, PII access is permitted by policy, and budget remains. A +high-risk action under a mandated approval gate is held for human sign-off; any +denial is written to the blocked-execution log. + +## 5. Evidence & reporting + +Each sensitive access is captured as `AuditEvidence`. At review time: + +```rust +let report = ComplianceReport::generate(&policy, &evidence, Some(&chain)); +assert!(report.is_compliant()); // PII + retention + completed chain + evidence +let dept = DepartmentComplianceSummary::summarize("Licensing", &[report]); +``` + +The municipality can now demonstrate, with reproducible evidence, *who approved +the agent, what it accessed, and that retention and export rules were enforced* +— the core of a PDPL accountability story. From e00156c3674d570083765dfc840ed47ebfc1ce0e Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:29:06 +0400 Subject: [PATCH 09/18] phase 10: add enterprise use case --- docs/enterprise.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/enterprise.md diff --git a/docs/enterprise.md b/docs/enterprise.md new file mode 100644 index 0000000..ee7e255 --- /dev/null +++ b/docs/enterprise.md @@ -0,0 +1,53 @@ +# Use Case — Enterprise IT + +**Scenario:** An enterprise IT operations team wants an agent that executes +approved remediation runbooks (restart services, run diagnostics) against +internal systems via a ServiceNow MCP server. This is critical-risk automation. + +## 1. Register the agent + +The platform team registers an **"IT Ops Runbook Agent"** in the Registry: +owner `it-ops`, department `Information Technology`, tools `["shell", "http.get"]`, +MCP servers `["servicenow-mcp"]`, `data_access_level = confidential`, +`risk_level = critical`. + +## 2. Govern the building blocks + +Several things need approval, each tracked by the Governance Engine: + +```rust +governance.submit(/* kind: Agent */)?; // the agent itself +governance.submit(/* kind: Mcp, servicenow-mcp */)?; +governance.submit(/* kind: Tool, shell */)?; +``` + +The `servicenow-mcp` server is registered in **MCP Governance**, reviewed +(`requires_governance_review()` is true — it has a `write` tool), approved, and +health-checked. + +## 3. Policy & budget + +A `SecurityPolicy` for this environment enables database writes and external +network for the runbook agent, but keeps `require_human_approval = true` so its +critical-risk actions still require a human gate. A per-agent `budget_limit` +caps daily spend. + +## 4. Guarded execution + +Before each remediation step the Security Gateway verifies the tool and MCP +server are allow-listed, the model matches, the action is within budget, and — +because the agent is critical-risk under a mandated gate — flags it for human +approval. Allowed steps proceed; everything is scored and logged. + +## 5. Observe the fleet + +Every run emits Observability events. The IT dashboard shows task success rate, +tool failure rate, average latency, total cost, MCP call volume, and any blocked +executions — for this agent and across the whole fleet (`summary(None)`). + +## Why a control plane + +Without ClawForge this agent would run with ad-hoc credentials, no central +approval, no per-action checks, and no unified audit trail. With it, IT gets +**Kubernetes-style lifecycle, ServiceNow-style approvals, and Splunk-style +observability** over their agent fleet — in one place. From e2e650dc239aba3421cfe7dd2c687d197684484e Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:29:23 +0400 Subject: [PATCH 10/18] phase 10: add roadmap --- docs/roadmap.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/roadmap.md diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..a9f6676 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,35 @@ +# Roadmap + +The control plane is built; these are the next increments that turn it from a +strong foundation into a production platform. + +## Near term + +- **HTTP/gateway surface** — expose the control-plane stores over the existing + `clawforge-gateway` so the React dashboard and external tools can drive them. +- **Gateway ↔ runtime wiring** — call `SecurityGateway::evaluate` from the live + executor so checks run on real actions, and stream execution events into + `ObservabilityStore`. +- **Control-plane UI** — registry, approval queue, fleet dashboard, MCP + catalogue, marketplace, and compliance views (see `docs/screenshots/`). + +## Mid term + +- **Pluggable identity** — wire the SSO/Active Directory integrations into + approver authentication and role-based access for governance actions. +- **Real digital signatures** — implement signing over `AuditEvidence` + `content_hash` (the placeholder is already in the model). +- **Policy bundles** — versioned, exportable `SecurityPolicy` / compliance + policy sets per environment (`local` / `staging` / `gov-prod`). +- **Scheduled health checks** — drive `McpRegistry::record_health` from the + existing scheduler crate. + +## Longer term + +- **Multi-tenant** — first-class organisation/tenant isolation across all stores. +- **Additional compliance frameworks** — GDPR, NIST, ISO 27001 mappings + alongside UAE PDPL. +- **Retention enforcement jobs** — act on `is_past_retention` to purge or + archive data automatically (respecting investigation holds). +- **Marketplace ratings & reviews** — populate the `rating` field from real + installer feedback. From 1c4f34a7b82e7cf2f3c680dc04f5d3b74283f860 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:29:41 +0400 Subject: [PATCH 11/18] phase 10: add limitations --- docs/limitations.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/limitations.md diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..3a9b7c6 --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,44 @@ +# Limitations + +An honest account of what the control plane does and does not do today, so +evaluators can judge it accurately. + +## Scope + +- **Library, not yet a service.** The control plane is a Rust crate + (`clawforge-controlplane`) with SQLite-backed stores and a clean API. It is + not yet exposed over HTTP, and there is no dedicated UI for it (the runtime's + React dashboard is separate). See the [roadmap](roadmap.md). +- **Not auto-wired into the runtime.** `SecurityGateway::evaluate` and the + observability event log are designed to sit in the execution path, but the + live executor does not call them automatically yet. + +## Security & identity + +- **No built-in authentication / RBAC.** Governance `approve`/`reject` and + status changes take an actor string but do not yet verify identity or + enforce role-based access. Wire this to SSO/AD before production. +- **Digital signatures are a placeholder.** `AuditEvidence` carries a + `signature` field and `is_signed()`, but no signing/verification is + implemented. +- **No secret management.** By design, the control plane stores credential + *references* only; it relies on an external vault/SSO for the actual secrets. + +## Data & scale + +- **Single-node SQLite.** Each store uses a local SQLite database — excellent + for local-first and small deployments, but not a clustered/HA datastore. +- **No multi-tenancy.** There is no tenant isolation layer yet; an + `organization` config field exists but is not enforced across stores. + +## Compliance + +- **Awareness, not certification.** The UAE PDPL mapping is an engineering aid. + ClawForge provides the controls and evidence; legal sufficiency is determined + by the entity and its regulator. See [uae-pdpl.md](uae-pdpl.md). +- **Retention is advisory.** `is_past_retention` reports when data is past due; + it does not yet delete or archive anything automatically. + +None of these are architectural dead-ends — they are the difference between a +solid, tested foundation and a fully operationalised platform, and each is +tracked on the [roadmap](roadmap.md). From 37a6433e63652f6a797e2d184c7280b895984367 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:29:58 +0400 Subject: [PATCH 12/18] phase 10: add security disclaimer --- docs/security-disclaimer.md | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/security-disclaimer.md diff --git a/docs/security-disclaimer.md b/docs/security-disclaimer.md new file mode 100644 index 0000000..5610ef3 --- /dev/null +++ b/docs/security-disclaimer.md @@ -0,0 +1,42 @@ +# Security Disclaimer + +ClawForge is software for **governing** AI agents. It helps an organisation +reduce risk, but it does not, by itself, make any deployment secure or +compliant. Read this before relying on it. + +## Treat agent input as untrusted + +The underlying runtime connects to real messaging surfaces (WhatsApp, Telegram, +Discord, Slack, …). Inbound messages, tool outputs, and MCP responses are +**untrusted input** and may attempt prompt injection. The Security Gateway gates +*capabilities* (which tool/MCP/model/data an action may use) — it does not, on +its own, sanitise content. Keep untrusted execution sandboxed (the runtime +supports Docker isolation). + +## The control plane is an aid, not a guarantee + +- It enforces the policies **you configure**. A permissive `SecurityPolicy` + permits permissive behaviour. +- Approval actions currently trust the supplied actor identity — **add + authentication/RBAC** (SSO/AD) before production. See [limitations](limitations.md). +- Digital signatures over audit evidence are a **placeholder**; do not treat + unsigned evidence as cryptographically attested. +- Compliance reporting is **awareness-level**; it is not legal certification. + +## Secrets + +Never put secrets in the control plane. Integrations store a `CredentialRef` +(vault/env/SSO pointer) only. Keep real secrets in a proper vault and restrict +access to it. + +## Responsible use + +This project assists **authorised** operation and governance of AI agents in +environments you control. Do not use it to target systems you are not authorised +to operate, to evade legitimate oversight, or to process personal data without a +lawful basis. + +## Reporting issues + +Found a security issue? Open a private report to the maintainer rather than a +public issue, and avoid including sensitive data in reproduction steps. From 99960ad18bc618828383250672a46e8a091a2cf1 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:30:14 +0400 Subject: [PATCH 13/18] phase 10: add installation guide --- docs/installation.md | 73 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/installation.md diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..293b91c --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,73 @@ +# Installation Guide + +## Prerequisites + +- **Rust ≥ 1.80** (workspace edition 2021) — install via [rustup](https://rustup.rs). +- **Node ≥ 20** — only needed for the React dashboard in `frontend/`. +- A C toolchain (for the bundled SQLite used by `rusqlite`). + +## Get the code + +```bash +git clone https://github.com/YASSERRMD/clawforge.git +cd clawforge +``` + +## Configure + +```bash +cp .env.example .env +# edit .env — at minimum set OPENROUTER_API_KEY for the runtime. +``` + +Control-plane settings (all optional, sensible local-first defaults): + +| Variable | Default | Purpose | +|----------|---------|---------| +| `CLAWFORGE_CP_DB` | `clawforge-controlplane.db` | SQLite path for control-plane stores | +| `CLAWFORGE_CP_ENV` | `local` | Environment label (`local`/`staging`/`gov-prod`) | +| `CLAWFORGE_CP_ORG` | `ClawForge` | Owning organisation (in audit records) | +| `CLAWFORGE_CP_REQUIRE_APPROVAL` | `true` | Mandate human approval for high-risk actions | +| `CLAWFORGE_CP_BUDGET_LIMIT` | `100` | Default per-agent daily spend ceiling | + +## Build & test the control plane + +```bash +cargo build -p clawforge-controlplane +cargo test -p clawforge-controlplane # 80+ tests +``` + +## Run the runtime (optional) + +```bash +export OPENROUTER_API_KEY="sk-or-v1-..." +cargo run -p clawforge-cli -- serve --port 3000 +``` + +Dashboard, in a second terminal: + +```bash +cd frontend && npm install && npm run dev +``` + +## Docker + +```bash +docker-compose up --build +``` + +## Embedding the control plane + +The control plane is a library. In another workspace crate: + +```toml +# Cargo.toml +clawforge-controlplane = { path = "../controlplane" } +``` + +```rust +use clawforge_controlplane::registry::AgentRegistry; +let registry = AgentRegistry::open("clawforge-controlplane.db")?; +``` + +See the [developer guide](developer-guide.md) to go further. From 0fb5383a8212c3ea9d4a61537bf7d191b8fab6e4 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:30:33 +0400 Subject: [PATCH 14/18] phase 10: add developer guide --- docs/developer-guide.md | 61 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/developer-guide.md diff --git a/docs/developer-guide.md b/docs/developer-guide.md new file mode 100644 index 0000000..11e0d1c --- /dev/null +++ b/docs/developer-guide.md @@ -0,0 +1,61 @@ +# Developer Guide + +This guide orients contributors working on the `clawforge-controlplane` crate. + +## Crate layout + +``` +backend/controlplane/src/ +├── lib.rs # crate docs + module wiring +├── config.rs # ControlPlaneConfig (CLAWFORGE_CP_*) +├── constants.rs # RiskLevel, DataAccessLevel, LifecycleStatus +├── error.rs # ControlPlaneError + Result +├── logging.rs # cp_info! / cp_warn! / cp_blocked! +├── registry/ # Agent Registry (Phase 2) +├── governance/ # Governance Engine (Phase 3) +├── observability/ # Execution events & metrics (Phase 4) +├── gateway/ # Security Gateway (Phase 5) +├── mcp/ # MCP Governance (Phase 6) +├── marketplace/ # Agent Marketplace (Phase 7) +├── integrations/ # Enterprise Integrations (Phase 8) +└── compliance/ # Government Compliance Pack (Phase 9) +``` + +Each domain follows the same shape: a `model.rs` of `serde` types, a `store.rs` +SQLite store with `open(path)` + `in_memory()`, and `#[cfg(test)]` unit tests +beside the code. + +## Conventions + +- **One vocabulary.** Reuse `constants::{RiskLevel, DataAccessLevel, + LifecycleStatus}` rather than redefining per module. +- **Errors.** Return the crate-wide `Result` (`error::Result`). `From` impls + convert `rusqlite` and `serde_json` errors automatically. +- **Storage.** JSON-encode list/enum columns so schemas stay stable across + vocabulary changes; provide both `open` and `in_memory` constructors. +- **Logging.** Emit via `cp_info!` / `cp_warn!` / `cp_blocked!` so audit and + observability tooling sees a consistent `clawforge::controlplane` target. +- **No `unsafe`, edition 2021, idiomatic Rust.** + +## Adding a new domain module + +1. Create `src//{mod.rs, model.rs, store.rs}`. +2. Wire `pub mod ;` into `lib.rs` and re-export the key types. +3. Add `#[cfg(test)]` tests and a `docs/.md`. +4. Keep each commit atomic and green (`cargo test -p clawforge-controlplane`). + +## Build & test + +```bash +cargo build -p clawforge-controlplane +cargo test -p clawforge-controlplane +cargo fmt +cargo clippy -p clawforge-controlplane # optional lint pass +``` + +## How the modules compose + +The Marketplace installs into the Registry; Governance approves Registry agents; +the Security Gateway reads the Registry (and is consulted on MCP/integration +use); Observability records what happened; Compliance reports over all of it. +See [diagrams.md](diagrams.md) and [use-cases.md](use-cases.md). From 800f05ae6cf26e830e88be76249e36fbcd8823d3 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:31:43 +0400 Subject: [PATCH 15/18] phase 10: add runnable end-to-end demo script --- backend/controlplane/examples/demo.rs | 112 ++++++++++++++++++++++++++ docs/demo.md | 46 +++++++++++ 2 files changed, 158 insertions(+) create mode 100644 backend/controlplane/examples/demo.rs create mode 100644 docs/demo.md diff --git a/backend/controlplane/examples/demo.rs b/backend/controlplane/examples/demo.rs new file mode 100644 index 0000000..5bdbc63 --- /dev/null +++ b/backend/controlplane/examples/demo.rs @@ -0,0 +1,112 @@ +//! End-to-end control-plane demo. +//! +//! Run with: +//! +//! ```bash +//! cargo run -p clawforge-controlplane --example demo +//! ``` +//! +//! Walks a single agent through the whole control plane: publish from the +//! marketplace, register an MCP server, govern the agent through approval, +//! check an action at the security gateway, record observability, and produce +//! a compliance report — all in-memory. + +use clawforge_controlplane::compliance::{ + ApprovalChain, CompliancePolicy, ComplianceReport, PiiClassification, +}; +use clawforge_controlplane::constants::{DataAccessLevel, LifecycleStatus, RiskLevel}; +use clawforge_controlplane::gateway::{ActionRequest, SecurityGateway, SecurityPolicy}; +use clawforge_controlplane::governance::{ApprovalKind, GovernanceEngine, NewApprovalRequest}; +use clawforge_controlplane::marketplace::Marketplace; +use clawforge_controlplane::mcp::{McpRegistry, McpTool, NewMcpServer, TransportType}; +use clawforge_controlplane::observability::{NewExecutionEvent, ObservabilityStore}; +use clawforge_controlplane::registry::AgentRegistry; + +fn main() -> anyhow::Result<()> { + println!("== ClawForge Control Plane demo ==\n"); + + // Stores (in-memory for the demo). + let registry = AgentRegistry::in_memory()?; + let governance = GovernanceEngine::in_memory()?; + let mcp = McpRegistry::in_memory()?; + let marketplace = Marketplace::in_memory()?; + let obs = ObservabilityStore::in_memory()?; + + // 1. Marketplace → install a verified template into the registry. + let listings = clawforge_controlplane::marketplace::seed::seed(&marketplace)?; + let listing = &listings[0]; + println!("1. Marketplace listing '{}' trusted={}", listing.name, listing.is_trusted()); + let agent = marketplace.install(&listing.id, ®istry, "Permit Bot A", "team-a", "Licensing")?; + println!(" installed agent {} (status {:?})", agent.name, agent.status); + + // 2. MCP governance → register + approve the server the agent needs. + let server = mcp.register(NewMcpServer { + name: "records-mcp".into(), + description: "Resident records".into(), + owner: "data-platform".into(), + endpoint: "https://mcp.internal/records".into(), + transport: TransportType::Http, + tools_exposed: vec![McpTool { + name: "lookup".into(), + description: "read records".into(), + permissions: vec!["read".into()], + }], + permissions_required: vec!["read".into()], + risk_level: RiskLevel::High, + })?; + mcp.approve(&server.id)?; + println!("2. MCP server '{}' approved", server.name); + + // 3. Governance → approve the agent, then move it to Active. + let req = governance.submit(NewApprovalRequest { + kind: ApprovalKind::Agent, + subject_id: agent.id.clone(), + subject_name: agent.name.clone(), + requested_by: "team-a".into(), + department: "Licensing".into(), + risk_level: agent.risk_level, + justification: "Permit triage".into(), + })?; + governance.approve(&req.id, "ciso", "meets data-access policy")?; + registry.set_status(&agent.id, LifecycleStatus::PendingApproval)?; + let agent = registry.set_status(&agent.id, LifecycleStatus::Active)?; + println!("3. Governance approved; agent now {:?}", agent.status); + + // 4. Security gateway → check an action before execution. + let gateway = SecurityGateway::new(SecurityPolicy::permissive()); + let mut action = ActionRequest::for_agent(agent.clone()); + action.tool = Some("search".into()); + action.mcp_server = Some("records-mcp".into()); + action.data_access_level = DataAccessLevel::Internal; + action.estimated_cost = 0.02; + let decision = gateway.evaluate(&action); + println!("4. Gateway decision: {}", decision.summary()); + + // 5. Observability → record the execution. + obs.log_event(NewExecutionEvent::task(&agent.id, decision.allowed, 120, 0.02))?; + let metrics = obs.summary(Some(&agent.id))?; + println!( + "5. Observability: {} task(s), success rate {:.0}%", + metrics.task_count, + metrics.success_rate() * 100.0 + ); + + // 6. Compliance → classify and report. + let mut policy = CompliancePolicy::pdpl(&agent.id); + policy.pii_classification = PiiClassification::Pii; + policy.data_retention_days = 365; + let chain = ApprovalChain::from_roles(&agent.id, &["data-owner", "dpo"]); + let report = ComplianceReport::generate(&policy, &[], Some(&chain)); + println!( + "6. Compliance: framework {}, compliant={} ({} finding(s))", + report.framework, + report.is_compliant(), + report.findings.len() + ); + for f in &report.findings { + println!(" - {f}"); + } + + println!("\n== demo complete =="); + Ok(()) +} diff --git a/docs/demo.md b/docs/demo.md new file mode 100644 index 0000000..2652f71 --- /dev/null +++ b/docs/demo.md @@ -0,0 +1,46 @@ +# Demo + +A runnable, end-to-end walkthrough of the control plane lives at +[`backend/controlplane/examples/demo.rs`](../backend/controlplane/examples/demo.rs). + +## Run it + +```bash +cargo run -p clawforge-controlplane --example demo +``` + +## What it does + +Everything runs in-memory, exercising every module in one agent's journey: + +1. **Marketplace** — seeds verified listings and installs one into the Registry. +2. **MCP Governance** — registers and approves the `records-mcp` server. +3. **Governance** — submits and approves the agent, moving it `Draft → + PendingApproval → Active`. +4. **Security Gateway** — evaluates a real action (tool + MCP + data access + + budget) and prints an allow/deny verdict with a risk score. +5. **Observability** — records the execution and prints the task success rate. +6. **Compliance** — classifies the agent (UAE PDPL, PII, retention, approval + chain) and prints a compliance report with findings. + +## Expected output + +``` +== ClawForge Control Plane demo == + +1. Marketplace listing 'Permit Intake Assistant' trusted=true + installed agent Permit Bot A (status Draft) +2. MCP server 'records-mcp' approved +3. Governance approved; agent now Active +4. Gateway decision: ALLOW (risk: medium, score: 26) +5. Observability: 1 task(s), success rate 100% +6. Compliance: framework UAE-PDPL, compliant=false (2 finding(s)) + - regulated PII handled but no audit evidence collected + - approval chain is incomplete + +== demo complete == +``` + +The compliance findings are intentional: they show the report correctly +flagging an agent that handles PII without collected evidence or a completed +approval chain — exactly the kind of gap the control plane surfaces. From 10ea35901d05283c4f9ef46a235a6237064c586a Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:32:15 +0400 Subject: [PATCH 16/18] phase 10: apply rustfmt formatting across control-plane crate --- backend/controlplane/examples/demo.rs | 26 ++++++- backend/controlplane/src/compliance/model.rs | 4 +- backend/controlplane/src/compliance/report.rs | 20 ++++- backend/controlplane/src/config.rs | 5 +- backend/controlplane/src/error.rs | 5 +- backend/controlplane/src/gateway/decision.rs | 6 +- backend/controlplane/src/gateway/engine.rs | 19 ++++- backend/controlplane/src/gateway/log.rs | 22 +++++- backend/controlplane/src/governance/store.rs | 73 +++++++++++++++---- .../controlplane/src/integrations/model.rs | 22 +++++- .../src/integrations/placeholders.rs | 24 +++++- .../controlplane/src/integrations/store.rs | 43 ++++++++--- backend/controlplane/src/marketplace/model.rs | 5 +- backend/controlplane/src/marketplace/seed.rs | 4 +- backend/controlplane/src/marketplace/store.rs | 52 +++++++++---- backend/controlplane/src/mcp/model.rs | 5 +- backend/controlplane/src/mcp/store.rs | 54 +++++++++++--- .../controlplane/src/observability/seed.rs | 14 +++- .../controlplane/src/observability/store.rs | 44 ++++++++--- backend/controlplane/src/registry/seed.rs | 6 +- backend/controlplane/src/registry/store.rs | 28 +++++-- .../controlplane/src/registry/validation.rs | 11 ++- 22 files changed, 386 insertions(+), 106 deletions(-) diff --git a/backend/controlplane/examples/demo.rs b/backend/controlplane/examples/demo.rs index 5bdbc63..37354fb 100644 --- a/backend/controlplane/examples/demo.rs +++ b/backend/controlplane/examples/demo.rs @@ -35,9 +35,22 @@ fn main() -> anyhow::Result<()> { // 1. Marketplace → install a verified template into the registry. let listings = clawforge_controlplane::marketplace::seed::seed(&marketplace)?; let listing = &listings[0]; - println!("1. Marketplace listing '{}' trusted={}", listing.name, listing.is_trusted()); - let agent = marketplace.install(&listing.id, ®istry, "Permit Bot A", "team-a", "Licensing")?; - println!(" installed agent {} (status {:?})", agent.name, agent.status); + println!( + "1. Marketplace listing '{}' trusted={}", + listing.name, + listing.is_trusted() + ); + let agent = marketplace.install( + &listing.id, + ®istry, + "Permit Bot A", + "team-a", + "Licensing", + )?; + println!( + " installed agent {} (status {:?})", + agent.name, agent.status + ); // 2. MCP governance → register + approve the server the agent needs. let server = mcp.register(NewMcpServer { @@ -83,7 +96,12 @@ fn main() -> anyhow::Result<()> { println!("4. Gateway decision: {}", decision.summary()); // 5. Observability → record the execution. - obs.log_event(NewExecutionEvent::task(&agent.id, decision.allowed, 120, 0.02))?; + obs.log_event(NewExecutionEvent::task( + &agent.id, + decision.allowed, + 120, + 0.02, + ))?; let metrics = obs.summary(Some(&agent.id))?; println!( "5. Observability: {} task(s), success rate {:.0}%", diff --git a/backend/controlplane/src/compliance/model.rs b/backend/controlplane/src/compliance/model.rs index c57eded..3c97262 100644 --- a/backend/controlplane/src/compliance/model.rs +++ b/backend/controlplane/src/compliance/model.rs @@ -181,7 +181,9 @@ impl CompliancePolicy { /// Indefinite retention (`0`) and active investigation holds are never past /// due (a legal hold overrides routine deletion). pub fn is_past_retention(&self, age_days: u32) -> bool { - !self.investigation_mode && self.data_retention_days != 0 && age_days > self.data_retention_days + !self.investigation_mode + && self.data_retention_days != 0 + && age_days > self.data_retention_days } /// A baseline UAE PDPL policy for a subject. diff --git a/backend/controlplane/src/compliance/report.rs b/backend/controlplane/src/compliance/report.rs index 03cc6ef..babd2f0 100644 --- a/backend/controlplane/src/compliance/report.rs +++ b/backend/controlplane/src/compliance/report.rs @@ -8,7 +8,9 @@ use chrono::Utc; use serde::{Deserialize, Serialize}; -use super::model::{ApprovalChain, AuditEvidence, CompliancePolicy, ExportControl, PiiClassification}; +use super::model::{ + ApprovalChain, AuditEvidence, CompliancePolicy, ExportControl, PiiClassification, +}; /// A point-in-time compliance assessment for a single subject. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -53,7 +55,10 @@ impl ComplianceReport { if !approval_complete { findings.push("approval chain is incomplete".into()); } - if matches!(policy.export_control, ExportControl::Prohibited) && signed == 0 && !evidence.is_empty() { + if matches!(policy.export_control, ExportControl::Prohibited) + && signed == 0 + && !evidence.is_empty() + { findings.push("export-prohibited data lacks signed evidence".into()); } @@ -102,7 +107,11 @@ impl DepartmentComplianceSummary { let under_investigation = reports.iter().filter(|r| r.investigation_mode).count(); let mut findings: Vec = reports .iter() - .flat_map(|r| r.findings.iter().map(|f| format!("{}: {}", r.subject_id, f))) + .flat_map(|r| { + r.findings + .iter() + .map(|f| format!("{}: {}", r.subject_id, f)) + }) .collect(); findings.sort(); DepartmentComplianceSummary { @@ -143,7 +152,10 @@ mod tests { policy.pii_classification = PiiClassification::Pii; let report = ComplianceReport::generate(&policy, &[], None); assert!(!report.is_compliant()); - assert!(report.findings.iter().any(|f| f.contains("no audit evidence"))); + assert!(report + .findings + .iter() + .any(|f| f.contains("no audit evidence"))); } #[test] diff --git a/backend/controlplane/src/config.rs b/backend/controlplane/src/config.rs index 8054f47..1520ba9 100644 --- a/backend/controlplane/src/config.rs +++ b/backend/controlplane/src/config.rs @@ -63,7 +63,10 @@ impl ControlPlaneConfig { } fn parse_bool(value: &str) -> bool { - matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on") + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) } #[cfg(test)] diff --git a/backend/controlplane/src/error.rs b/backend/controlplane/src/error.rs index 2c11e43..51488bd 100644 --- a/backend/controlplane/src/error.rs +++ b/backend/controlplane/src/error.rs @@ -40,7 +40,10 @@ pub enum ControlPlaneError { impl ControlPlaneError { /// Build a [`ControlPlaneError::NotFound`] for the given entity and id. pub fn not_found(entity: &'static str, id: impl Into) -> Self { - ControlPlaneError::NotFound { entity, id: id.into() } + ControlPlaneError::NotFound { + entity, + id: id.into(), + } } /// Build a validation error. diff --git a/backend/controlplane/src/gateway/decision.rs b/backend/controlplane/src/gateway/decision.rs index fbdf86c..345aac4 100644 --- a/backend/controlplane/src/gateway/decision.rs +++ b/backend/controlplane/src/gateway/decision.rs @@ -47,7 +47,11 @@ impl SecurityDecision { /// One-line, human-readable verdict suitable for logs and API responses. pub fn summary(&self) -> String { if self.allowed { - format!("ALLOW (risk: {}, score: {})", self.risk_band(), self.risk_score) + format!( + "ALLOW (risk: {}, score: {})", + self.risk_band(), + self.risk_score + ) } else { format!( "DENY (risk: {}, score: {}) — {}", diff --git a/backend/controlplane/src/gateway/engine.rs b/backend/controlplane/src/gateway/engine.rs index a804202..e37b233 100644 --- a/backend/controlplane/src/gateway/engine.rs +++ b/backend/controlplane/src/gateway/engine.rs @@ -59,7 +59,9 @@ impl SecurityGateway { fn check_mcp(&self, req: &ActionRequest, denials: &mut Vec) { if let Some(server) = &req.mcp_server { if !req.agent.mcp_servers_allowed.iter().any(|s| s == server) { - denials.push(format!("MCP server '{server}' is not allowed for this agent")); + denials.push(format!( + "MCP server '{server}' is not allowed for this agent" + )); } } } @@ -263,7 +265,10 @@ mod tests { req.model = Some("gpt-4".into()); let d = gw.evaluate(&req); assert!(!d.allowed); - assert!(d.denials.iter().any(|r| r.contains("MCP server 'rogue-mcp'"))); + assert!(d + .denials + .iter() + .any(|r| r.contains("MCP server 'rogue-mcp'"))); assert!(d.denials.iter().any(|r| r.contains("model 'gpt-4'"))); } @@ -274,7 +279,10 @@ mod tests { req.data_access_level = DataAccessLevel::Restricted; // agent clearance is Internal let d = gw.evaluate(&req); assert!(!d.allowed); - assert!(d.denials.iter().any(|r| r.contains("exceeds agent clearance"))); + assert!(d + .denials + .iter() + .any(|r| r.contains("exceeds agent clearance"))); } #[test] @@ -306,7 +314,10 @@ mod tests { let mut req = ActionRequest::for_agent(a); req.tool = Some("search".into()); let d = gw.evaluate(&req); - assert!(d.denials.iter().any(|r| r.contains("human approval required"))); + assert!(d + .denials + .iter() + .any(|r| r.contains("human approval required"))); } #[test] diff --git a/backend/controlplane/src/gateway/log.rs b/backend/controlplane/src/gateway/log.rs index 5175bd3..e17f1b0 100644 --- a/backend/controlplane/src/gateway/log.rs +++ b/backend/controlplane/src/gateway/log.rs @@ -46,19 +46,27 @@ impl BlockedExecutionLog { pub fn open(path: &str) -> Result { let conn = Connection::open(path)?; conn.execute_batch(&format!("PRAGMA journal_mode=WAL;{SCHEMA}"))?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Open an ephemeral in-memory log (used by tests). pub fn in_memory() -> Result { let conn = Connection::open_in_memory()?; conn.execute_batch(SCHEMA)?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Record a denied decision. No-op (returns `None`) if the decision was /// actually allowed, so callers can pass every decision unconditionally. - pub fn record(&self, agent_id: &str, decision: &SecurityDecision) -> Result> { + pub fn record( + &self, + agent_id: &str, + decision: &SecurityDecision, + ) -> Result> { if decision.allowed { return Ok(None); } @@ -73,7 +81,13 @@ impl BlockedExecutionLog { conn.execute( "INSERT INTO blocked_executions (id, agent_id, reasons, risk_score, at) VALUES (?1,?2,?3,?4,?5)", - params![entry.id, entry.agent_id, entry.reasons, entry.risk_score, entry.at], + params![ + entry.id, + entry.agent_id, + entry.reasons, + entry.risk_score, + entry.at + ], )?; cp_blocked!("gateway.blocked", agent_id = %entry.agent_id, reasons = %entry.reasons); Ok(Some(entry)) diff --git a/backend/controlplane/src/governance/store.rs b/backend/controlplane/src/governance/store.rs index c08db3f..575f1bf 100644 --- a/backend/controlplane/src/governance/store.rs +++ b/backend/controlplane/src/governance/store.rs @@ -70,8 +70,9 @@ fn row_to_request(row: &rusqlite::Row) -> rusqlite::Result { } fn de(s: &str, col: usize) -> rusqlite::Result { - serde_json::from_str(s) - .map_err(|e| rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e))) + serde_json::from_str(s).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e)) + }) } impl GovernanceEngine { @@ -79,20 +80,26 @@ impl GovernanceEngine { pub fn open(path: &str) -> Result { let conn = Connection::open(path)?; conn.execute_batch(&format!("PRAGMA journal_mode=WAL;{SCHEMA}"))?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Open an ephemeral in-memory engine (used by tests). pub fn in_memory() -> Result { let conn = Connection::open_in_memory()?; conn.execute_batch(SCHEMA)?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Submit a new approval request; it starts in `Pending`. pub fn submit(&self, input: NewApprovalRequest) -> Result { if input.justification.trim().is_empty() { - return Err(ControlPlaneError::validation("justification must not be empty")); + return Err(ControlPlaneError::validation( + "justification must not be empty", + )); } let now = Utc::now().timestamp(); let req = ApprovalRequest::from_new(input, Uuid::new_v4().to_string(), now); @@ -121,12 +128,23 @@ impl GovernanceEngine { )?; cp_info!("governance.submit", request_id = %req.id, kind = ?req.kind); drop(conn); - self.record_event(&req.id, "submitted", &req.requested_by, Some(&req.justification))?; + self.record_event( + &req.id, + "submitted", + &req.requested_by, + Some(&req.justification), + )?; Ok(req) } /// Append a change-history (audit) event for a request. - fn record_event(&self, request_id: &str, action: &str, actor: &str, reason: Option<&str>) -> Result<()> { + fn record_event( + &self, + request_id: &str, + action: &str, + actor: &str, + reason: Option<&str>, + ) -> Result<()> { let conn = self.conn.lock().expect("governance mutex poisoned"); conn.execute( "INSERT INTO approval_events (id, request_id, action, actor, reason, at) @@ -178,10 +196,18 @@ impl GovernanceEngine { } /// Apply a terminal decision to a pending request (internal helper). - fn decide(&self, id: &str, status: ApprovalStatus, decided_by: &str, reason: &str) -> Result { + fn decide( + &self, + id: &str, + status: ApprovalStatus, + decided_by: &str, + reason: &str, + ) -> Result { // Every decision must carry a policy reason for the audit trail. if reason.trim().is_empty() { - return Err(ControlPlaneError::validation("a decision reason is required")); + return Err(ControlPlaneError::validation( + "a decision reason is required", + )); } let current = self.get(id)?; if current.status.is_decided() { @@ -212,7 +238,10 @@ impl GovernanceEngine { /// List all approval requests, newest first. pub fn list(&self) -> Result> { - self.query_requests(&format!("SELECT {COLUMNS} FROM approval_requests ORDER BY created_at DESC"), []) + self.query_requests( + &format!("SELECT {COLUMNS} FROM approval_requests ORDER BY created_at DESC"), + [], + ) } /// List approval requests with a given status (e.g. all `Pending`). @@ -232,7 +261,11 @@ impl GovernanceEngine { } /// Run a SELECT returning approval requests (internal helper). - fn query_requests(&self, sql: &str, params: P) -> Result> { + fn query_requests( + &self, + sql: &str, + params: P, + ) -> Result> { let conn = self.conn.lock().expect("governance mutex poisoned"); let mut stmt = conn.prepare(sql)?; let rows = stmt.query_map(params, row_to_request)?; @@ -252,7 +285,9 @@ impl GovernanceEngine { row_to_request, ) .map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => ControlPlaneError::not_found("approval_request", id), + rusqlite::Error::QueryReturnedNoRows => { + ControlPlaneError::not_found("approval_request", id) + } other => other.into(), }) } @@ -308,7 +343,9 @@ mod tests { fn reject_sets_rejected_status() { let eng = GovernanceEngine::in_memory().unwrap(); let r = eng.submit(req()).unwrap(); - let rejected = eng.reject(&r.id, "ciso", "insufficient justification").unwrap(); + let rejected = eng + .reject(&r.id, "ciso", "insufficient justification") + .unwrap(); assert_eq!(rejected.status, ApprovalStatus::Rejected); } @@ -349,8 +386,14 @@ mod tests { eng.approve(&a.id, "ciso", "ok").unwrap(); assert_eq!(eng.list().unwrap().len(), 2); - assert_eq!(eng.list_by_status(ApprovalStatus::Pending).unwrap().len(), 1); - assert_eq!(eng.list_by_status(ApprovalStatus::Approved).unwrap().len(), 1); + assert_eq!( + eng.list_by_status(ApprovalStatus::Pending).unwrap().len(), + 1 + ); + assert_eq!( + eng.list_by_status(ApprovalStatus::Approved).unwrap().len(), + 1 + ); assert_eq!(eng.list_by_owner("other-team").unwrap().len(), 1); } } diff --git a/backend/controlplane/src/integrations/model.rs b/backend/controlplane/src/integrations/model.rs index db8e7c8..4f357ed 100644 --- a/backend/controlplane/src/integrations/model.rs +++ b/backend/controlplane/src/integrations/model.rs @@ -70,17 +70,29 @@ pub struct CredentialRef { impl CredentialRef { /// A reference into a secrets vault. pub fn vault(path: impl Into) -> Self { - CredentialRef { store: "vault".into(), key: path.into(), description: String::new() } + CredentialRef { + store: "vault".into(), + key: path.into(), + description: String::new(), + } } /// A reference to an environment variable. pub fn env(var: impl Into) -> Self { - CredentialRef { store: "env".into(), key: var.into(), description: String::new() } + CredentialRef { + store: "env".into(), + key: var.into(), + description: String::new(), + } } /// A placeholder for integrations that need no stored credential. pub fn none() -> Self { - CredentialRef { store: "none".into(), key: String::new(), description: String::new() } + CredentialRef { + store: "none".into(), + key: String::new(), + description: String::new(), + } } /// Whether this reference actually points at a secret store. @@ -111,7 +123,9 @@ impl IntegrationPermission { pub fn is_elevated(&self) -> bool { matches!( self, - IntegrationPermission::Write | IntegrationPermission::Delete | IntegrationPermission::Admin + IntegrationPermission::Write + | IntegrationPermission::Delete + | IntegrationPermission::Admin ) } } diff --git a/backend/controlplane/src/integrations/placeholders.rs b/backend/controlplane/src/integrations/placeholders.rs index 97859ac..7976a65 100644 --- a/backend/controlplane/src/integrations/placeholders.rs +++ b/backend/controlplane/src/integrations/placeholders.rs @@ -33,7 +33,13 @@ pub fn database( } /// Placeholder for a GIS integration (ArcGIS and similar spatial services). -pub fn gis(name: &str, owner: &str, department: &str, endpoint: &str, credential: CredentialRef) -> NewIntegration { +pub fn gis( + name: &str, + owner: &str, + department: &str, + endpoint: &str, + credential: CredentialRef, +) -> NewIntegration { NewIntegration { name: name.into(), kind: IntegrationKind::ArcGis, @@ -49,7 +55,13 @@ pub fn gis(name: &str, owner: &str, department: &str, endpoint: &str, credential /// Placeholder for an SSO / identity-provider integration (OIDC/SAML, AD). /// Identity integrations are high risk by default. -pub fn sso(name: &str, owner: &str, department: &str, endpoint: &str, credential: CredentialRef) -> NewIntegration { +pub fn sso( + name: &str, + owner: &str, + department: &str, + endpoint: &str, + credential: CredentialRef, +) -> NewIntegration { NewIntegration { name: name.into(), kind: IntegrationKind::Sso, @@ -64,7 +76,13 @@ pub fn sso(name: &str, owner: &str, department: &str, endpoint: &str, credential } /// Placeholder for an email-sending integration (SMTP / provider API). -pub fn email(name: &str, owner: &str, department: &str, endpoint: &str, credential: CredentialRef) -> NewIntegration { +pub fn email( + name: &str, + owner: &str, + department: &str, + endpoint: &str, + credential: CredentialRef, +) -> NewIntegration { NewIntegration { name: name.into(), kind: IntegrationKind::Email, diff --git a/backend/controlplane/src/integrations/store.rs b/backend/controlplane/src/integrations/store.rs index 8970baa..50648b5 100644 --- a/backend/controlplane/src/integrations/store.rs +++ b/backend/controlplane/src/integrations/store.rs @@ -69,8 +69,9 @@ fn row_to_integration(row: &rusqlite::Row) -> rusqlite::Result(s: &str, col: usize) -> rusqlite::Result { - serde_json::from_str(s) - .map_err(|e| rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e))) + serde_json::from_str(s).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e)) + }) } impl IntegrationRegistry { @@ -78,20 +79,26 @@ impl IntegrationRegistry { pub fn open(path: &str) -> Result { let conn = Connection::open(path)?; conn.execute_batch(&format!("PRAGMA journal_mode=WAL;{SCHEMA}"))?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Open an ephemeral in-memory registry (used by tests). pub fn in_memory() -> Result { let conn = Connection::open_in_memory()?; conn.execute_batch(SCHEMA)?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Register a new integration; it starts in `PendingApproval`. pub fn register(&self, input: NewIntegration) -> Result { if input.name.trim().is_empty() { - return Err(ControlPlaneError::validation("integration name must not be empty")); + return Err(ControlPlaneError::validation( + "integration name must not be empty", + )); } let mut integration = IntegrationProvider::from_new(input); // Escalate risk to at least the classified baseline for the kind and @@ -111,7 +118,12 @@ impl IntegrationRegistry { let conn = self.conn.lock().expect("integration mutex poisoned"); conn.execute( "INSERT INTO integration_audit (id, integration_id, action, at) VALUES (?1,?2,?3,?4)", - params![Uuid::new_v4().to_string(), integration_id, action, Utc::now().timestamp()], + params![ + Uuid::new_v4().to_string(), + integration_id, + action, + Utc::now().timestamp() + ], )?; Ok(()) } @@ -141,7 +153,9 @@ impl IntegrationRegistry { /// List all integrations, newest first. pub fn list(&self) -> Result> { let conn = self.conn.lock().expect("integration mutex poisoned"); - let mut stmt = conn.prepare(&format!("SELECT {COLUMNS} FROM integrations ORDER BY created_at DESC"))?; + let mut stmt = conn.prepare(&format!( + "SELECT {COLUMNS} FROM integrations ORDER BY created_at DESC" + ))?; let rows = stmt.query_map([], row_to_integration)?; let mut out = Vec::new(); for r in rows { @@ -269,7 +283,12 @@ mod tests { let reg = IntegrationRegistry::in_memory().unwrap(); // A webhook is Medium by default, but Write permission floors it at High. let i = reg - .register(placeholders::webhook("alerts", "ops", "IT", "https://hooks.internal/x")) + .register(placeholders::webhook( + "alerts", + "ops", + "IT", + "https://hooks.internal/x", + )) .unwrap(); assert_eq!(i.risk_level, RiskLevel::High); assert!(i.has_elevated_permission()); @@ -280,7 +299,13 @@ mod tests { use crate::integrations::placeholders; let reg = IntegrationRegistry::in_memory().unwrap(); let i = reg - .register(placeholders::sso("corp-sso", "iam", "IT", "https://idp/realm", CredentialRef::vault("kv/sso"))) + .register(placeholders::sso( + "corp-sso", + "iam", + "IT", + "https://idp/realm", + CredentialRef::vault("kv/sso"), + )) .unwrap(); assert_eq!(i.risk_level, RiskLevel::Critical); assert!(i.credential.is_present()); diff --git a/backend/controlplane/src/marketplace/model.rs b/backend/controlplane/src/marketplace/model.rs index 070dd29..f1fbafd 100644 --- a/backend/controlplane/src/marketplace/model.rs +++ b/backend/controlplane/src/marketplace/model.rs @@ -92,7 +92,10 @@ impl MarketplaceAgent { /// option: verified and at least compliance-reviewed. pub fn is_trusted(&self) -> bool { self.verification == VerificationBadge::Verified - && matches!(self.compliance, ComplianceBadge::Compliant | ComplianceBadge::Certified) + && matches!( + self.compliance, + ComplianceBadge::Compliant | ComplianceBadge::Certified + ) } } diff --git a/backend/controlplane/src/marketplace/seed.rs b/backend/controlplane/src/marketplace/seed.rs index 0096ba0..4418957 100644 --- a/backend/controlplane/src/marketplace/seed.rs +++ b/backend/controlplane/src/marketplace/seed.rs @@ -3,7 +3,9 @@ use crate::constants::{DataAccessLevel, RiskLevel}; use crate::error::Result; -use super::model::{AgentTemplate, ComplianceBadge, MarketplaceAgent, NewListing, VerificationBadge}; +use super::model::{ + AgentTemplate, ComplianceBadge, MarketplaceAgent, NewListing, VerificationBadge, +}; use super::store::Marketplace; /// The built-in catalogue of example listings. diff --git a/backend/controlplane/src/marketplace/store.rs b/backend/controlplane/src/marketplace/store.rs index bbe043a..2a9bd8d 100644 --- a/backend/controlplane/src/marketplace/store.rs +++ b/backend/controlplane/src/marketplace/store.rs @@ -58,8 +58,9 @@ fn row_to_listing(row: &rusqlite::Row) -> rusqlite::Result { } fn de(s: &str, col: usize) -> rusqlite::Result { - serde_json::from_str(s) - .map_err(|e| rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e))) + serde_json::from_str(s).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e)) + }) } impl Marketplace { @@ -67,20 +68,26 @@ impl Marketplace { pub fn open(path: &str) -> Result { let conn = Connection::open(path)?; conn.execute_batch(&format!("PRAGMA journal_mode=WAL;{SCHEMA}"))?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Open an ephemeral in-memory marketplace (used by tests). pub fn in_memory() -> Result { let conn = Connection::open_in_memory()?; conn.execute_batch(SCHEMA)?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Publish a new listing to the marketplace. pub fn publish(&self, input: NewListing) -> Result { if input.name.trim().is_empty() { - return Err(ControlPlaneError::validation("listing name must not be empty")); + return Err(ControlPlaneError::validation( + "listing name must not be empty", + )); } let listing = MarketplaceAgent::from_new(input); self.upsert(&listing)?; @@ -90,7 +97,10 @@ impl Marketplace { /// List all listings, most-installed first. pub fn list(&self) -> Result> { - self.query(&format!("SELECT {COLUMNS} FROM marketplace_listings ORDER BY install_count DESC"), []) + self.query( + &format!("SELECT {COLUMNS} FROM marketplace_listings ORDER BY install_count DESC"), + [], + ) } /// List listings in a given category. @@ -132,9 +142,10 @@ impl Marketplace { department: &str, ) -> Result { let mut listing = self.get(listing_id)?; - let new_agent = listing - .template - .to_new_agent(name, listing.description.clone(), owner, department); + let new_agent = + listing + .template + .to_new_agent(name, listing.description.clone(), owner, department); let agent = registry.create(new_agent)?; listing.install_count += 1; self.upsert(&listing)?; @@ -143,7 +154,11 @@ impl Marketplace { } /// Set the verification badge on a listing (platform-team action). - pub fn set_verification(&self, listing_id: &str, badge: VerificationBadge) -> Result { + pub fn set_verification( + &self, + listing_id: &str, + badge: VerificationBadge, + ) -> Result { let mut listing = self.get(listing_id)?; listing.verification = badge; self.upsert(&listing)?; @@ -152,7 +167,11 @@ impl Marketplace { } /// Set the compliance badge on a listing (compliance-team action). - pub fn set_compliance(&self, listing_id: &str, badge: ComplianceBadge) -> Result { + pub fn set_compliance( + &self, + listing_id: &str, + badge: ComplianceBadge, + ) -> Result { let mut listing = self.get(listing_id)?; listing.compliance = badge; self.upsert(&listing)?; @@ -238,8 +257,11 @@ mod tests { fn badges_make_listing_trusted() { let mkt = Marketplace::in_memory().unwrap(); let l = mkt.publish(listing()).unwrap(); - mkt.set_verification(&l.id, VerificationBadge::Verified).unwrap(); - let l = mkt.set_compliance(&l.id, ComplianceBadge::Certified).unwrap(); + mkt.set_verification(&l.id, VerificationBadge::Verified) + .unwrap(); + let l = mkt + .set_compliance(&l.id, ComplianceBadge::Certified) + .unwrap(); assert!(l.is_trusted()); } @@ -260,7 +282,9 @@ mod tests { let mkt = Marketplace::in_memory().unwrap(); let reg = AgentRegistry::in_memory().unwrap(); let l = mkt.publish(listing()).unwrap(); - let agent = mkt.install(&l.id, ®, "Permit Bot A", "team-a", "Licensing").unwrap(); + let agent = mkt + .install(&l.id, ®, "Permit Bot A", "team-a", "Licensing") + .unwrap(); assert_eq!(agent.name, "Permit Bot A"); assert_eq!(agent.tools_allowed, vec!["search".to_string()]); assert_eq!(reg.count().unwrap(), 1); diff --git a/backend/controlplane/src/mcp/model.rs b/backend/controlplane/src/mcp/model.rs index 096019b..cc6043b 100644 --- a/backend/controlplane/src/mcp/model.rs +++ b/backend/controlplane/src/mcp/model.rs @@ -133,7 +133,10 @@ impl McpServer { /// Number of exposed tools that request a sensitive permission scope. pub fn sensitive_tool_count(&self) -> usize { - self.tools_exposed.iter().filter(|t| t.is_sensitive()).count() + self.tools_exposed + .iter() + .filter(|t| t.is_sensitive()) + .count() } /// Names of all exposed tools. diff --git a/backend/controlplane/src/mcp/store.rs b/backend/controlplane/src/mcp/store.rs index d87aa14..70789a2 100644 --- a/backend/controlplane/src/mcp/store.rs +++ b/backend/controlplane/src/mcp/store.rs @@ -67,8 +67,9 @@ fn row_to_server(row: &rusqlite::Row) -> rusqlite::Result { } fn de(s: &str, col: usize) -> rusqlite::Result { - serde_json::from_str(s) - .map_err(|e| rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e))) + serde_json::from_str(s).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e)) + }) } impl McpRegistry { @@ -76,23 +77,31 @@ impl McpRegistry { pub fn open(path: &str) -> Result { let conn = Connection::open(path)?; conn.execute_batch(&format!("PRAGMA journal_mode=WAL;{SCHEMA}"))?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Open an ephemeral in-memory registry (used by tests). pub fn in_memory() -> Result { let conn = Connection::open_in_memory()?; conn.execute_batch(SCHEMA)?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Register a new MCP server; it starts in `PendingApproval`. pub fn register(&self, input: NewMcpServer) -> Result { if input.name.trim().is_empty() { - return Err(ControlPlaneError::validation("MCP server name must not be empty")); + return Err(ControlPlaneError::validation( + "MCP server name must not be empty", + )); } if input.endpoint.trim().is_empty() { - return Err(ControlPlaneError::validation("MCP server endpoint must not be empty")); + return Err(ControlPlaneError::validation( + "MCP server endpoint must not be empty", + )); } let server = McpServer::from_new(input); self.upsert(&server)?; @@ -103,7 +112,9 @@ impl McpRegistry { /// List all registered MCP servers, newest first. pub fn list(&self) -> Result> { let conn = self.conn.lock().expect("mcp mutex poisoned"); - let mut stmt = conn.prepare(&format!("SELECT {COLUMNS} FROM mcp_servers ORDER BY created_at DESC"))?; + let mut stmt = conn.prepare(&format!( + "SELECT {COLUMNS} FROM mcp_servers ORDER BY created_at DESC" + ))?; let rows = stmt.query_map([], row_to_server)?; let mut out = Vec::new(); for r in rows { @@ -236,7 +247,10 @@ mod tests { #[test] fn get_missing_is_not_found() { let reg = McpRegistry::in_memory().unwrap(); - assert!(matches!(reg.get("nope"), Err(ControlPlaneError::NotFound { .. }))); + assert!(matches!( + reg.get("nope"), + Err(ControlPlaneError::NotFound { .. }) + )); } use crate::constants::RiskLevel; @@ -250,8 +264,16 @@ mod tests { endpoint: "https://mcp.internal/records".into(), transport: TransportType::Http, tools_exposed: vec![ - McpTool { name: "lookup".into(), description: "read records".into(), permissions: vec!["read".into()] }, - McpTool { name: "write".into(), description: "update records".into(), permissions: vec!["write".into(), "pii".into()] }, + McpTool { + name: "lookup".into(), + description: "read records".into(), + permissions: vec!["read".into()], + }, + McpTool { + name: "write".into(), + description: "update records".into(), + permissions: vec!["write".into(), "pii".into()], + }, ], permissions_required: vec!["read".into(), "write".into()], risk_level: RiskLevel::High, @@ -292,8 +314,16 @@ mod tests { reg.register(input()).unwrap(); reg.approve(&a.id).unwrap(); assert_eq!(reg.list().unwrap().len(), 2); - assert_eq!(reg.list_by_status(LifecycleStatus::Active).unwrap().len(), 1); - assert_eq!(reg.list_by_status(LifecycleStatus::PendingApproval).unwrap().len(), 1); + assert_eq!( + reg.list_by_status(LifecycleStatus::Active).unwrap().len(), + 1 + ); + assert_eq!( + reg.list_by_status(LifecycleStatus::PendingApproval) + .unwrap() + .len(), + 1 + ); } #[test] diff --git a/backend/controlplane/src/observability/seed.rs b/backend/controlplane/src/observability/seed.rs index dab2602..16b29b9 100644 --- a/backend/controlplane/src/observability/seed.rs +++ b/backend/controlplane/src/observability/seed.rs @@ -12,7 +12,12 @@ use super::store::ObservabilityStore; pub fn seed_for_agent(store: &ObservabilityStore, agent_id: &str) -> Result<()> { // Tasks: mostly successful, a couple failed. for i in 0..8 { - store.log_event(NewExecutionEvent::task(agent_id, true, 100 + i * 10, 0.01 + i as f64 * 0.002))?; + store.log_event(NewExecutionEvent::task( + agent_id, + true, + 100 + i * 10, + 0.01 + i as f64 * 0.002, + ))?; } store.log_event(NewExecutionEvent::task(agent_id, false, 450, 0.03))?; store.log_event(NewExecutionEvent::task(agent_id, false, 500, 0.04))?; @@ -24,7 +29,12 @@ pub fn seed_for_agent(store: &ObservabilityStore, agent_id: &str) -> Result<()> for _ in 0..3 { store.log_event(call(agent_id, EventKind::McpCall, "records-mcp", true))?; } - store.log_event(call(agent_id, EventKind::ModelCall, "claude-opus-4-8", true))?; + store.log_event(call( + agent_id, + EventKind::ModelCall, + "claude-opus-4-8", + true, + ))?; // Governance / risk signals. store.log_event(event(agent_id, EventKind::RiskEvent))?; diff --git a/backend/controlplane/src/observability/store.rs b/backend/controlplane/src/observability/store.rs index 8703a84..84a5f48 100644 --- a/backend/controlplane/src/observability/store.rs +++ b/backend/controlplane/src/observability/store.rs @@ -40,14 +40,18 @@ impl ObservabilityStore { pub fn open(path: &str) -> Result { let conn = Connection::open(path)?; conn.execute_batch(&format!("PRAGMA journal_mode=WAL;{SCHEMA}"))?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Open an ephemeral in-memory store (used by tests). pub fn in_memory() -> Result { let conn = Connection::open_in_memory()?; conn.execute_batch(SCHEMA)?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Record a new execution event, returning the persisted record. @@ -125,7 +129,12 @@ impl ObservabilityStore { } /// Count events of a kind with a given outcome (optionally scoped). - fn count_kind_success(&self, agent: Option<&str>, kind: EventKind, success: bool) -> Result { + fn count_kind_success( + &self, + agent: Option<&str>, + kind: EventKind, + success: bool, + ) -> Result { let k = serde_json::to_string(&kind)?; let s = success as i64; let conn = self.conn.lock().expect("observability mutex poisoned"); @@ -169,8 +178,9 @@ impl ObservabilityStore { let kind = serde_json::to_string(&EventKind::Task)?; let conn = self.conn.lock().expect("observability mutex poisoned"); let sql_all = format!("SELECT COALESCE({expr}, 0.0) FROM execution_events WHERE kind = ?1"); - let sql_agent = - format!("SELECT COALESCE({expr}, 0.0) FROM execution_events WHERE kind = ?1 AND agent_id = ?2"); + let sql_agent = format!( + "SELECT COALESCE({expr}, 0.0) FROM execution_events WHERE kind = ?1 AND agent_id = ?2" + ); let v: f64 = match agent { Some(a) => conn.query_row(&sql_agent, params![kind, a], |r| r.get(0))?, None => conn.query_row(&sql_all, params![kind], |r| r.get(0))?, @@ -272,7 +282,9 @@ mod tests { #[test] fn log_and_count_events() { let store = ObservabilityStore::in_memory().unwrap(); - store.log_event(NewExecutionEvent::task("agent-1", true, 120, 0.02)).unwrap(); + store + .log_event(NewExecutionEvent::task("agent-1", true, 120, 0.02)) + .unwrap(); store.log_event(tool("agent-2", true)).unwrap(); assert_eq!(store.event_count(None).unwrap(), 2); assert_eq!(store.event_count(Some("agent-1")).unwrap(), 1); @@ -281,9 +293,15 @@ mod tests { #[test] fn task_metrics_compute() { let store = ObservabilityStore::in_memory().unwrap(); - store.log_event(NewExecutionEvent::task("a", true, 100, 0.10)).unwrap(); - store.log_event(NewExecutionEvent::task("a", true, 300, 0.30)).unwrap(); - store.log_event(NewExecutionEvent::task("a", false, 200, 0.20)).unwrap(); + store + .log_event(NewExecutionEvent::task("a", true, 100, 0.10)) + .unwrap(); + store + .log_event(NewExecutionEvent::task("a", true, 300, 0.30)) + .unwrap(); + store + .log_event(NewExecutionEvent::task("a", false, 200, 0.20)) + .unwrap(); assert_eq!(store.task_count(Some("a")).unwrap(), 3); assert_eq!(store.successful_tasks(Some("a")).unwrap(), 2); assert_eq!(store.failed_tasks(Some("a")).unwrap(), 1); @@ -316,8 +334,12 @@ mod tests { #[test] fn fleet_summary_aggregates_all_agents() { let store = ObservabilityStore::in_memory().unwrap(); - store.log_event(NewExecutionEvent::task("a", true, 100, 0.1)).unwrap(); - store.log_event(NewExecutionEvent::task("b", false, 100, 0.1)).unwrap(); + store + .log_event(NewExecutionEvent::task("a", true, 100, 0.1)) + .unwrap(); + store + .log_event(NewExecutionEvent::task("b", false, 100, 0.1)) + .unwrap(); let fleet = store.summary(None).unwrap(); assert_eq!(fleet.agent_id, "*"); assert_eq!(fleet.task_count, 2); diff --git a/backend/controlplane/src/registry/seed.rs b/backend/controlplane/src/registry/seed.rs index 2b890ff..a3056d7 100644 --- a/backend/controlplane/src/registry/seed.rs +++ b/backend/controlplane/src/registry/seed.rs @@ -15,7 +15,8 @@ pub fn sample_agents() -> Vec { vec![ NewAgent { name: "Permit Intake Assistant".into(), - description: "Triages building-permit applications for the Licensing department.".into(), + description: "Triages building-permit applications for the Licensing department." + .into(), owner: "licensing-platform".into(), department: "Licensing".into(), framework: "openclaw".into(), @@ -41,7 +42,8 @@ pub fn sample_agents() -> Vec { }, NewAgent { name: "IT Ops Runbook Agent".into(), - description: "Executes approved remediation runbooks for the IT operations team.".into(), + description: "Executes approved remediation runbooks for the IT operations team." + .into(), owner: "it-ops".into(), department: "Information Technology".into(), framework: "openclaw".into(), diff --git a/backend/controlplane/src/registry/store.rs b/backend/controlplane/src/registry/store.rs index 9017c0a..97ad4d6 100644 --- a/backend/controlplane/src/registry/store.rs +++ b/backend/controlplane/src/registry/store.rs @@ -75,8 +75,9 @@ fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result { /// Deserialize a JSON-encoded column value, surfacing parse errors as SQLite /// conversion failures so they propagate through `query_map`. fn de(s: &str, col: usize) -> rusqlite::Result { - serde_json::from_str(s) - .map_err(|e| rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e))) + serde_json::from_str(s).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e)) + }) } impl AgentRegistry { @@ -84,14 +85,18 @@ impl AgentRegistry { pub fn open(path: &str) -> Result { let conn = Connection::open(path)?; conn.execute_batch(&format!("PRAGMA journal_mode=WAL;{SCHEMA}"))?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Open an ephemeral in-memory registry (used by tests). pub fn in_memory() -> Result { let conn = Connection::open_in_memory()?; conn.execute_batch(SCHEMA)?; - Ok(Self { conn: Mutex::new(conn) }) + Ok(Self { + conn: Mutex::new(conn), + }) } /// Validate and register a new agent, returning the materialised record. @@ -131,7 +136,9 @@ impl AgentRegistry { /// List all registered agents, newest first. pub fn list(&self) -> Result> { let conn = self.conn.lock().expect("registry mutex poisoned"); - let mut stmt = conn.prepare(&format!("SELECT {COLUMNS} FROM agents ORDER BY created_at DESC"))?; + let mut stmt = conn.prepare(&format!( + "SELECT {COLUMNS} FROM agents ORDER BY created_at DESC" + ))?; let rows = stmt.query_map([], row_to_record)?; let mut out = Vec::new(); for row in rows { @@ -322,10 +329,15 @@ mod tests { let reg = AgentRegistry::in_memory().unwrap(); let created = reg.create(input()).unwrap(); // Draft -> Active is not allowed (must pass approval). - assert!(reg.set_status(&created.id, LifecycleStatus::Active).is_err()); + assert!(reg + .set_status(&created.id, LifecycleStatus::Active) + .is_err()); // Draft -> PendingApproval -> Active is allowed. - reg.set_status(&created.id, LifecycleStatus::PendingApproval).unwrap(); - let active = reg.set_status(&created.id, LifecycleStatus::Active).unwrap(); + reg.set_status(&created.id, LifecycleStatus::PendingApproval) + .unwrap(); + let active = reg + .set_status(&created.id, LifecycleStatus::Active) + .unwrap(); assert!(active.is_operational()); } } diff --git a/backend/controlplane/src/registry/validation.rs b/backend/controlplane/src/registry/validation.rs index 372793c..f5445cc 100644 --- a/backend/controlplane/src/registry/validation.rs +++ b/backend/controlplane/src/registry/validation.rs @@ -26,7 +26,8 @@ pub fn validate_new_agent(input: &NewAgent) -> Result<()> { // A restricted-data agent that is only low risk is almost always a // mis-classification; force an explicit higher risk level. - if input.data_access_level == DataAccessLevel::Restricted && input.risk_level == RiskLevel::Low { + if input.data_access_level == DataAccessLevel::Restricted && input.risk_level == RiskLevel::Low + { return Err(ControlPlaneError::validation( "agents with restricted data access must be at least medium risk", )); @@ -43,7 +44,9 @@ pub fn validate_record(record: &AgentRecord) -> Result<()> { bound_len("name", &record.name)?; bound_len("owner", &record.owner)?; bound_len("department", &record.department)?; - if record.data_access_level == DataAccessLevel::Restricted && record.risk_level == RiskLevel::Low { + if record.data_access_level == DataAccessLevel::Restricted + && record.risk_level == RiskLevel::Low + { return Err(ControlPlaneError::validation( "agents with restricted data access must be at least medium risk", )); @@ -53,7 +56,9 @@ pub fn validate_record(record: &AgentRecord) -> Result<()> { fn require_non_empty(field: &'static str, value: &str) -> Result<()> { if value.trim().is_empty() { - return Err(ControlPlaneError::validation(format!("{field} must not be empty"))); + return Err(ControlPlaneError::validation(format!( + "{field} must not be empty" + ))); } Ok(()) } From eac6b5833923ac1e2db9a12a52f47c47610636d8 Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:32:38 +0400 Subject: [PATCH 17/18] phase 10: add changelog recording verified control-plane build --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..750ec9c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +## Control Plane — initial build + +Adds the `clawforge-controlplane` crate: the enterprise/government control plane +for governing, securing, observing, auditing, and operating AI agents and MCP +servers. Built in ten phases, each merged via its own pull request. + +### Added + +- **Foundation** — `ControlPlaneConfig`, shared vocabularies (`RiskLevel`, + `DataAccessLevel`, `LifecycleStatus`), unified `ControlPlaneError`, structured + logging macros. +- **Agent Registry** — CRUD, validation, and an enforced lifecycle state + machine (no `Draft → Active` without approval). +- **Governance Engine** — approval workflow with human gate, mandatory decision + reasons, and append-only change history. +- **Observability** — append-only execution events with on-demand per-agent and + fleet-wide metric summaries. +- **Security Gateway** — pre-execution checks (agent state, tool, MCP, model, + data access, capabilities, budget, human approval), risk scoring, and a + blocked-execution log. +- **MCP Governance** — registry with approval, health, and usage tracking. +- **Agent Marketplace** — verified, reusable templates with verification and + compliance badges; install into the registry. +- **Enterprise Integrations** — governed connectors (DBs, SSO, GIS, ITSM, …) + with credential *references* (never secrets) and risk classification. +- **Government Compliance Pack** — PII classification, retention, approval + chains, audit evidence, investigation holds, and compliance reporting + (UAE PDPL-aware). +- **Docs & demo** — per-domain docs, Mermaid diagrams, government and enterprise + use cases, installation and developer guides, roadmap, limitations, security + disclaimer, and a runnable end-to-end example. + +### Tests + +`cargo test -p clawforge-controlplane` — **82 passing, 0 failing**. + +### Notes + +The control plane is a self-contained library crate. It is not yet wired into +the live runtime or exposed over HTTP; see [docs/roadmap.md](docs/roadmap.md) +and [docs/limitations.md](docs/limitations.md). From 40a93bef6bf31c4b3508fb69edc3614d033c78ef Mon Sep 17 00:00:00 2001 From: YASSERRMD Date: Tue, 9 Jun 2026 18:33:10 +0400 Subject: [PATCH 18/18] phase 10: add documentation index and final review --- docs/README.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 8485f5c..c46867b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,2 +1,40 @@ # ClawForge Documentation -> API references and user guides — coming later. + +Index of the control-plane documentation. + +## Start here + +- [Product positioning](product-positioning.md) — what ClawForge is (and isn't). +- [Architecture](architecture.md) — the two layers and how they relate. +- [Diagrams](diagrams.md) — architecture, lifecycle, governance, and MCP flows. +- [Installation guide](installation.md) — prerequisites, config, build & test. +- [Demo](demo.md) — runnable end-to-end walkthrough. + +## Capabilities + +- [Agent Registry](registry.md) +- [Governance Engine](governance.md) +- [Observability](observability.md) +- [Security Gateway](security-gateway.md) +- [MCP Governance](mcp-governance.md) +- [Agent Marketplace](marketplace.md) +- [Enterprise Integrations](enterprise-integrations.md) +- [Government Compliance Pack](government-compliance.md) + +## Use cases + +- [Use cases overview](use-cases.md) +- [Government municipality](government-municipality.md) +- [Enterprise IT](enterprise.md) + +## Governance, compliance & operations + +- [UAE PDPL awareness note](uae-pdpl.md) +- [Security disclaimer](security-disclaimer.md) +- [Roadmap](roadmap.md) +- [Limitations](limitations.md) + +## Contributing + +- [Developer guide](developer-guide.md) +- [Contributing](../CONTRIBUTING.md)