diff --git a/packages/agent-os/extensions/vscode/.eslintrc.json b/packages/agent-os-vscode/.eslintrc.json similarity index 100% rename from packages/agent-os/extensions/vscode/.eslintrc.json rename to packages/agent-os-vscode/.eslintrc.json diff --git a/packages/agent-os/extensions/vscode/.gitignore b/packages/agent-os-vscode/.gitignore similarity index 100% rename from packages/agent-os/extensions/vscode/.gitignore rename to packages/agent-os-vscode/.gitignore diff --git a/packages/agent-os/extensions/vscode/.vscodeignore b/packages/agent-os-vscode/.vscodeignore similarity index 100% rename from packages/agent-os/extensions/vscode/.vscodeignore rename to packages/agent-os-vscode/.vscodeignore diff --git a/packages/agent-os-vscode/CHANGELOG.md b/packages/agent-os-vscode/CHANGELOG.md new file mode 100644 index 00000000..940b88d6 --- /dev/null +++ b/packages/agent-os-vscode/CHANGELOG.md @@ -0,0 +1,138 @@ +# Changelog + +All notable changes to the Agent OS VS Code extension will be documented in this file. + +## [1.1.0] - 2026-03-25 + +### Security +- Rate limiting added to GovernanceServer (100 requests/minute per client) +- Session token authentication for WebSocket connections +- Bundled D3.js v7.8.5 and Chart.js v4.4.1 locally (removed CDN dependency on cdn.jsdelivr.net) +- Eliminated innerHTML XSS vectors via shared `escapeHtml` utility across all legacy panels +- Pinned axios (1.13.6) and ws (8.20.0) to exact versions for reproducible builds +- Python path validation: rejects shell metacharacters before subprocess spawn +- axios retained over VS Code built-in fetch: provides timeout, maxContentLength, and maxRedirects guards not available in built-in fetch API + +### Removed +- `S3StorageProvider` - Cloud export to AWS S3 (stub, never implemented) +- `AzureBlobStorageProvider` - Cloud export to Azure Blob Storage (stub, never implemented) +- Backend service layer (out of scope for this release) + +### Added +- Live governance data: auto-detects and starts agent-failsafe REST server on activation +- Auto-install: prompts to install agent-failsafe[server] from PyPI if not found +- Connection indicator in status bar: Live, Stale, Disconnected +- Input validation on all REST responses with type checking, size caps, string truncation +- Loopback enforcement: governance endpoint restricted to 127.0.0.1/localhost/::1 +- Governance Hub: Unified sidebar webview with SLO, topology, and audit tabs +- SLO Dashboard: Rich webview panel with availability, latency, compliance, and trust score metrics +- Agent Topology: Force-directed graph panel showing agent mesh, trust rings, and bridges +- Browser experience: Local dev server serves governance dashboard in external browser +- Governance status bar: Mode indicator, execution ring, connection status +- Policy diagnostics: Real-time governance rule validation with code actions +- Local report export: Self-contained HTML governance report +- Metrics exporter: Push dashboard metrics to observability endpoints +- 3-slot configurable sidebar replacing 8 stacked tree views with React + Tailwind panel system +- Panel picker overlay for drag-and-drop slot configuration +- GovernanceStore: centralized state management with JSON deduplication and visibility gating +- Event-driven refresh: sidebar reacts instantly to data changes via vscode.EventEmitter, 30s heartbeat safety net +- Scanning mode: 4-second auto-rotation through sidebar slots with hover/focus pause and prefers-reduced-motion support +- Attention toggle: Manual/Auto switch — manual locks to user config, auto enables scanning and priority reordering +- Priority engine: ranks panels by health urgency (critical > warning > healthy > unknown), auto-reorders slots in auto mode +- Per-panel latency isolation: slow data sources automatically split to offset refresh cadence with staleness indicator + +### Changed +- SLO Dashboard, Agent Topology, and Governance Hub panels migrated from HTML template strings to React + Tailwind +- Panel host classes replaced with shared `panelHost.ts` factory (280 lines of duplication removed) +- GovernanceStore data fetches parallelized via Promise.all (latency: sum of all sources → max) +- ForceGraph DOM rendering optimized (build elements once, update positions per frame) +- Refresh commands (`refreshSLO`, `refreshTopology`) now route through GovernanceStore +- SidebarProvider refactored from monolithic 213-line data owner to 133-line thin webview bridge +- Sidebar polling replaced with event-driven architecture — LiveSREClient and AuditLogger emit change events + +### Removed +- Legacy tree view commands: `showSLODashboard`, `showAgentTopology` (replaced by `showSLOWebview`, `showTopologyGraph`) +- Legacy HTML template panels: SLODashboardPanel, TopologyGraphPanel, GovernanceHubPanel (replaced by React detail panels) +- Legacy hub formatters: hubSLOFormatter, hubTopologyFormatter, hubAuditFormatter, hubAuditHelpers + +### Fixed +- Path traversal vulnerability in LocalStorageProvider (export directory escape) +- KernelDebuggerProvider 1-second timer never disposed (memory/CPU leak) +- GovernanceStore detail subscriptions leaked empty Sets on dispose +- Panel host title HTML injection vulnerability (now stripped) + +## [1.0.1] - 2026-01-29 + +### Fixed +- Workflow Designer: Delete button now works correctly on nodes +- Workflow Designer: Code generation handles empty workflows gracefully +- Workflow Designer: TypeScript and Go exports have proper type annotations + +## [1.0.0] - 2026-01-28 + +### Added - GA Release 🎉 +- **Policy Management Studio**: Visual policy editor with templates + - 5 built-in templates (Strict Security, SOC 2, GDPR, Development, Rate Limiting) + - Real-time validation + - Import/Export in YAML format + +- **Workflow Designer**: Drag-and-drop agent workflow builder + - 4 node types (Action, Condition, Loop, Parallel) + - 8 action types (file_read, http_request, llm_call, etc.) + - Code export to Python, TypeScript, Go + - Policy attachment at node level + +- **Metrics Dashboard**: Real-time monitoring + - Policy check statistics + - Activity feed with timestamps + - Export to CSV/JSON + +- **IntelliSense & Snippets** + - 14 code snippets for Python, TypeScript, YAML + - Context-aware completions for AgentOS APIs + - Hover documentation + +- **Security Diagnostics** + - Real-time vulnerability detection + - 13 security rules (os.system, eval, exec, etc.) + - Quick fixes available + +- **Enterprise Features** + - SSO integration (Azure AD, Okta, Google, GitHub) + - Role-based access control (5 roles) + - CI/CD integration (GitHub Actions, GitLab CI, Jenkins, Azure Pipelines, CircleCI) + - Compliance frameworks (SOC 2, GDPR, HIPAA, PCI DSS) + +- **Onboarding Experience** + - Interactive getting started guide + - Progress tracking + - First agent tutorial + +### Changed +- Upgraded extension architecture for GA stability +- Improved WebView performance + +## [0.1.0] - 2026-01-27 + +### Added +- Initial release +- Real-time code safety analysis +- Policy engine with 5 policy categories: + - Destructive SQL (DROP, DELETE, TRUNCATE) + - File deletes (rm -rf, unlink, rmtree) + - Secret exposure (API keys, passwords, tokens) + - Privilege escalation (sudo, chmod 777) + - Unsafe network calls (HTTP instead of HTTPS) +- CMVK multi-model code review (mock implementation for demo) +- Audit log sidebar with recent activity +- Policies view showing active policies +- Statistics view with daily/weekly counts +- Status bar with real-time protection indicator +- Team policy sharing via `.vscode/agent-os.json` +- Export audit log to JSON +- Custom rule support + +### Known Limitations +- CMVK uses mock responses (real API integration planned) +- Inline completion interception is read-only (doesn't block) +- Limited to text change detection for now diff --git a/packages/agent-os-vscode/HELP.md b/packages/agent-os-vscode/HELP.md new file mode 100644 index 00000000..929d2bf8 --- /dev/null +++ b/packages/agent-os-vscode/HELP.md @@ -0,0 +1,140 @@ +# Agent OS for VS Code -- Help + +## Overview + +Agent OS provides kernel-level governance for AI coding assistants running inside VS Code. +It enforces policies in real time, audits every AI suggestion, and visualizes the health +of your agent mesh through a set of sidebar panels and detail views. + +--- + +## Panels + +### SLO Dashboard (Sidebar) + +Displays Service Level Objective health for the governance kernel. Four metric groups: + +| Metric | Meaning | +|---|---| +| Availability | Percentage of successful governance evaluations over the current window. | +| Latency P50 / P95 / P99 | Response time percentiles for policy evaluation calls (milliseconds). | +| Compliance | Percentage of tool calls that passed policy evaluation without violations. | +| Trust Score | Mean and minimum trust scores across all registered agents (0--1000 scale). | + +Click the panel header to open the **SLO Detail** view with burn-rate sparklines and error budget gauges. + +### Topology (Sidebar) + +Shows the agent mesh as a list of registered agents, protocol bridges (A2A, MCP, IATP), +and delegation chains. Each agent entry displays its DID, trust score, and execution ring. + +Click the panel header to open the **Topology Detail** view with a force-directed graph. + +### Audit Log (Sidebar) + +Scrollable list of recent governance events: tool calls evaluated, blocked, warned, or allowed. +Each entry shows timestamp, action, agent DID, affected file, and severity badge. + +### Policies (Sidebar) + +Lists all active policy rules with their action (ALLOW / DENY / AUDIT / BLOCK), match pattern, +evaluation count, and violation count for the current day. + +### Stats (Sidebar) + +Aggregate counters: total tool calls blocked, warnings issued, CMVK reviews triggered, +and total log entries. Refreshes on the same tick as all other panels. + +### Kernel Debugger (Sidebar) + +Live view of kernel internals: registered agents, active violations, saga checkpoints, +and kernel uptime. Useful for diagnosing why a tool call was blocked or escalated. + +### Memory Browser (Sidebar) + +Virtual filesystem browser showing the episodic memory kernel (EMK) contents. +Navigate directories and inspect files stored by agents during execution. + +### Governance Hub (Detail) + +Composite view combining SLO, topology, audit, and policy data in a tabbed interface. +Provides a single-pane-of-glass overview of governance health. Tabs: Overview, SLO, +Topology, Audit, Policy. + +### SLO Detail (Detail) + +Full SLO view with: +- Availability and latency gauges against their targets. +- Error budget remaining bars for availability and latency. +- 24-point burn-rate sparkline showing consumption trend. +- Trust score distribution histogram (4 buckets: 0--250, 251--500, 501--750, 751--1000). + +### Topology Detail (Detail) + +Force-directed graph of the agent mesh. Nodes are agents colored by trust tier. +Edges represent delegation chains labeled with the delegated capability. +Bridge status indicators show connected protocol bridges. + +### Policy Detail (Detail) + +Table of all policy rules with columns: name, action, pattern, enabled, evaluations today, +violations today. Sortable and filterable. + +--- + +## Glossary + +| Term | Definition | +|---|---| +| SLO | Service Level Objective -- a target for a measurable reliability metric. | +| SLI | Service Level Indicator -- the measured value that an SLO tracks. | +| P50 / P95 / P99 | Latency percentiles. P99 = 99% of requests are faster than this value. | +| Burn Rate | How fast the error budget is being consumed. 1.0 = on pace to exhaust exactly at window end. | +| Error Budget | Allowed unreliability. If target is 99.9%, the budget is 0.1% of total requests. | +| Trust Score | Numeric reputation of an agent (0--1000). Derived from behavioral signals via reward scoring. | +| Trust Ring | Concentric tiers grouping agents by trust level for visualization (high, medium, low). | +| DID | Decentralized Identifier. Format: `did:mesh:` (toolkit) or `did:myth::` (FailSafe). | +| CMVK | Constitutional Multi-Model Verification Kernel. Cross-checks AI output with multiple models. | +| Delegation Chain | A directed trust relationship where one agent grants a capability to another. | +| Bridge | Protocol adapter connecting Agent Mesh to external systems (A2A, MCP, IATP). | +| CSP | Content Security Policy. HTTP header restricting resource loading in webviews. | +| Policy Action | Evaluation result: ALLOW (permit), DENY (reject), AUDIT (permit + log), BLOCK (reject + alert). | +| Execution Ring | Privilege tier from hypervisor: Ring 0 (root), Ring 1 (supervisor), Ring 2 (user), Ring 3 (sandbox). | +| Agent Mesh | The network of registered agents, their identities, trust scores, and interconnections. | +| Saga | A multi-step workflow with checkpoints and compensating actions managed by the hypervisor. | + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Status bar shows **Disconnected** | WebSocket connection to local governance server dropped. | Check that the server is running (`Agent OS: Start Server` command). Verify port 9845 is not blocked. | +| Panel header shows **Stale** | Last data refresh was more than 2 tick intervals ago. | Click the refresh icon on the panel. If persistent, restart the extension host. | +| Panel shows **Waiting for data...** | First data fetch has not completed yet. | Wait 10 seconds for the first broadcast cycle. If it persists, the mock backend may have failed to initialize. | +| Topology graph is empty | No agents are registered in the mock or live backend. | Ensure the topology data provider is configured. In dev mode, the mock backend seeds 4 agents automatically. | +| SLO shows 0% availability | The SLO provider returned a zeroed snapshot. | This usually means the provider has not received any evaluation events. Trigger a policy evaluation or restart. | +| Browser dashboard not loading | Server failed to bind to 127.0.0.1. | Run `Agent OS: Start Server` and check the Output panel for port conflict messages. | + +--- + +## Security Design Decisions + +| Decision | Rationale | Risk Level | +|---|---|---| +| `'unsafe-inline'` for `style-src` in CSP | Required for VS Code theme CSS variable injection (`var(--vscode-*)`). Scripts remain nonce-gated. | Low -- style-only; no script injection vector. | +| `retainContextWhenHidden: true` on Topology Detail | Preserves force-simulation state across tab switches (~120 animation frames). | Low -- adds ~2 MB memory when backgrounded. | +| Session token in WebSocket URL query string | WebSocket upgrade requests cannot carry custom headers (RFC 6455). Token is 128-bit `crypto.randomBytes`. | Low -- server binds to 127.0.0.1; token never leaves loopback. | +| Rate limiter Map without TTL eviction | Server is loopback-only, so the map holds at most one entry (127.0.0.1). | Negligible -- no memory growth risk. | +| `Math.random()` for burn-rate sparkline jitter | Synthetic demo data only, not used for any security or cryptographic purpose. | None -- replaced by real SRE data when backend connects. | +| `axios` not used; `http` module for server | The governance server uses Node built-in `http`. No external HTTP client dependency. | N/A | + +--- + +## Configuration Reference + +All extension settings are documented in the [README](README.md) under the **Extension Settings** section. +Key settings are prefixed with `agent-os.` in VS Code's Settings UI. + +For policy file configuration, see the Agent OS documentation on policy schemas: +`packages/agent-os/src/agent_os/policies/schema.py`. diff --git a/packages/agent-os/extensions/vscode/LICENSE b/packages/agent-os-vscode/LICENSE similarity index 100% rename from packages/agent-os/extensions/vscode/LICENSE rename to packages/agent-os-vscode/LICENSE diff --git a/packages/agent-os/extensions/vscode/PUBLISHING.md b/packages/agent-os-vscode/PUBLISHING.md similarity index 100% rename from packages/agent-os/extensions/vscode/PUBLISHING.md rename to packages/agent-os-vscode/PUBLISHING.md diff --git a/packages/agent-os/extensions/vscode/README.md b/packages/agent-os-vscode/README.md similarity index 56% rename from packages/agent-os/extensions/vscode/README.md rename to packages/agent-os-vscode/README.md index 5fc0038d..48d92be7 100644 --- a/packages/agent-os/extensions/vscode/README.md +++ b/packages/agent-os-vscode/README.md @@ -62,6 +62,50 @@ Real-time monitoring of agent activity: - **CI/CD Integration**: GitHub Actions, GitLab CI, Jenkins, Azure Pipelines - **Compliance Frameworks**: SOC 2, GDPR, HIPAA, PCI DSS templates +## What's New in v1.1.0 + +### Governance Visualization Hub +Unified dashboard for real-time governance monitoring: +- **SLO Dashboard** -- Availability, latency P50/P95/P99, policy compliance, trust scores with error budgets and burn rates +- **Agent Topology** -- Force-directed graph of agent mesh, trust rings, bridge status, delegation chains +- **Audit Stream** -- Filterable event log with drill-down +- **3-Slot Sidebar** -- Configurable panel system with 8 available views, panel picker for slot assignment +- **Scanning Mode** -- Auto-rotates visual focus through slots (4s cadence), pauses on hover/focus, respects prefers-reduced-motion +- **Priority Engine** -- Auto-reorders slots by health urgency in Auto mode (critical > warning > healthy) +- **Attention Toggle** -- Manual/Auto switch in sidebar header; manual locks to user config +- **Browser Experience** -- Open dashboard in external browser via local server + +### Server Security Hardening +The Governance Server that powers the browser dashboard includes defense-in-depth security controls: +- **Session token authentication** -- WebSocket connections require a cryptographically random token generated per server session. Connections without a valid token are rejected with close code 4001. +- **Rate limiting** -- HTTP requests are limited to 100 per minute per client IP. Excess requests receive HTTP 429 with `Retry-After` header. +- **Local asset bundling** -- D3.js and Chart.js vendored locally (no CDN dependency). Eliminates supply-chain risk from external script loading. +- **Content Security Policy (CSP)** -- Restricts script execution to nonce-only (`'nonce-...'`). No CDN allowlisting, no `unsafe-eval`. WebSocket connect-src explicitly scoped to `ws://127.0.0.1:*`. +- **HTML escaping** -- Shared `escapeHtml` utility applied to all dynamic data in innerHTML assignments across legacy panels. Prevents XSS from agent DIDs, policy names, and audit data. +- **Loopback-only binding** -- Server binds exclusively to `127.0.0.1`. Remote connections are structurally impossible. +- **Python path validation** -- Rejects shell metacharacters before subprocess spawn to prevent command injection. +- **Dependency pinning** -- Production dependencies (axios, ws) pinned to exact versions for reproducible builds. + +For the full security model, threat analysis, and accepted risks, see [SECURITY.md](SECURITY.md). + +### Live Governance Data +The extension automatically detects and starts [agent-failsafe](https://pypi.org/project/agent-failsafe/) to populate dashboards with real governance data: +- On first activation, the extension checks for `agent-failsafe` and offers to install it if missing (`pip install agent-failsafe[server]`) +- Once installed, a local REST server starts automatically on `127.0.0.1:9377` — no manual configuration required +- SLO dashboard, agent topology, and audit stream populate with live policy compliance, fleet health, and audit events +- Status bar shows connection state: Live (green), Stale (yellow), Disconnected (red) +- All REST responses validated with type checking, size caps, and string truncation +- Advanced: override with `agentOS.governance.endpoint` to connect to an existing server + +### Policy Diagnostics +- Real-time governance rule validation on Python/TypeScript/YAML files +- Code actions: safe alternatives for flagged patterns +- Status bar with governance mode and execution ring indicator + +### Report Export +- Export governance snapshot as self-contained HTML report +- Metrics exporter pushes dashboard data to configured observability endpoints + ## Quick Start 1. Install from VS Code Marketplace @@ -155,6 +199,15 @@ Share policies via `.vscode/agent-os.json`: | `Agent OS: Setup CI/CD Integration` | Generate CI/CD configuration | | `Agent OS: Check Compliance` | Run compliance validation | | `Agent OS: Sign In (Enterprise)` | Enterprise SSO authentication | +| `Agent OS: SLO Dashboard (Visual)` | Rich webview SLO dashboard | +| `Agent OS: Agent Topology Graph` | Force-directed agent topology graph | +| `Agent OS: Refresh SLO Data` | Refresh SLO metrics | +| `Agent OS: Refresh Agent Topology` | Refresh topology data | +| `Agent OS: Open Governance Hub` | Unified governance dashboard | +| `Agent OS: Open SLO Dashboard in Browser` | SLO dashboard in external browser | +| `Agent OS: Open Topology Graph in Browser` | Topology graph in external browser | +| `Agent OS: Open Governance Hub in Browser` | Governance Hub in external browser | +| `Agent OS: Export Governance Report` | Export HTML governance report | ## Configuration @@ -170,6 +223,12 @@ Open Settings (Ctrl+,) and search for "Agent OS": | `agentOS.diagnostics.enabled` | true | Real-time diagnostics | | `agentOS.enterprise.sso.enabled` | false | Enterprise SSO | | `agentOS.enterprise.compliance.framework` | - | Default compliance framework | +| `agentOS.export.localPath` | "" | Local directory for exported reports | +| `agentOS.observability.endpoint` | "" | Metrics push endpoint (OTEL compatible) | +| `agentOS.diagnostics.severity` | "warning" | Minimum diagnostic severity | +| `agentOS.governance.pythonPath` | "python" | Python interpreter with agent-failsafe installed | +| `agentOS.governance.endpoint` | "" | Override: connect to existing agent-failsafe server (auto-start if empty) | +| `agentOS.governance.refreshIntervalMs` | 10000 | Polling interval for governance data (minimum 5000ms) | ## Pricing @@ -179,13 +238,17 @@ Open Settings (Ctrl+,) and search for "Agent OS": | **Pro** | $9/mo | Unlimited CMVK, 90-day audit, priority support | | **Enterprise** | Custom | Self-hosted, SSO, RBAC, compliance reports | -## Privacy +## Privacy and Security - **Local-first**: Policy checks run entirely in the extension - **No network**: Basic mode never sends code anywhere - **Opt-in CMVK**: You choose when to use cloud verification +- **Loopback server**: The browser dashboard server binds to `127.0.0.1` only and requires session token authentication +- **No telemetry**: The Governance Server does not send data to external endpoints unless you explicitly configure an observability endpoint - **Open source**: Inspect the code yourself +See [SECURITY.md](SECURITY.md) for the full server security model and threat analysis. + ## Requirements - VS Code 1.85.0 or later diff --git a/packages/agent-os-vscode/SECURITY.md b/packages/agent-os-vscode/SECURITY.md new file mode 100644 index 00000000..08dff0ab --- /dev/null +++ b/packages/agent-os-vscode/SECURITY.md @@ -0,0 +1,235 @@ +# Security Model: Agent OS VS Code Extension + +This document describes the security architecture of the Agent OS VS Code extension. It covers three security domains: the Governance Server (browser dashboard), the REST client (live data), and the subprocess lifecycle (agent-failsafe server management). + +For vulnerability reporting, see the [repository-level SECURITY.md](../../../../SECURITY.md). + +## Threat Model + +The extension operates in three security contexts: + +1. **Governance Server** — local HTTP/WebSocket server for the browser dashboard. Binds to `127.0.0.1` only. +2. **REST Client** — polls a local agent-failsafe REST server for live governance data. Connects to loopback only. +3. **Subprocess Manager** — spawns and manages the agent-failsafe server process. Runs `pip install` when the package is missing. + +**In scope:** Other local processes, malicious browser tabs, cross-origin attacks, compromised REST servers, malicious `.vscode/settings.json` in cloned repositories, supply chain risks from pip install. + +**Out of scope:** Remote network attacks (all bindings are loopback), physical access, compromised VS Code host process. + +### Attack Vectors Addressed + +| Vector | Mitigation | Source | +|--------|-----------|--------| +| Cross-origin WebSocket hijacking | Session token on WS upgrade | `GovernanceServer.ts:197-198` | +| Script tampering | Local vendor bundling (no CDN) | `assets/vendor/d3.v7.8.5.min.js`, `assets/vendor/chart.v4.4.1.umd.min.js` | +| XSS via dashboard content | CSP nonces + shared `escapeHtml` utility | `GovernanceServer.ts:176`, `utils/escapeHtml.ts` | +| Local DoS via request flooding | Rate limiting (100 req/min) | `serverHelpers.ts:99-112` | +| REST response memory exhaustion | 5MB cap + array size limits | `liveClient.ts:89`, `translators.ts:19-22` | +| Token exfiltration via redirect | `maxRedirects: 0` | `liveClient.ts:91` | +| Non-loopback endpoint in settings | `isLoopbackEndpoint()` validation | `liveClient.ts:60-63` | +| Malicious pip package name | Hardcoded `agent-failsafe[server]` | `sreServer.ts:71` | + +## Security Controls + +### 1. Loopback-Only Binding + +The Governance Server binds to `127.0.0.1`. The REST client validates endpoints against a loopback allowlist. Neither is configurable to bind remotely. + +**Why loopback, not `0.0.0.0` with authentication:** The session token is transmitted over plaintext HTTP. On a non-loopback interface, any device on the same network could intercept it via packet capture. Authentication alone cannot compensate for plaintext transport on a shared network. Binding to loopback eliminates the entire class of network-adjacent attacks. + +``` +Server binding: serverHelpers.ts:14 — DEFAULT_HOST = '127.0.0.1' +Client validation: liveClient.ts:48 — LOOPBACK_HOSTS = Set(['127.0.0.1', 'localhost', '::1', '[::1]']) +Client enforcement: liveClient.ts:60-63 — isLoopbackEndpoint() parses URL, checks hostname +Constructor guard: liveClient.ts:81 — throws if endpoint is not loopback +Factory guard: providerFactory.ts:67 — explicit endpoint checked before use +``` + +### 2. Session Token Authentication + +Each server start generates a 32-character hex token using `crypto.randomBytes(16)`. The token is embedded in the dashboard HTML and required on WebSocket upgrade. + +**Why this exists:** Without session tokens, any local process that discovers the port can open a WebSocket and receive governance data. The token acts as a capability: only the browser tab that received the HTML can authenticate. + +**Limitation:** The token is in the HTML response over plaintext HTTP. A process with loopback traffic access could intercept it. This is accepted for a localhost dev server — TLS would require certificate management with no meaningful security gain on loopback. + +``` +Generation: serverHelpers.ts:73-74 — randomBytes(16).toString('hex') +Assignment: GovernanceServer.ts:81 — this._sessionToken = generateSessionToken() +Validation: GovernanceServer.ts:197-198 — validateWebSocketToken + close(4001) +Embedding: browserScripts.ts:25 — token in WebSocket URL +``` + +### 3. Rate Limiting + +HTTP requests are limited to 100 per minute per client IP. Excess requests receive HTTP 429 with `Retry-After: 60`. State is cleared on `stop()`. + +**Why 100/min:** Normal dashboard polling is 6 req/min (every 10s). The cap provides headroom for page loads and reconnections while blocking flood attacks. + +``` +Implementation: serverHelpers.ts:99-112 — checkRateLimit() +Enforcement: GovernanceServer.ts:156-157 — 429 response +Cleanup: GovernanceServer.ts:94 — requestCounts.clear() on stop +``` + +### 4. Content Security Policy (CSP) + +Both the HTTP header and HTML meta tag enforce a restrictive CSP with per-request nonces: + +``` +default-src 'self'; +script-src 'nonce-{random}'; +style-src 'self' 'unsafe-inline'; +connect-src 'self' +``` + +**Why nonces instead of `'unsafe-inline'`:** Nonces allow only server-rendered scripts to execute. An XSS injection that adds a ` + + + +
+

Agent Governance Report

+ +
+
+

SLO Metrics

+

Agent Topology

+

Audit Trail

+
+
+

Agent Governance Toolkit - Microsoft

+
+ + + +`; + } + + private getStyles(): string { + return ` +:root { --primary: #0078d4; --success: #107c10; --warning: #ffb900; --error: #d13438; } +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.6; padding: 2rem; max-width: 1200px; margin: 0 auto; } +.report-header { border-bottom: 2px solid var(--primary); padding-bottom: 1rem; margin-bottom: 2rem; } +.report-header h1 { color: var(--primary); } +.metadata { display: flex; gap: 2rem; color: #666; font-size: 0.9rem; margin-top: 0.5rem; } +section { margin-bottom: 2rem; padding: 1.5rem; background: #f9f9f9; border-radius: 8px; } +section h2 { color: #333; margin-bottom: 1rem; } +.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } +.metric-card { background: white; padding: 1rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } +.metric-value { font-size: 2rem; font-weight: bold; } +.healthy { color: var(--success); } +.warning { color: var(--warning); } +.breached { color: var(--error); } +table { width: 100%; border-collapse: collapse; margin-top: 1rem; } +th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #ddd; } +th { background: #e8e8e8; } +.report-footer { text-align: center; color: #666; font-size: 0.8rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; } +@media print { body { padding: 0; } section { break-inside: avoid; } }`; + } + + private getRenderScript(): string { + return ` +(function() { + var esc = function(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); }; + const data = JSON.parse(document.getElementById('report-data').textContent); + renderSLO(data.sloSnapshot); + renderTopology(data.agents, data.bridges, data.delegations); + renderAudit(data.auditEvents); + + function renderSLO(slo) { + const section = document.getElementById('slo-section'); + const health = (v, t) => v >= t ? 'healthy' : v >= t * 0.9 ? 'warning' : 'breached'; + section.innerHTML += '
' + + metricCard('Availability', slo.availability.currentPercent.toFixed(2) + '%', health(slo.availability.currentPercent, slo.availability.targetPercent)) + + metricCard('P99 Latency', slo.latency.p99Ms + 'ms', health(slo.latency.targetMs, slo.latency.p99Ms)) + + metricCard('Compliance', slo.policyCompliance.compliancePercent.toFixed(1) + '%', health(slo.policyCompliance.compliancePercent, 95)) + + metricCard('Trust Score', slo.trustScore.meanScore, slo.trustScore.meanScore > 700 ? 'healthy' : slo.trustScore.meanScore > 400 ? 'warning' : 'breached') + + '
'; + } + + function metricCard(label, value, status) { + return '
' + esc(label) + '
' + esc(value) + '
'; + } + + function renderTopology(agents, bridges, delegations) { + const section = document.getElementById('topology-section'); + section.innerHTML += '

Agents (' + agents.length + ')

' + + agents.map(a => '').join('') + '
DIDTrustRing
' + esc(a.did) + '' + esc(a.trustScore) + 'Ring ' + esc(a.ring) + '
'; + section.innerHTML += '

Bridges

' + + bridges.map(b => '').join('') + '
ProtocolStatusPeers
' + esc(b.protocol) + '' + (b.connected ? 'Connected' : 'Disconnected') + '' + esc(b.peerCount) + '
'; + } + + function renderAudit(events) { + const section = document.getElementById('audit-section'); + section.innerHTML += '' + + events.map(e => '').join('') + '
TimestampTypeDetails
' + esc(e.timestamp) + '' + esc(e.type) + '' + esc(JSON.stringify(e.details)) + '
'; + } +})();`; + } +} diff --git a/packages/agent-os-vscode/src/export/StorageProvider.ts b/packages/agent-os-vscode/src/export/StorageProvider.ts new file mode 100644 index 00000000..b7569047 --- /dev/null +++ b/packages/agent-os-vscode/src/export/StorageProvider.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Storage provider interface for report uploads. + * + * Defines the contract for local, S3, and Azure Blob storage implementations. + */ + +/** Result of a successful upload operation. */ +export interface UploadResult { + /** Public or pre-signed URL to access the uploaded file. */ + url: string; + /** When the URL expires (for pre-signed URLs). */ + expiresAt: Date; +} + +/** + * Storage provider contract for uploading governance reports. + * + * All implementations must validate credentials on every upload (zero-trust). + */ +export interface StorageProvider { + /** + * Validate credentials before upload. + * + * @throws CredentialError if credentials are missing, invalid, or expired. + */ + validateCredentials(): Promise; + + /** + * Upload HTML content to storage. + * + * @param html - The HTML content to upload. + * @param filename - Desired filename for the upload. + * @returns Upload result with URL and expiration. + * @throws CredentialError if credentials fail validation. + */ + upload(html: string, filename: string): Promise; + + /** + * Configure provider-specific settings. + * + * @param settings - Key-value settings for the provider. + */ + configure(settings: Record): void; +} diff --git a/packages/agent-os-vscode/src/export/index.ts b/packages/agent-os-vscode/src/export/index.ts new file mode 100644 index 00000000..3a709664 --- /dev/null +++ b/packages/agent-os-vscode/src/export/index.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Export module barrel file. + * + * Re-exports all storage providers and report generation utilities. + */ + +export { CredentialError, CredentialProvider, CredentialReason } from './CredentialError'; +export { StorageProvider, UploadResult } from './StorageProvider'; +export { LocalStorageProvider } from './LocalStorageProvider'; +export { ReportGenerator, AuditEntry, TimeRange, ReportData } from './ReportGenerator'; diff --git a/packages/agent-os/extensions/vscode/src/extension.ts b/packages/agent-os-vscode/src/extension.ts similarity index 51% rename from packages/agent-os/extensions/vscode/src/extension.ts rename to packages/agent-os-vscode/src/extension.ts index 0b98ca1f..bd0d89de 100644 --- a/packages/agent-os/extensions/vscode/src/extension.ts +++ b/packages/agent-os-vscode/src/extension.ts @@ -10,6 +10,8 @@ */ import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; import * as crypto from 'crypto'; import { PolicyEngine } from './policyEngine'; import { CMVKClient } from './cmvkClient'; @@ -27,6 +29,39 @@ import { MetricsDashboardPanel } from './webviews/metricsDashboard/MetricsDashbo import { OnboardingPanel } from './webviews/onboarding/OnboardingPanel'; import { AgentOSCompletionProvider, AgentOSHoverProvider } from './language/completionProvider'; import { AgentOSDiagnosticProvider } from './language/diagnosticProvider'; +import { GovernanceDiagnosticProvider } from './language/governanceDiagnosticProvider'; + +// Governance Visualization (Issue #39) +import { GovernanceStatusBar } from './governanceStatusBar'; + +// Governance Webview Panels — React detail panels (D1 migration) +import { showSLODetail } from './webviews/sloDetail/SLODetailPanel'; +import { showTopologyDetail } from './webviews/topologyDetail/TopologyDetailPanel'; +import { showHubDetail } from './webviews/hubDetail/HubDetailPanel'; + +// 3-Slot Sidebar (Sidebar Redesign) +import { SidebarProvider } from './webviews/sidebar/SidebarProvider'; +import { GovernanceEventBus } from './webviews/sidebar/governanceEventBus'; +import { GovernanceStore } from './webviews/sidebar/GovernanceStore'; +import type { DataProviders } from './webviews/sidebar/dataAggregator'; + +// Governance Server (Issue #39 - Browser Experience) +import { GovernanceServer } from './server/GovernanceServer'; + +// Export & Observability (Issue #39 - Shareable Reports) +import { ReportGenerator, LocalStorageProvider, CredentialError } from './export'; +import { MetricsExporter } from './observability'; + +// Backend Services +import { createMockSLOBackend } from './mockBackend/MockSLOBackend'; +import { createMockTopologyBackend } from './mockBackend/MockTopologyBackend'; +import { createMockPolicyBackend } from './mockBackend/MockPolicyBackend'; +import { PolicyDataProvider } from './views/policyTypes'; +import { SLODataProvider } from './views/sloTypes'; +import { AgentTopologyDataProvider } from './views/topologyTypes'; + +// Provider Factory +import { createProviders, ProviderConfig, Providers } from './services/providerFactory'; // Enterprise Features import { EnterpriseAuthProvider } from './enterprise/auth/ssoProvider'; @@ -43,39 +78,122 @@ let rbacManager: RBACManager; let cicdIntegration: CICDIntegration; let complianceManager: ComplianceManager; let diagnosticProvider: AgentOSDiagnosticProvider; +let governanceDiagnosticProvider: GovernanceDiagnosticProvider; +let governanceStatusBar: GovernanceStatusBar; +let governanceServer: GovernanceServer | undefined; +let sidebarProvider: SidebarProvider | undefined; +let activeProviders: Providers | undefined; -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext) { console.log('Agent OS extension activating...'); - // Initialize core components - policyEngine = new PolicyEngine(); - cmvkClient = new CMVKClient(); - auditLogger = new AuditLogger(context); - statusBar = new StatusBarManager(); - - // Initialize enterprise components - authProvider = new EnterpriseAuthProvider(context); - rbacManager = new RBACManager(authProvider); - cicdIntegration = new CICDIntegration(); - complianceManager = new ComplianceManager(); - diagnosticProvider = new AgentOSDiagnosticProvider(); - - // Log RBAC initialization - console.log(`RBAC initialized with ${rbacManager.getAllRoles().length} roles`); - - // Create tree data providers - const auditLogProvider = new AuditLogProvider(auditLogger); - const policiesProvider = new PoliciesProvider(policyEngine); - const statsProvider = new StatsProvider(auditLogger); - const kernelDebuggerProvider = new KernelDebuggerProvider(); - const memoryBrowserProvider = new MemoryBrowserProvider(); - - // Register tree views - vscode.window.registerTreeDataProvider('agent-os.auditLog', auditLogProvider); - vscode.window.registerTreeDataProvider('agent-os.policies', policiesProvider); - vscode.window.registerTreeDataProvider('agent-os.stats', statsProvider); - vscode.window.registerTreeDataProvider('agent-os.kernelDebugger', kernelDebuggerProvider); - vscode.window.registerTreeDataProvider('agent-os.memoryBrowser', memoryBrowserProvider); + try { + // Initialize core components + console.log('Initializing core components...'); + policyEngine = new PolicyEngine(); + cmvkClient = new CMVKClient(); + auditLogger = new AuditLogger(context); + statusBar = new StatusBarManager(); + + // Initialize enterprise components + console.log('Initializing enterprise components...'); + authProvider = new EnterpriseAuthProvider(context); + rbacManager = new RBACManager(authProvider); + cicdIntegration = new CICDIntegration(); + complianceManager = new ComplianceManager(); + diagnosticProvider = new AgentOSDiagnosticProvider(); + governanceDiagnosticProvider = new GovernanceDiagnosticProvider(); + governanceStatusBar = new GovernanceStatusBar(); + + // Log RBAC initialization + console.log(`RBAC initialized with ${rbacManager.getAllRoles().length} roles`); + + // Create tree data providers + console.log('Creating tree data providers...'); + const auditLogProvider = new AuditLogProvider(auditLogger); + const policiesProvider = new PoliciesProvider(policyEngine); + const statsProvider = new StatsProvider(auditLogger); + const kernelDebuggerProvider = new KernelDebuggerProvider(); + context.subscriptions.push(kernelDebuggerProvider); + const memoryBrowserProvider = new MemoryBrowserProvider(); + + // Tree data providers are kept as data sources but no longer registered as views. + // The new SidebarProvider aggregates their data into a single webview. + + // Register governance visualization (Issue #39) + const govConfig = vscode.workspace.getConfiguration('agentOS.governance'); + const providerConfig: ProviderConfig = { + pythonPath: govConfig.get('pythonPath', 'python'), + endpoint: govConfig.get('endpoint', ''), + refreshIntervalMs: govConfig.get('refreshIntervalMs', 10000), + }; + + // Register 3-slot sidebar SYNCHRONOUSLY with empty providers so the + // webview view provider is available immediately — before the async + // createProviders() call which can block or throw. + const governanceEventBus = new GovernanceEventBus(); + const emptyProviders: DataProviders = { + slo: { getSnapshot: async () => ({ availability: { currentPercent: 0, targetPercent: 0, errorBudgetRemainingPercent: 0, burnRate: 0 }, latency: { p50Ms: 0, p95Ms: 0, p99Ms: 0, targetMs: 0, errorBudgetRemainingPercent: 0 }, policyCompliance: { totalEvaluations: 0, violationsToday: 0, compliancePercent: 0, trend: 'stable' as const }, trustScore: { meanScore: 0, minScore: 0, agentsBelowThreshold: 0, distribution: [0, 0, 0, 0] } }) }, + topology: { getAgents: () => [], getBridges: () => [], getDelegations: () => [] }, + audit: auditLogger, + policy: { getSnapshot: async () => ({ rules: [], recentViolations: [], totalEvaluationsToday: 0, totalViolationsToday: 0 }) }, + kernel: kernelDebuggerProvider, + memory: memoryBrowserProvider, + }; + const governanceStore = new GovernanceStore( + emptyProviders, + governanceEventBus, + context.workspaceState, + providerConfig.refreshIntervalMs ?? 10000, + undefined, // thresholdMs + undefined, // liveClient — wired after createProviders resolves + auditLogger, + ); + sidebarProvider = new SidebarProvider( + context.extensionUri, + context, + governanceStore, + ); + context.subscriptions.push(governanceStore); + context.subscriptions.push(governanceEventBus); + context.subscriptions.push(auditLogger); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + SidebarProvider.viewType, + sidebarProvider, + ) + ); + + // Async: connect live providers in background (non-blocking) + createProviders(providerConfig).then((providers) => { + activeProviders = providers; + context.subscriptions.push(providers); + governanceStore.upgradeProviders( + { + slo: providers.slo, topology: providers.topology, + audit: auditLogger, policy: providers.policy, + kernel: kernelDebuggerProvider, memory: memoryBrowserProvider, + }, + providers.liveClient ?? undefined, + auditLogger, + ); + }).catch((err) => { + console.warn('Agent OS: live providers unavailable, using disconnected mode:', err); + }); + + // Provider proxies — commands read from activeProviders when available, + // falling back to empty defaults. This avoids blocking activation. + const sloDataProvider: SLODataProvider = { + getSnapshot: () => (activeProviders?.slo ?? emptyProviders.slo).getSnapshot(), + }; + const agentTopologyDataProvider: AgentTopologyDataProvider = { + getAgents: () => (activeProviders?.topology ?? emptyProviders.topology).getAgents(), + getBridges: () => (activeProviders?.topology ?? emptyProviders.topology).getBridges(), + getDelegations: () => (activeProviders?.topology ?? emptyProviders.topology).getDelegations(), + }; + const policyDataProvider: PolicyDataProvider = { + getSnapshot: () => (activeProviders?.policy ?? emptyProviders.policy).getSnapshot(), + }; // Register completion and hover providers for IntelliSense const completionProvider = new AgentOSCompletionProvider(); @@ -104,8 +222,18 @@ export function activate(context: vscode.ExtensionContext) { ) ); - // Activate diagnostic provider + // Activate diagnostic providers diagnosticProvider.activate(context); + governanceDiagnosticProvider.activate(context); + + // Initialize governance status bar with defaults + const mode = vscode.workspace.getConfiguration('agentOS').get('mode', 'basic'); + const GOVERNANCE_LEVEL_MAP: Record = { + enterprise: 'strict', + enhanced: 'permissive', + }; + const governanceLevel = GOVERNANCE_LEVEL_MAP[mode] ?? 'audit-only'; + governanceStatusBar.updateGovernanceMode(governanceLevel, 0); // Register inline completion interceptor const completionInterceptor = vscode.languages.registerInlineCompletionItemProvider( @@ -285,6 +413,225 @@ safe_query = "SELECT * FROM users WHERE id = ?" vscode.env.openExternal(vscode.Uri.parse('https://github.com/microsoft/agent-governance-toolkit')); }); + // ======================================== + // Register Governance Visualization Commands (Issue #39) + // ======================================== + + const refreshSLOCmd = vscode.commands.registerCommand('agent-os.refreshSLO', () => { + governanceStore.refreshNow(); + }); + + const refreshTopologyCmd = vscode.commands.registerCommand('agent-os.refreshTopology', () => { + governanceStore.refreshNow(); + }); + + // Governance Webview Panels — React detail panels (D1 migration) + const showSLOWebviewCmd = vscode.commands.registerCommand('agent-os.showSLOWebview', () => { + showSLODetail(context.extensionUri, governanceStore); + }); + + const showTopologyGraphCmd = vscode.commands.registerCommand('agent-os.showTopologyGraph', () => { + showTopologyDetail(context.extensionUri, governanceStore); + }); + + const showGovernanceHubCmd = vscode.commands.registerCommand('agent-os.showGovernanceHub', () => { + showHubDetail(context.extensionUri, governanceStore); + }); + + // Agent Drill-Down Command (Dashboard Feature Completeness - Phase 2) + const showAgentDetailsCmd = vscode.commands.registerCommand( + 'agent-os.showAgentDetails', + async (did: string) => { + const agents = agentTopologyDataProvider.getAgents(); + const agent = agents.find(a => a.did === did); + if (!agent) { + vscode.window.showWarningMessage(`Agent not found: ${did}`); + return; + } + + const ringLabels: Record = { + 0: 'Ring 0 (Root)', + 1: 'Ring 1 (Trusted)', + 2: 'Ring 2 (Standard)', + 3: 'Ring 3 (Sandbox)', + }; + + const items: vscode.QuickPickItem[] = [ + { label: '$(key) DID', description: agent.did }, + { label: '$(shield) Trust Score', description: `${agent.trustScore} / 1000` }, + { label: '$(layers) Execution Ring', description: ringLabels[agent.ring] || `Ring ${agent.ring}` }, + { label: '$(clock) Registered', description: agent.registeredAt || 'Unknown' }, + { label: '$(pulse) Last Activity', description: agent.lastActivity || 'Unknown' }, + { label: '$(tools) Capabilities', description: agent.capabilities?.join(', ') || 'None' }, + ]; + + await vscode.window.showQuickPick(items, { + title: `Agent: ${did.slice(0, 24)}...`, + placeHolder: 'Agent details', + }); + } + ); + + // Audit CSV Export Command (Dashboard Feature Completeness - Phase 3) + const exportAuditCSVCmd = vscode.commands.registerCommand( + 'agent-os.exportAuditCSV', + async () => { + const entries = auditLogger.getAll(); + if (entries.length === 0) { + vscode.window.showInformationMessage('No audit entries to export'); + return; + } + + const csv = [ + 'Timestamp,Type,File,Language,Violation,Reason', + ...entries.map(e => { + const entry = e as { timestamp: Date; type: string; file?: string; language?: string; violation?: string; reason?: string }; + return [ + entry.timestamp.toISOString(), + entry.type, + entry.file || '', + entry.language || '', + (entry.violation || '').replace(/,/g, ';'), + (entry.reason || '').replace(/,/g, ';'), + ].join(','); + }) + ].join('\n'); + + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(`audit-log-${Date.now()}.csv`), + filters: { 'CSV': ['csv'] }, + }); + + if (uri) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(csv, 'utf-8')); + vscode.window.showInformationMessage(`Exported ${entries.length} entries to ${uri.fsPath}`); + } + } + ); + + // Browser Experience Commands (Issue #39 - Local Dev Server) + const openGovernanceInBrowserCmd = vscode.commands.registerCommand( + 'agent-os.openGovernanceInBrowser', + async () => { + governanceServer = GovernanceServer.getInstance( + sloDataProvider, + agentTopologyDataProvider, + auditLogger + ); + const port = await governanceServer.start(); + const url = `http://localhost:${port}`; + vscode.env.openExternal(vscode.Uri.parse(url)); + } + ); + + const openSLOInBrowserCmd = vscode.commands.registerCommand( + 'agent-os.openSLOInBrowser', + async () => { + governanceServer = GovernanceServer.getInstance( + sloDataProvider, + agentTopologyDataProvider, + auditLogger + ); + const port = await governanceServer.start(); + const url = `http://localhost:${port}/#slo`; + vscode.env.openExternal(vscode.Uri.parse(url)); + } + ); + + const openTopologyInBrowserCmd = vscode.commands.registerCommand( + 'agent-os.openTopologyInBrowser', + async () => { + governanceServer = GovernanceServer.getInstance( + sloDataProvider, + agentTopologyDataProvider, + auditLogger + ); + const port = await governanceServer.start(); + const url = `http://localhost:${port}/#topology`; + vscode.env.openExternal(vscode.Uri.parse(url)); + } + ); + + // Export Report Command (Issue #39 - Shareable Reports) + const exportReportCmd = vscode.commands.registerCommand( + 'agent-os.exportReport', + async () => { + const config = vscode.workspace.getConfiguration('agentOS.export'); + const localPath = config.get('localPath', ''); + const outputDir = localPath || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + const provider = new LocalStorageProvider(outputDir); + + // Zero-trust: validate on every export + try { + await provider.validateCredentials(); + } catch (e) { + if (e instanceof CredentialError) { + const action = await vscode.window.showErrorMessage( + `Storage credentials ${e.reason}: ${e.message}`, + 'Configure' + ); + if (action === 'Configure') { + vscode.commands.executeCommand( + 'workbench.action.openSettings', + `agentOS.export.${e.provider}` + ); + } + return; + } + throw e; + } + + // Generate report + const reportGenerator = new ReportGenerator(); + const sloSnapshot = await sloDataProvider.getSnapshot(); + const agents = agentTopologyDataProvider.getAgents(); + const bridges = agentTopologyDataProvider.getBridges(); + const delegations = agentTopologyDataProvider.getDelegations(); + const auditEntries = auditLogger.getAll().map(e => ({ + timestamp: new Date(), + type: 'audit', + details: e as unknown as Record + })); + + const report = reportGenerator.generate({ + sloSnapshot, + agents, + bridges, + delegations, + auditEvents: auditEntries, + timeRange: { start: new Date(Date.now() - 86400000), end: new Date() } + }); + + const result = await provider.upload( + report, + `governance-report-${Date.now()}.html` + ); + + const action = await vscode.window.showInformationMessage( + `Report saved: ${result.url}`, + 'Open' + ); + if (action === 'Open') { + vscode.env.openExternal(vscode.Uri.parse(result.url)); + } + } + ); + + // ======================================== + // Help Command + // ======================================== + + const showHelpCmd = vscode.commands.registerCommand('agent-os.showHelp', () => { + const panel = vscode.window.createWebviewPanel( + 'agent-os.help', 'Agent OS Help', vscode.ViewColumn.Beside, + { enableScripts: false, localResourceRoots: [] }, + ); + const helpPath = path.join(context.extensionPath, 'HELP.md'); + let content = ''; + try { content = fs.readFileSync(helpPath, 'utf8'); } catch { content = '# Help\n\nHelp file not found.'; } + panel.webview.html = renderMarkdownHtml(content); + }); + // ======================================== // Register Enterprise Commands // ======================================== @@ -343,6 +690,23 @@ safe_query = "SELECT * FROM users WHERE id = ?" createFirstAgentCmd, runSafetyTestCmd, openDocsCmd, + // Governance Visualization + showSLOWebviewCmd, + showTopologyGraphCmd, + refreshSLOCmd, + refreshTopologyCmd, + sidebarProvider!, + governanceStatusBar, + // Governance Hub & Browser Experience + showGovernanceHubCmd, + showAgentDetailsCmd, + exportAuditCSVCmd, + openGovernanceInBrowserCmd, + openSLOInBrowserCmd, + openTopologyInBrowserCmd, + exportReportCmd, + // Help + showHelpCmd, // Enterprise Features signInCmd, signOutCmd, @@ -368,10 +732,24 @@ safe_query = "SELECT * FROM users WHERE id = ?" } console.log('Agent OS extension activated - GA Release v1.0.0'); + } catch (error) { + console.error('Agent OS extension activation failed:', error); + vscode.window.showErrorMessage(`Agent OS failed to activate: ${error}`); + } } -export function deactivate() { - console.log('Agent OS extension deactivated'); +export async function deactivate() { + // Clean up governance server if running + if (governanceServer) { + await governanceServer.stop(); + governanceServer = undefined; + } + + // Clean up sidebar provider + if (sidebarProvider) { + sidebarProvider.dispose(); + sidebarProvider = undefined; + } } // Helper functions @@ -505,7 +883,8 @@ async function reviewCodeWithCMVK(code: string, language: string): Promise function generateCMVKResultsHTML(result: any, webview: vscode.Webview): string { const nonce = crypto.randomBytes(16).toString('base64'); const cspSource = webview.cspSource; - const consensusColor = result.consensus >= 0.8 ? '#28a745' + + const consensusColor = result.consensus >= 0.8 ? '#28a745' : result.consensus >= 0.5 ? '#ffc107' : '#dc3545'; @@ -627,6 +1006,58 @@ async function exportAuditLog(): Promise { } } +/** Escape HTML entities for safe rendering. */ +function escHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); } + +/** Apply inline Markdown formatting (bold, code). */ +function inlineMdFormat(s: string): string { + let out = escHtml(s); + out = out.replace(/\*\*(.+?)\*\*/g, '$1'); + return out.replace(/`(.+?)`/g, '$1'); +} + +/** CSS for the help panel webview. */ +const HELP_CSS = [ + 'body{font-family:var(--vscode-font-family);color:var(--vscode-foreground);background:var(--vscode-editor-background);padding:20px;line-height:1.6}', + 'h1{font-size:1.4em;border-bottom:1px solid var(--vscode-panel-border);padding-bottom:8px}', + 'h2{font-size:1.2em;margin-top:24px} h3{font-size:1.05em;margin-top:16px}', + 'table{width:100%;border-collapse:collapse;margin:12px 0}', + 'th,td{padding:6px 10px;border:1px solid var(--vscode-panel-border);text-align:left}', + 'th{background:var(--vscode-sideBar-background)}', + 'code{background:var(--vscode-textCodeBlock-background);padding:2px 4px;border-radius:3px;font-family:var(--vscode-editor-font-family)}', + 'ul{padding-left:20px;margin:8px 0} li{margin:4px 0}', +].join('\n'); + +/** Convert Markdown text to a simple themed HTML document. */ +function renderMarkdownHtml(md: string): string { + const out: string[] = []; + let inList = false, inTable = false; + function close(): void { + if (inList) { out.push(''); inList = false; } + if (inTable) { out.push(''); inTable = false; } + } + for (const line of md.split('\n')) { + const t = line.trim(); + if (t.startsWith('### ')) { close(); out.push(`

${escHtml(t.slice(4))}

`); } + else if (t.startsWith('## ')) { close(); out.push(`

${escHtml(t.slice(3))}

`); } + else if (t.startsWith('# ')) { close(); out.push(`

${escHtml(t.slice(2))}

`); } + else if (t.startsWith('| ')) { + const cells = t.split('|').filter(c => c.trim() !== ''); + if (!cells.every(c => /^[\s-:]+$/.test(c))) { + const tag = !inTable ? 'th' : 'td'; + if (!inTable) { out.push(''); inTable = true; } + out.push('' + cells.map(c => `<${tag}>${inlineMdFormat(c.trim())}`).join('') + ''); + } + } else if (t.startsWith('- ')) { + if (!inList) { out.push('
    '); inList = true; } + out.push(`
  • ${inlineMdFormat(t.slice(2))}
  • `); + } else if (t === '') { close(); } + else { close(); out.push(`

    ${inlineMdFormat(t)}

    `); } + } + close(); + return `${out.join('\n')}`; +} + function showWelcomeMessage(): void { vscode.window.showInformationMessage( 'Welcome to Agent OS! Your AI coding assistant is now protected.', diff --git a/packages/agent-os-vscode/src/governanceStatusBar.ts b/packages/agent-os-vscode/src/governanceStatusBar.ts new file mode 100644 index 00000000..5f4eed85 --- /dev/null +++ b/packages/agent-os-vscode/src/governanceStatusBar.ts @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Governance Status Bar Manager for Agent OS VS Code Extension + * + * Provides a rich, multi-indicator governance status bar showing policy + * enforcement mode, SLO health, active agent count, and violation tracking. + */ + +import * as vscode from 'vscode'; +import { + GovernanceMode, + SLOBreakdown, + AgentInfo, + ViolationBreakdown, + MODE_LABELS, + buildModeTooltip, + buildViolationTooltip, + truncateAgentDid, +} from './governanceStatusBarTypes'; + +/** + * Enhanced governance-aware status bar with multiple indicators. + * + * Displays four right-aligned status bar items: + * 1. Governance Mode (priority 96) + * 2. SLO Health (priority 95) + * 3. Active Agents (priority 94) + * 4. Violations Counter (priority 93) + */ +class GovernanceStatusBar implements vscode.Disposable { + private readonly modeItem: vscode.StatusBarItem; + private readonly sloItem: vscode.StatusBarItem; + private readonly agentItem: vscode.StatusBarItem; + private readonly violationItem: vscode.StatusBarItem; + + private violationCount: number = 0; + private violationBreakdown: ViolationBreakdown = { errors: 0, warnings: 0 }; + private lastPolicyReload: Date = new Date(); + private currentMode: GovernanceMode = 'strict'; + + constructor() { + this.modeItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 96); + this.modeItem.command = 'agent-os.configurePolicy'; + this.modeItem.text = `$(shield) Strict`; + this.modeItem.tooltip = buildModeTooltip('strict', 0, this.lastPolicyReload); + this.modeItem.show(); + + this.sloItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 95); + this.sloItem.command = 'agent-os.showMetrics'; + + this.agentItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 94); + this.agentItem.command = 'agent-os.showTopologyGraph'; + this.agentItem.text = `$(organization) 0 agents`; + this.agentItem.tooltip = 'No agents registered'; + this.agentItem.show(); + + this.violationItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 93); + this.violationItem.command = 'agent-os.showAuditLog'; + this.violationItem.text = `$(check) Clean`; + this.violationItem.tooltip = 'No violations today'; + this.violationItem.show(); + } + + /** + * Update the governance mode indicator. + * + * @param mode - The active policy enforcement mode. + * @param activePolicies - Number of currently loaded policy documents. + */ + updateGovernanceMode(mode: GovernanceMode, activePolicies: number): void { + this.currentMode = mode; + this.lastPolicyReload = new Date(); + this.modeItem.text = `$(shield) ${MODE_LABELS[mode]}`; + this.modeItem.tooltip = buildModeTooltip(mode, activePolicies, this.lastPolicyReload); + const colors: Record = { + strict: 'statusBarItem.prominentBackground', + permissive: 'statusBarItem.warningBackground', + 'audit-only': 'statusBarItem.errorBackground', + }; + this.modeItem.backgroundColor = new vscode.ThemeColor(colors[mode]); + } + + /** + * Update the SLO health indicator. + * + * The item is shown on first call and remains visible thereafter. + * + * @param overall - Aggregate SLO percentage (0-100). + * @param breakdown - Per-dimension SLO values. + */ + updateSLOHealth(overall: number, breakdown: SLOBreakdown): void { + const pct = overall.toFixed(1); + const icon = overall < 99.5 ? 'warning' : 'pulse'; + this.sloItem.text = `$(${icon}) SLO: ${pct}%`; + + if (overall >= 99.5) { + this.sloItem.color = new vscode.ThemeColor('testing.iconPassed'); + this.sloItem.backgroundColor = undefined; + } else if (overall >= 99.0) { + this.sloItem.color = new vscode.ThemeColor('editorWarning.foreground'); + this.sloItem.backgroundColor = undefined; + } else { + this.sloItem.color = undefined; + this.sloItem.backgroundColor = new vscode.ThemeColor( + 'statusBarItem.errorBackground', + ); + } + + const lines = [ + `Aggregate SLO: ${pct}%`, + '', + ` Availability: ${breakdown.availability.toFixed(1)}%`, + ` Latency: ${breakdown.latency.toFixed(1)}%`, + ` Compliance: ${breakdown.compliance.toFixed(1)}%`, + '', + 'Click to view metrics dashboard', + ]; + this.sloItem.tooltip = lines.join('\n'); + this.sloItem.show(); + } + + /** + * Update the active agent count indicator. + * + * @param count - Number of currently registered agents. + * @param agents - Optional list of agent DIDs and trust scores. + */ + updateAgentCount(count: number, agents?: AgentInfo[]): void { + const label = count === 1 ? 'agent' : 'agents'; + this.agentItem.text = `$(organization) ${count} ${label}`; + + const lines: string[] = [`${count} registered ${label}`]; + + if (agents && agents.length > 0) { + lines.push(''); + const display = agents.slice(0, 10); + for (const agent of display) { + const truncatedDid = truncateAgentDid(agent.did); + lines.push(` ${truncatedDid} trust: ${agent.trust}`); + } + if (agents.length > 10) { + lines.push(` ... and ${agents.length - 10} more`); + } + + const avgTrust = + agents.reduce((sum, a) => sum + a.trust, 0) / agents.length; + lines.push(''); + lines.push(`Overall trust: ${avgTrust.toFixed(0)}/1000`); + } + + lines.push(''); + lines.push('Click to view agent topology'); + this.agentItem.tooltip = lines.join('\n'); + } + + /** + * Update the violation counter with a full breakdown. + * + * @param count - Total violations today. + * @param breakdown - Optional severity breakdown and last violation time. + */ + updateViolations(count: number, breakdown?: ViolationBreakdown): void { + this.violationCount = count; + this.violationBreakdown = breakdown ?? { errors: 0, warnings: 0 }; + + if (count === 0) { + this.violationItem.text = `$(check) Clean`; + this.violationItem.backgroundColor = undefined; + this.violationItem.color = new vscode.ThemeColor('testing.iconPassed'); + this.violationItem.tooltip = 'No violations today'; + } else { + const label = count === 1 ? 'violation' : 'violations'; + this.violationItem.text = `$(error) ${count} ${label}`; + this.violationItem.backgroundColor = new vscode.ThemeColor( + 'statusBarItem.warningBackground', + ); + this.violationItem.color = undefined; + this.violationItem.tooltip = buildViolationTooltip( + this.violationCount, + this.violationBreakdown, + ); + } + } + + /** + * Increment the violation counter by one. + * + * Updates the display immediately using the existing breakdown. + */ + incrementViolations(): void { + this.violationBreakdown.errors += 1; + this.violationBreakdown.lastViolation = new Date(); + this.updateViolations(this.violationCount + 1, this.violationBreakdown); + } + + /** + * Update the connection state indicator on the mode item. + * + * @param state - 'live' | 'stale' | 'disconnected' | 'no-endpoint' + * @param staleSecs - Seconds since last successful fetch (for stale state). + */ + updateConnectionState( + state: 'live' | 'stale' | 'disconnected' | 'no-endpoint', + staleSecs?: number, + ): void { + switch (state) { + case 'live': + this.modeItem.text = `$(shield) ${MODE_LABELS[this.currentMode]}`; + this.modeItem.color = new vscode.ThemeColor('testing.iconPassed'); + this.modeItem.backgroundColor = undefined; + break; + case 'stale': + this.modeItem.text = `$(plug) Stale ${staleSecs ?? '?'}s`; + this.modeItem.color = new vscode.ThemeColor('editorWarning.foreground'); + this.modeItem.backgroundColor = undefined; + break; + case 'disconnected': + this.modeItem.text = `$(plug) Disconnected`; + this.modeItem.color = undefined; + this.modeItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); + break; + case 'no-endpoint': + this.modeItem.text = `$(circle-slash) No endpoint`; + this.modeItem.color = new vscode.ThemeColor('disabledForeground'); + this.modeItem.backgroundColor = undefined; + break; + } + } + + /** Reset all violation state. Intended to be called at the start of each day. */ + resetDaily(): void { + this.updateViolations(0); + } + + /** Dispose all status bar items. */ + dispose(): void { + this.modeItem.dispose(); + this.sloItem.dispose(); + this.agentItem.dispose(); + this.violationItem.dispose(); + } +} + +export { GovernanceStatusBar }; diff --git a/packages/agent-os-vscode/src/governanceStatusBarTypes.ts b/packages/agent-os-vscode/src/governanceStatusBarTypes.ts new file mode 100644 index 00000000..6ce149ff --- /dev/null +++ b/packages/agent-os-vscode/src/governanceStatusBarTypes.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Types, constants, and pure helper functions for the Governance Status Bar. + * + * Extracted from governanceStatusBar.ts to keep each file under 250 lines. + */ + +/** Policy enforcement modes supported by the governance engine. */ +type GovernanceMode = 'strict' | 'permissive' | 'audit-only'; + +/** Per-dimension SLO breakdown. */ +interface SLOBreakdown { + availability: number; + latency: number; + compliance: number; +} + +/** Summary information for a registered agent. */ +interface AgentInfo { + did: string; + trust: number; +} + +/** Violation severity breakdown. */ +interface ViolationBreakdown { + errors: number; + warnings: number; + lastViolation?: Date; +} + +/** Human-readable labels for each governance mode. */ +const MODE_LABELS: Record = { + 'strict': 'Strict', + 'permissive': 'Permissive', + 'audit-only': 'Audit-Only', +}; + +/** Descriptive tooltips for each governance mode. */ +const MODE_DESCRIPTIONS: Record = { + 'strict': 'All policy violations are blocked immediately.', + 'permissive': 'Policy violations are logged but not blocked.', + 'audit-only': 'Actions are recorded for audit; no enforcement applied.', +}; + +/** + * Format a Date as a locale time string (HH:MM:SS). + * + * @param date - The date to format. + * @returns Formatted time string. + */ +function formatTime(date: Date): string { + return date.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +/** + * Truncate a DID string for compact display. + * + * Returns the full string when 24 characters or fewer, otherwise + * keeps the first 16 and last 6 characters separated by an ellipsis. + * + * @param did - The agent DID to truncate. + * @returns Truncated DID string. + */ +function truncateAgentDid(did: string): string { + if (did.length <= 24) { + return did; + } + return `${did.slice(0, 16)}...${did.slice(-6)}`; +} + +/** + * Build a tooltip string for the governance mode status bar item. + * + * @param mode - The active governance mode. + * @param activePolicies - Number of loaded policy documents. + * @param lastReload - Timestamp of the last policy reload. + * @returns Multi-line tooltip string. + */ +function buildModeTooltip( + mode: GovernanceMode, + activePolicies: number, + lastReload: Date, +): string { + const lines = [ + `Governance Mode: ${MODE_LABELS[mode]}`, + MODE_DESCRIPTIONS[mode], + '', + `Active policies: ${activePolicies}`, + `Last reload: ${formatTime(lastReload)}`, + '', + 'Click to configure policy', + ]; + return lines.join('\n'); +} + +/** + * Build a tooltip string for the violation counter status bar item. + * + * @param count - Total violation count for today. + * @param breakdown - Severity breakdown with optional last violation time. + * @returns Multi-line tooltip string. + */ +function buildViolationTooltip( + count: number, + breakdown: ViolationBreakdown, +): string { + const lines = [ + `${count} violation${count === 1 ? '' : 's'} today`, + '', + ` Errors: ${breakdown.errors}`, + ` Warnings: ${breakdown.warnings}`, + ]; + if (breakdown.lastViolation) { + lines.push(''); + lines.push(`Last violation: ${formatTime(breakdown.lastViolation)}`); + } + lines.push(''); + lines.push('Click to view audit log'); + return lines.join('\n'); +} + +export { + GovernanceMode, + SLOBreakdown, + AgentInfo, + ViolationBreakdown, + MODE_LABELS, + MODE_DESCRIPTIONS, + formatTime, + truncateAgentDid, + buildModeTooltip, + buildViolationTooltip, +}; diff --git a/packages/agent-os/extensions/vscode/src/language/completionProvider.ts b/packages/agent-os-vscode/src/language/completionProvider.ts similarity index 100% rename from packages/agent-os/extensions/vscode/src/language/completionProvider.ts rename to packages/agent-os-vscode/src/language/completionProvider.ts diff --git a/packages/agent-os/extensions/vscode/src/language/diagnosticProvider.ts b/packages/agent-os-vscode/src/language/diagnosticProvider.ts similarity index 100% rename from packages/agent-os/extensions/vscode/src/language/diagnosticProvider.ts rename to packages/agent-os-vscode/src/language/diagnosticProvider.ts diff --git a/packages/agent-os-vscode/src/language/governanceCodeActions.ts b/packages/agent-os-vscode/src/language/governanceCodeActions.ts new file mode 100644 index 00000000..7278a368 --- /dev/null +++ b/packages/agent-os-vscode/src/language/governanceCodeActions.ts @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Governance code action provider and quick-fix registry. + * + * Provides inline quick fixes for governance diagnostics (GOV0xx, GOV1xx) + * and language-appropriate suppression comments. + */ + +import * as vscode from 'vscode'; + +import { DIAGNOSTIC_SOURCE, TRUST_SCORE_MIN, TRUST_SCORE_MAX } from './governanceRules'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface GovernanceQuickFix { + title: string; + createEdit: ( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic + ) => vscode.WorkspaceEdit | undefined; +} + +// --------------------------------------------------------------------------- +// Quick-fix registry (keyed by diagnostic code) +// --------------------------------------------------------------------------- + +const QUICK_FIXES: Record = { + GOV001: { + title: 'Insert version field', + createEdit(document, _diagnostic) { + const edit = new vscode.WorkspaceEdit(); + edit.insert( + document.uri, + new vscode.Position(0, 0), + 'version: "1.0"\n', + ); + return edit; + }, + }, + GOV003: { + title: 'Clamp trust_threshold to valid range (0-1000)', + createEdit(document, diagnostic) { + const text = document.getText(diagnostic.range); + const numMatch = text.match(/(\d+)/); + if (!numMatch) { + return undefined; + } + const value = parseInt(numMatch[1], 10); + const clamped = Math.max(TRUST_SCORE_MIN, Math.min(TRUST_SCORE_MAX, value)); + const replacement = text.replace(numMatch[1], String(clamped)); + const edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, diagnostic.range, replacement); + return edit; + }, + }, + GOV005: { + title: 'Show valid execution ring values (0-3)', + createEdit(document, diagnostic) { + const edit = new vscode.WorkspaceEdit(); + const line = document.lineAt(diagnostic.range.start.line); + const comment = ' # Valid rings: 0 (ROOT), 1 (PRIVILEGED), 2 (USER), 3 (SANDBOX)'; + edit.insert( + document.uri, + new vscode.Position(line.lineNumber, line.text.length), + comment, + ); + return edit; + }, + }, + GOV103: { + title: 'Use policy-based ring assignment instead of hardcoded ring', + createEdit(document, diagnostic) { + const text = document.getText(diagnostic.range); + const edit = new vscode.WorkspaceEdit(); + edit.replace( + document.uri, + diagnostic.range, + `policy_engine.assign_ring(agent_did) # was: ${text}`, + ); + return edit; + }, + }, +}; + +// --------------------------------------------------------------------------- +// GovernanceCodeActionProvider +// --------------------------------------------------------------------------- + +export class GovernanceCodeActionProvider implements vscode.CodeActionProvider { + provideCodeActions( + document: vscode.TextDocument, + _range: vscode.Range | vscode.Selection, + context: vscode.CodeActionContext, + _token: vscode.CancellationToken, + ): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> { + const actions: vscode.CodeAction[] = []; + + for (const diagnostic of context.diagnostics) { + if (diagnostic.source !== DIAGNOSTIC_SOURCE) { + continue; + } + + const code = diagnostic.code as string; + this.addQuickFix(document, diagnostic, code, actions); + this.addSuppressAction(document, diagnostic, code, actions); + this.addLearnMoreAction(code, actions); + } + + return actions; + } + + private addQuickFix( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + code: string, + actions: vscode.CodeAction[], + ): void { + const quickFix = QUICK_FIXES[code]; + if (!quickFix) { + return; + } + const edit = quickFix.createEdit(document, diagnostic); + if (!edit) { + return; + } + const action = new vscode.CodeAction( + quickFix.title, + vscode.CodeActionKind.QuickFix, + ); + action.edit = edit; + action.diagnostics = [diagnostic]; + action.isPreferred = true; + actions.push(action); + } + + private addSuppressAction( + document: vscode.TextDocument, + diagnostic: vscode.Diagnostic, + code: string, + actions: vscode.CodeAction[], + ): void { + const suppressAction = new vscode.CodeAction( + `Suppress: ${code}`, + vscode.CodeActionKind.QuickFix, + ); + suppressAction.edit = new vscode.WorkspaceEdit(); + const line = document.lineAt(diagnostic.range.start.line); + const suppressComment = getSuppressComment(document.languageId, code); + suppressAction.edit.insert( + document.uri, + new vscode.Position(line.lineNumber, line.text.length), + suppressComment, + ); + suppressAction.diagnostics = [diagnostic]; + actions.push(suppressAction); + } + + private addLearnMoreAction( + code: string, + actions: vscode.CodeAction[], + ): void { + const learnMoreAction = new vscode.CodeAction( + `Learn more about ${code}`, + vscode.CodeActionKind.Empty, + ); + learnMoreAction.command = { + command: 'vscode.open', + title: 'Learn more', + arguments: [ + vscode.Uri.parse( + `https://agent-os.dev/docs/rules/${code}`, + ), + ], + }; + actions.push(learnMoreAction); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a language-appropriate inline suppression comment. + */ +export function getSuppressComment(languageId: string, code: string): string { + switch (languageId) { + case 'python': + return ` # noqa: ${code}`; + case 'yaml': + case 'json': + return ` # @agent-os-ignore ${code}`; + case 'javascript': + case 'typescript': + return ` // @agent-os-ignore ${code}`; + default: + return ` // @agent-os-ignore ${code}`; + } +} diff --git a/packages/agent-os-vscode/src/language/governanceDiagnosticProvider.ts b/packages/agent-os-vscode/src/language/governanceDiagnosticProvider.ts new file mode 100644 index 00000000..0b618e29 --- /dev/null +++ b/packages/agent-os-vscode/src/language/governanceDiagnosticProvider.ts @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Governance Diagnostic Provider for Agent OS + * + * Orchestrates real-time diagnostics for governance-specific policy violations + * by composing rules from governanceRules, governanceIntegrationRules, and + * code actions from governanceCodeActions. + */ + +import * as vscode from 'vscode'; + +import { + GovernanceDiagnosticRule, + DIAGNOSTIC_COLLECTION_NAME, + SUPPORTED_LANGUAGES, + isPolicyFile, + buildPolicyFileRules, +} from './governanceRules'; +import { buildPythonRules, buildCrossLanguageRules } from './governanceIntegrationRules'; +import { GovernanceCodeActionProvider } from './governanceCodeActions'; + +// --------------------------------------------------------------------------- +// GovernanceDiagnosticProvider +// --------------------------------------------------------------------------- + +export class GovernanceDiagnosticProvider { + private diagnosticCollection: vscode.DiagnosticCollection; + private disposables: vscode.Disposable[] = []; + + private readonly policyFileRules: GovernanceDiagnosticRule[]; + private readonly pythonRules: GovernanceDiagnosticRule[]; + private readonly crossLanguageRules: GovernanceDiagnosticRule[]; + + constructor() { + this.diagnosticCollection = vscode.languages.createDiagnosticCollection( + DIAGNOSTIC_COLLECTION_NAME, + ); + this.policyFileRules = buildPolicyFileRules(); + this.pythonRules = buildPythonRules(); + this.crossLanguageRules = buildCrossLanguageRules(); + } + + /** + * Activate the provider and subscribe to document lifecycle events. + */ + activate(context: vscode.ExtensionContext): void { + this.disposables.push( + vscode.workspace.onDidOpenTextDocument(doc => this.analyzeDocument(doc)), + ); + this.disposables.push( + vscode.workspace.onDidChangeTextDocument(event => + this.analyzeDocument(event.document), + ), + ); + this.disposables.push( + vscode.workspace.onDidSaveTextDocument(doc => this.analyzeDocument(doc)), + ); + + vscode.workspace.textDocuments.forEach(doc => this.analyzeDocument(doc)); + + this.disposables.push( + vscode.languages.registerCodeActionsProvider( + [ + { scheme: 'file', language: 'python' }, + { scheme: 'file', language: 'yaml' }, + { scheme: 'file', language: 'json' }, + { scheme: 'file', language: 'javascript' }, + { scheme: 'file', language: 'typescript' }, + ], + new GovernanceCodeActionProvider(), + { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }, + ), + ); + + context.subscriptions.push(this.diagnosticCollection, ...this.disposables); + } + + /** + * Analyze a single document and produce governance diagnostics. + */ + private analyzeDocument(document: vscode.TextDocument): void { + const config = vscode.workspace.getConfiguration('agentOS'); + if (!config.get('enabled', true)) { + this.diagnosticCollection.delete(document.uri); + return; + } + + if (!SUPPORTED_LANGUAGES.includes(document.languageId)) { + return; + } + + const text = document.getText(); + const diagnostics: vscode.Diagnostic[] = []; + + if (isPolicyFile(document)) { + for (const rule of this.policyFileRules) { + this.applyRule(document, text, rule, diagnostics); + } + } + + if (document.languageId === 'python') { + for (const rule of this.pythonRules) { + this.applyRule(document, text, rule, diagnostics); + } + } + + for (const rule of this.crossLanguageRules) { + this.applyRule(document, text, rule, diagnostics); + } + + this.diagnosticCollection.set(document.uri, diagnostics); + } + + /** + * Apply a single rule. Delegates to the rule's custom analyze function + * if present, otherwise falls back to simple regex matching. + */ + private applyRule( + document: vscode.TextDocument, + text: string, + rule: GovernanceDiagnosticRule, + diagnostics: vscode.Diagnostic[], + ): void { + if (rule.analyze) { + rule.analyze(document, text, diagnostics); + return; + } + if (rule.pattern) { + this.applyRegexRule(document, text, rule, diagnostics); + } + } + + /** + * Simple regex-based rule application. + */ + private applyRegexRule( + document: vscode.TextDocument, + text: string, + rule: GovernanceDiagnosticRule, + diagnostics: vscode.Diagnostic[], + ): void { + if (!rule.pattern) { + return; + } + rule.pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = rule.pattern.exec(text)) !== null) { + const startPos = document.positionAt(match.index); + const endPos = document.positionAt(match.index + match[0].length); + const range = new vscode.Range(startPos, endPos); + const diagnostic = new vscode.Diagnostic(range, rule.message, rule.severity); + diagnostic.code = rule.code; + diagnostic.source = 'Agent OS Governance'; + diagnostics.push(diagnostic); + } + } + + /** + * Dispose of all subscriptions and the diagnostic collection. + */ + dispose(): void { + this.diagnosticCollection.dispose(); + this.disposables.forEach(d => d.dispose()); + } +} + +export { GovernanceCodeActionProvider } from './governanceCodeActions'; diff --git a/packages/agent-os-vscode/src/language/governanceIntegrationRules.ts b/packages/agent-os-vscode/src/language/governanceIntegrationRules.ts new file mode 100644 index 00000000..a2bc23fd --- /dev/null +++ b/packages/agent-os-vscode/src/language/governanceIntegrationRules.ts @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Python integration rules (GOV1xx) and cross-language rules (GOV2xx) + * for the governance diagnostic provider. + */ + +import * as vscode from 'vscode'; + +import { GovernanceDiagnosticRule, DIAGNOSTIC_SOURCE } from './governanceRules'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Check whether a line near `lineNumber` contains the word "audit". */ +function hasNearbyAuditRef( + document: vscode.TextDocument, + lineNumber: number, + radius: number, +): boolean { + const start = Math.max(0, lineNumber - radius); + const end = Math.min(document.lineCount - 1, lineNumber + radius); + for (let i = start; i <= end; i++) { + if (/\baudit\b/i.test(document.lineAt(i).text)) { + return true; + } + } + return false; +} + +/** + * Check whether a line near `lineNumber` matches any of the given patterns. + * Returns true if at least one pattern is found within the search radius. + */ +function hasNearbyPattern( + document: vscode.TextDocument, + lineNumber: number, + radius: number, + patterns: RegExp[], +): boolean { + const start = Math.max(0, lineNumber - radius); + const end = Math.min(document.lineCount - 1, lineNumber + radius); + for (let i = start; i <= end; i++) { + const lineText = document.lineAt(i).text; + if (patterns.some(p => p.test(lineText))) { + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Python rules (GOV1xx) +// --------------------------------------------------------------------------- + +/** + * Build the list of Python-specific rules (GOV1xx). + */ +export function buildPythonRules(): GovernanceDiagnosticRule[] { + return [ + // GOV101 - ToolCallInterceptor without error handling + { + code: 'GOV101', + message: 'ToolCallInterceptor.intercept() called without error handling. Wrap in try/except to handle governance failures gracefully.', + severity: vscode.DiagnosticSeverity.Warning, + analyze(document, text, diagnostics) { + const pattern = /interceptor\.intercept\s*\(/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const lineNumber = document.positionAt(match.index).line; + const hasTryExcept = hasNearbyPattern( + document, lineNumber, 5, [/\b(try|except)\b/], + ); + if (hasTryExcept) { continue; } + const startPos = document.positionAt(match.index); + const endPos = document.positionAt(match.index + match[0].length); + const range = new vscode.Range(startPos, endPos); + const diag = new vscode.Diagnostic(range, this.message, this.severity); + diag.code = this.code; + diag.source = DIAGNOSTIC_SOURCE; + diagnostics.push(diag); + } + }, + }, + // GOV102 - KillSwitch without audit logging + { + code: 'GOV102', + message: 'KillSwitch invocation detected without audit logging. Always log kill switch activations for compliance.', + severity: vscode.DiagnosticSeverity.Warning, + analyze(document, text, diagnostics) { + const patterns = [ + /kill_switch\.activate\s*\(/g, + /KillSwitch\s*\(/g, + ]; + for (const pattern of patterns) { + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const lineNumber = document.positionAt(match.index).line; + if (!hasNearbyAuditRef(document, lineNumber, 10)) { + const startPos = document.positionAt(match.index); + const endPos = document.positionAt(match.index + match[0].length); + const range = new vscode.Range(startPos, endPos); + const diag = new vscode.Diagnostic(range, this.message, this.severity); + diag.code = this.code; + diag.source = DIAGNOSTIC_SOURCE; + diagnostics.push(diag); + } + } + } + }, + }, + // GOV103 - Hardcoded execution ring assignment + { + code: 'GOV103', + message: 'Hardcoded execution ring assignment. Use policy-based ring assignment for dynamic governance.', + severity: vscode.DiagnosticSeverity.Warning, + pattern: /ExecutionRing\.RING_[0-3](?:_ROOT|_PRIVILEGED|_USER|_SANDBOX)?/g, + }, + // GOV104 - Missing governance context in agent registration + { + code: 'GOV104', + message: 'Agent registration without governance context. Include "governance_context" or "policy" parameter for compliance tracking.', + severity: vscode.DiagnosticSeverity.Information, + analyze(document, text, diagnostics) { + const pattern = /register_agent\s*\([^)]*\)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const callText = match[0]; + if (!/governance_context|policy/i.test(callText)) { + const startPos = document.positionAt(match.index); + const endPos = document.positionAt(match.index + match[0].length); + const range = new vscode.Range(startPos, endPos); + const diag = new vscode.Diagnostic(range, this.message, this.severity); + diag.code = this.code; + diag.source = DIAGNOSTIC_SOURCE; + diagnostics.push(diag); + } + } + }, + }, + ]; +} + +// --------------------------------------------------------------------------- +// Cross-language rules (GOV2xx) +// --------------------------------------------------------------------------- + +/** + * Build the list of cross-language rules (GOV2xx). + */ +export function buildCrossLanguageRules(): GovernanceDiagnosticRule[] { + return [ + // GOV201 - Agent DID format violation + { + code: 'GOV201', + message: 'Malformed agent DID. Expected format: "did:mesh:" or "did:myth::".', + severity: vscode.DiagnosticSeverity.Error, + analyze(document, text, diagnostics) { + const didPattern = /["']?(did:(mesh|myth):[^"'\s,)}\]]*)/g; + let match: RegExpExecArray | null; + while ((match = didPattern.exec(text)) !== null) { + const did = match[1]; + const isValid = isValidAgentDID(did); + if (!isValid) { + const didStart = match.index + (match[0].startsWith('"') || match[0].startsWith("'") ? 1 : 0); + const startPos = document.positionAt(didStart); + const endPos = document.positionAt(didStart + did.length); + const range = new vscode.Range(startPos, endPos); + const diag = new vscode.Diagnostic( + range, + `${this.message} Found: "${did}".`, + this.severity, + ); + diag.code = this.code; + diag.source = DIAGNOSTIC_SOURCE; + diagnostics.push(diag); + } + } + }, + }, + // GOV202 - Trust score comparison with magic numbers + { + code: 'GOV202', + message: 'Trust score compared against a magic number. Define a named constant (e.g., MINIMUM_TRUST_THRESHOLD) for maintainability.', + severity: vscode.DiagnosticSeverity.Warning, + pattern: /trust(?:_score|Score|_level|Level)?\s*[<>=!]+\s*\d{2,}/gi, + }, + ]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Validate an agent DID string. + * + * Valid formats: + * did:mesh:<32+ hex chars> + * did:myth::<32 hex chars> + * + * Personas: scrivener, sentinel, judge, overseer (and future additions). + */ +export function isValidAgentDID(did: string): boolean { + // did:mesh: + if (/^did:mesh:[0-9a-fA-F]{32,}$/.test(did)) { + return true; + } + // did:myth:: + if (/^did:myth:[a-z]+:[0-9a-fA-F]{32}$/.test(did)) { + return true; + } + return false; +} diff --git a/packages/agent-os-vscode/src/language/governanceRules.ts b/packages/agent-os-vscode/src/language/governanceRules.ts new file mode 100644 index 00000000..2ec110e4 --- /dev/null +++ b/packages/agent-os-vscode/src/language/governanceRules.ts @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Governance diagnostic rule types, constants, and policy file rules (GOV0xx). + * + * Rule prefixes: + * GOV0xx - Policy file rules (YAML/JSON) + */ + +import * as vscode from 'vscode'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface GovernanceDiagnosticRule { + code: string; + message: string; + severity: vscode.DiagnosticSeverity; + /** When present the rule uses simple regex matching via applyRegexRule. */ + pattern?: RegExp; + /** When present the rule requires custom analysis logic. */ + analyze?: ( + document: vscode.TextDocument, + text: string, + diagnostics: vscode.Diagnostic[] + ) => void; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const DIAGNOSTIC_SOURCE = 'Agent OS Governance'; +export const DIAGNOSTIC_COLLECTION_NAME = 'agentOS.governance'; + +export const VALID_POLICY_ACTIONS = ['ALLOW', 'DENY', 'AUDIT', 'BLOCK']; +export const VALID_RING_VALUES = [0, 1, 2, 3]; +export const TRUST_SCORE_MIN = 0; +export const TRUST_SCORE_MAX = 1000; + +export const SUPPORTED_LANGUAGES = [ + 'javascript', 'typescript', 'python', 'yaml', 'json', + 'shellscript', 'bash', 'sh', +]; + +// --------------------------------------------------------------------------- +// Policy file rules (GOV0xx) +// --------------------------------------------------------------------------- + +/** + * Build the list of policy-file rules (GOV0xx). + * These only apply to YAML/JSON files whose name matches the policy pattern. + */ +export function buildPolicyFileRules(): GovernanceDiagnosticRule[] { + return [ + // GOV001 - Missing version field + { + code: 'GOV001', + message: 'Missing "version" field in policy document. Every policy document must declare a version.', + severity: vscode.DiagnosticSeverity.Error, + analyze(document, text, diagnostics) { + const hasVersion = /^\s*["']?version["']?\s*[:=]/m.test(text); + if (!hasVersion) { + const range = new vscode.Range( + new vscode.Position(0, 0), + new vscode.Position(0, Math.min(document.lineAt(0).text.length, 1)), + ); + const diag = new vscode.Diagnostic(range, this.message, this.severity); + diag.code = this.code; + diag.source = DIAGNOSTIC_SOURCE; + diagnostics.push(diag); + } + }, + }, + // GOV002 - Rule without action field + { + code: 'GOV002', + message: `Policy rule is missing an "action" field. Must be one of: ${VALID_POLICY_ACTIONS.join(', ')}.`, + severity: vscode.DiagnosticSeverity.Error, + analyze(document, text, diagnostics) { + const ruleBlockPattern = /^[ \t]*-\s*\n?((?:[ \t]+\S.*\n?)*)/gm; + let blockMatch: RegExpExecArray | null; + ruleBlockPattern.lastIndex = 0; + while ((blockMatch = ruleBlockPattern.exec(text)) !== null) { + if (!matchMissingActionBlock(blockMatch[0])) { continue; } + const startPos = document.positionAt(blockMatch.index); + const endLine = document.lineAt(startPos.line); + const range = new vscode.Range(startPos, endLine.range.end); + const diag = new vscode.Diagnostic(range, this.message, this.severity); + diag.code = this.code; + diag.source = DIAGNOSTIC_SOURCE; + diagnostics.push(diag); + } + }, + }, + // GOV003 - trust_threshold outside 0-1000 + { + code: 'GOV003', + message: `trust_threshold must be between ${TRUST_SCORE_MIN} and ${TRUST_SCORE_MAX}.`, + severity: vscode.DiagnosticSeverity.Error, + analyze(document, text, diagnostics) { + const pattern = /trust_threshold\s*:\s*(-?\d+)/gi; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const value = parseInt(match[1], 10); + if (value < TRUST_SCORE_MIN || value > TRUST_SCORE_MAX) { + const startPos = document.positionAt(match.index); + const endPos = document.positionAt(match.index + match[0].length); + const range = new vscode.Range(startPos, endPos); + const diag = new vscode.Diagnostic( + range, + `${this.message} Found: ${value}.`, + this.severity, + ); + diag.code = this.code; + diag.source = DIAGNOSTIC_SOURCE; + diagnostics.push(diag); + } + } + }, + }, + // GOV004 - DENY/BLOCK without escalation config + { + code: 'GOV004', + message: 'DENY/BLOCK rule without escalation configuration. Consider adding an "escalation" section for operational safety.', + severity: vscode.DiagnosticSeverity.Warning, + analyze(document, text, diagnostics) { + const actionPattern = /\baction\s*:\s*(DENY|BLOCK)\b/gi; + let match: RegExpExecArray | null; + while ((match = actionPattern.exec(text)) !== null) { + const contextStart = Math.max(0, match.index - 300); + const contextEnd = Math.min(text.length, match.index + match[0].length + 300); + const context = text.substring(contextStart, contextEnd); + if (!/\bescalation\s*:/i.test(context)) { + const startPos = document.positionAt(match.index); + const endPos = document.positionAt(match.index + match[0].length); + const range = new vscode.Range(startPos, endPos); + const diag = new vscode.Diagnostic(range, this.message, this.severity); + diag.code = this.code; + diag.source = DIAGNOSTIC_SOURCE; + diagnostics.push(diag); + } + } + }, + }, + // GOV005 - Invalid execution_ring value + { + code: 'GOV005', + message: `execution_ring must be one of: ${VALID_RING_VALUES.join(', ')}. Maps to RING_0_ROOT through RING_3_SANDBOX.`, + severity: vscode.DiagnosticSeverity.Error, + analyze(document, text, diagnostics) { + const pattern = /execution_ring\s*:\s*(\d+)/gi; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const value = parseInt(match[1], 10); + if (!VALID_RING_VALUES.includes(value)) { + const startPos = document.positionAt(match.index); + const endPos = document.positionAt(match.index + match[0].length); + const range = new vscode.Range(startPos, endPos); + const diag = new vscode.Diagnostic( + range, + `${this.message} Found: ${value}.`, + this.severity, + ); + diag.code = this.code; + diag.source = DIAGNOSTIC_SOURCE; + diagnostics.push(diag); + } + } + }, + }, + // GOV006 - No OWASP ASI coverage mapping + { + code: 'GOV006', + message: 'Policy document does not include an OWASP Agentic Security Initiative (ASI) coverage mapping. Consider adding an "owasp" or "coverage" section.', + severity: vscode.DiagnosticSeverity.Information, + analyze(document, text, diagnostics) { + const hasOwasp = /\b(owasp|asi_coverage|coverage_map|agentic_top_10)\s*:/i.test(text); + if (!hasOwasp) { + const lastLine = document.lineCount - 1; + const lastLineText = document.lineAt(lastLine).text; + const range = new vscode.Range( + new vscode.Position(lastLine, 0), + new vscode.Position(lastLine, Math.min(lastLineText.length, 1)), + ); + const diag = new vscode.Diagnostic(range, this.message, this.severity); + diag.code = this.code; + diag.source = DIAGNOSTIC_SOURCE; + diagnostics.push(diag); + } + }, + }, + ]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Match a YAML rule block that is missing an "action" field. + * Returns the match index if the block has a rule indicator but no action, + * or null if the block is valid. + */ +export function matchMissingActionBlock( + block: string, +): boolean { + const hasRuleIndicator = /\b(name|match|pattern|rule|when|condition)\s*:/i.test(block); + const hasAction = /\baction\s*:/i.test(block); + return hasRuleIndicator && !hasAction; +} + +/** + * Determine whether a document is a governance policy file + * based on its file name. + */ +export function isPolicyFile(document: vscode.TextDocument): boolean { + const fileName = document.fileName.toLowerCase(); + return ( + (document.languageId === 'yaml' || document.languageId === 'json') && + (fileName.includes('policy') || + fileName.includes('agent-os') || + fileName.includes('.agents')) + ); +} diff --git a/packages/agent-os-vscode/src/mockBackend/MockPolicyBackend.ts b/packages/agent-os-vscode/src/mockBackend/MockPolicyBackend.ts new file mode 100644 index 00000000..97ccd8a5 --- /dev/null +++ b/packages/agent-os-vscode/src/mockBackend/MockPolicyBackend.ts @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Mock Policy Backend + * + * Simulates policy data for development. + * Implements PolicyDataProvider interface for swappable backends. + */ + +import { + PolicyDataProvider, + PolicySnapshot, + PolicyRule, + PolicyViolation +} from '../views/policyTypes'; + +const MOCK_RULES: PolicyRule[] = [ + { + id: 'rule-001', + name: 'Block Secret Patterns', + description: 'Deny file writes containing API keys or secrets', + action: 'BLOCK', + pattern: '(?i)(api[_-]?key|secret|password)\\s*=', + scope: 'file', + enabled: true, + evaluationsToday: 342, + violationsToday: 2, + }, + { + id: 'rule-002', + name: 'Audit External URLs', + description: 'Log all HTTP requests to external domains', + action: 'AUDIT', + pattern: 'https?://(?!localhost)', + scope: 'tool', + enabled: true, + evaluationsToday: 156, + violationsToday: 0, + }, + { + id: 'rule-003', + name: 'Sandbox New Agents', + description: 'Force Ring 3 for agents with trust score < 400', + action: 'ALLOW', + pattern: 'trustScore < 400', + scope: 'agent', + enabled: true, + evaluationsToday: 28, + violationsToday: 0, + }, + { + id: 'rule-004', + name: 'Deny Shell Commands', + description: 'Block direct shell execution outside approved tools', + action: 'DENY', + pattern: 'exec|spawn|system|shell', + scope: 'tool', + enabled: true, + evaluationsToday: 89, + violationsToday: 1, + }, + { + id: 'rule-005', + name: 'Require CMVK for Destructive', + description: 'Require multi-model review for delete operations', + action: 'AUDIT', + pattern: 'delete|remove|drop|truncate', + scope: 'file', + enabled: false, + evaluationsToday: 0, + violationsToday: 0, + }, +]; + +/** Create mock violations based on current time. */ +function createMockViolations(): PolicyViolation[] { + return [ + { + id: 'viol-001', + ruleId: 'rule-001', + ruleName: 'Block Secret Patterns', + timestamp: new Date(Date.now() - 1800000), + file: 'src/config.ts', + line: 42, + context: 'API_KEY = "sk-..."', + action: 'BLOCK', + }, + { + id: 'viol-002', + ruleId: 'rule-004', + ruleName: 'Deny Shell Commands', + timestamp: new Date(Date.now() - 3600000), + agentDid: 'did:mesh:f1e2d3c4b5a6...', + context: 'exec("rm -rf /")', + action: 'DENY', + }, + ]; +} + +/** Create a mock policy data provider. */ +export function createMockPolicyBackend(): PolicyDataProvider { + return { + async getSnapshot(): Promise { + const rules = MOCK_RULES.map(r => { + if (!r.enabled) { return { ...r }; } + return { + ...r, + violationsToday: r.violationsToday + (Math.random() < 0.1 ? 1 : 0), + evaluationsToday: r.evaluationsToday + Math.floor(Math.random() * 5), + }; + }); + + const recentViolations = createMockViolations(); + + return { + rules, + recentViolations, + totalEvaluationsToday: rules.reduce((s, r) => s + r.evaluationsToday, 0), + totalViolationsToday: rules.reduce((s, r) => s + r.violationsToday, 0), + }; + }, + }; +} diff --git a/packages/agent-os-vscode/src/mockBackend/MockSLOBackend.ts b/packages/agent-os-vscode/src/mockBackend/MockSLOBackend.ts new file mode 100644 index 00000000..b8d80838 --- /dev/null +++ b/packages/agent-os-vscode/src/mockBackend/MockSLOBackend.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Mock SLO Backend Service + * + * Simulates a realistic SLO data feed with time-varying metrics. + * Values drift over time with occasional incidents and recoveries, + * producing a convincing live dashboard experience. + */ + +import { + SLODataProvider, + SLOSnapshot, + AvailabilitySLOData, + LatencySLOData, + PolicyComplianceSLOData, + TrustScoreSLOData, +} from '../views/sloTypes'; +import { drift } from './mockUtils'; + +/** + * Creates an SLODataProvider backed by a simulated time-varying feed. + * + * Each call to `getSnapshot()` returns slightly different values, + * simulating real SLO metric drift. Occasional "incidents" cause + * brief dips in availability and spikes in latency. + */ +export function createMockSLOBackend(): SLODataProvider { + let availability = 99.82; + let errorBudget = 63.0; + let burnRate = 1.05; + let p50 = 42, p95 = 115, p99 = 225; + let compliance = 99.7; + let violations = 2; + let totalEvals = 1284; + let meanTrust = 820, minTrust = 410; + let belowThreshold = 1; + let callCount = 0; + + /** Apply incident/recovery/normal drift to core metrics. */ + function applyDrift(): void { + const incident = callCount % 30 === 0; + const recovery = callCount % 30 === 3; + + if (incident) { + availability = drift(availability, 1.2, 97.5, 99.0); + p99 = drift(p99, 80, 280, 450); + violations += Math.floor(Math.random() * 4) + 2; + burnRate = drift(burnRate, 1.5, 2.0, 4.0); + } else if (recovery) { + availability = drift(availability, 0.5, 99.5, 99.95); + p99 = drift(p99, 30, 180, 250); + burnRate = drift(burnRate, 0.5, 0.8, 1.2); + } else { + availability = drift(availability, 0.08, 99.2, 99.99); + burnRate = drift(burnRate, 0.15, 0.6, 2.0); + } + + p50 = drift(p50, 5, 25, 80); + p95 = drift(p95, 10, 80, 200); + p99 = drift(p99, 12, Math.max(p95 + 20, 150), 400); + errorBudget = drift(errorBudget, 2, 5, 90); + compliance = drift(compliance, 0.15, 98.0, 100.0); + totalEvals += Math.floor(Math.random() * 20) + 5; + violations = Math.max(0, violations + (Math.random() > 0.7 ? 1 : 0)); + meanTrust = Math.round(drift(meanTrust, 15, 650, 950)); + minTrust = Math.round(drift(minTrust, 20, 200, meanTrust - 100)); + belowThreshold = Math.max(0, Math.round(drift(belowThreshold, 0.8, 0, 5))); + } + + /** Build availability snapshot from current state. */ + function buildAvailabilitySnapshot(): AvailabilitySLOData { + return { + currentPercent: +availability.toFixed(2), + targetPercent: 99.5, + errorBudgetRemainingPercent: +errorBudget.toFixed(1), + burnRate: +burnRate.toFixed(2), + }; + } + + /** Build latency snapshot from current state. */ + function buildLatencySnapshot(): LatencySLOData { + return { + p50Ms: +p50.toFixed(0), + p95Ms: +p95.toFixed(0), + p99Ms: +p99.toFixed(0), + targetMs: 300, + errorBudgetRemainingPercent: +drift(72, 5, 20, 95).toFixed(1), + }; + } + + /** Build compliance snapshot from current state. */ + function buildComplianceSnapshot(): PolicyComplianceSLOData { + return { + totalEvaluations: totalEvals, + violationsToday: Math.round(violations), + compliancePercent: +compliance.toFixed(2), + trend: compliance > 99.5 ? 'up' : compliance > 99.0 ? 'stable' : 'down', + }; + } + + /** Build trust score snapshot from current state. */ + function buildTrustSnapshot(): TrustScoreSLOData { + const dist: [number, number, number, number] = [ + Math.max(0, Math.round(drift(belowThreshold, 1, 0, 4))), + Math.max(0, Math.round(drift(3, 1.5, 1, 8))), + Math.round(drift(15, 3, 8, 25)), + Math.round(drift(40, 4, 28, 55)), + ]; + return { + meanScore: meanTrust, + minScore: minTrust, + agentsBelowThreshold: belowThreshold, + distribution: dist, + }; + } + + return { + async getSnapshot(): Promise { + callCount++; + applyDrift(); + return { + availability: buildAvailabilitySnapshot(), + latency: buildLatencySnapshot(), + policyCompliance: buildComplianceSnapshot(), + trustScore: buildTrustSnapshot(), + }; + }, + }; +} diff --git a/packages/agent-os-vscode/src/mockBackend/MockTopologyBackend.ts b/packages/agent-os-vscode/src/mockBackend/MockTopologyBackend.ts new file mode 100644 index 00000000..37c62cc8 --- /dev/null +++ b/packages/agent-os-vscode/src/mockBackend/MockTopologyBackend.ts @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Mock Topology Backend Service + * + * Simulates a live agent mesh with agents registering/deregistering, + * trust scores shifting, bridges connecting/disconnecting, and + * delegations expiring. Provides a realistic visual feed. + */ + +import { + AgentTopologyDataProvider, + AgentNode, + BridgeStatus, + DelegationChain, + ExecutionRing, +} from '../views/topologyTypes'; +import { clamp, drift } from './mockUtils'; + +/** ISO timestamp for N minutes ago. */ +function minutesAgo(n: number): string { + return new Date(Date.now() - n * 60_000).toISOString(); +} + +/** + * Creates an AgentTopologyDataProvider that simulates a live mesh. + * Trust scores drift, bridges flap, delegations expire and renew. + */ +export function createMockTopologyBackend(): AgentTopologyDataProvider { + const agents: AgentNode[] = [ + { + did: 'did:mesh:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', + trustScore: 920, + ring: ExecutionRing.Ring1Supervisor, + registeredAt: '2026-03-20T08:15:00Z', + lastActivity: minutesAgo(2), + capabilities: ['tool_call', 'file_read', 'policy_evaluate'], + }, + { + did: 'did:mesh:f1e2d3c4b5a6f7e8d9c0b1a2f3e4d5c6', + trustScore: 580, + ring: ExecutionRing.Ring2User, + registeredAt: '2026-03-18T11:42:00Z', + lastActivity: minutesAgo(8), + capabilities: ['tool_call'], + }, + { + did: 'did:mesh:0a1b2c3d4e5f0a1b2c3d4e5f6a7b8c9d', + trustScore: 310, + ring: ExecutionRing.Ring3Sandbox, + registeredAt: '2026-03-21T16:00:00Z', + lastActivity: minutesAgo(25), + capabilities: [], + }, + { + did: 'did:mesh:b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9', + trustScore: 750, + ring: ExecutionRing.Ring2User, + registeredAt: '2026-03-22T09:30:00Z', + lastActivity: minutesAgo(1), + capabilities: ['tool_call', 'file_write'], + }, + ]; + + const bridges: BridgeStatus[] = [ + { protocol: 'A2A', connected: true, peerCount: 4 }, + { protocol: 'MCP', connected: true, peerCount: 2 }, + { protocol: 'IATP', connected: false, peerCount: 0 }, + ]; + + const delegations: DelegationChain[] = [ + { + fromDid: agents[0].did, + toDid: agents[1].did, + capability: 'tool_call', + expiresIn: '2h', + }, + { + fromDid: agents[0].did, + toDid: agents[2].did, + capability: 'file_read', + expiresIn: '30m', + }, + { + fromDid: agents[3].did, + toDid: agents[1].did, + capability: 'file_write', + expiresIn: '1h', + }, + ]; + + let callCount = 0; + + return { + getAgents(): AgentNode[] { + callCount++; + for (const a of agents) { + a.trustScore = Math.round(drift(a.trustScore, 20, 100, 980)); + a.lastActivity = minutesAgo(Math.floor(Math.random() * 15)); + // Occasional ring change + if (callCount % 10 === 0 && a.trustScore < 400) { + a.ring = ExecutionRing.Ring3Sandbox; + } else if (a.trustScore > 800) { + a.ring = ExecutionRing.Ring1Supervisor; + } + } + return [...agents]; + }, + + getBridges(): BridgeStatus[] { + // Flap IATP bridge occasionally + bridges[2].connected = callCount % 5 === 0; + bridges[2].peerCount = bridges[2].connected ? 1 : 0; + // Drift peer counts + bridges[0].peerCount = clamp( + bridges[0].peerCount + (Math.random() > 0.7 ? 1 : 0) - (Math.random() > 0.8 ? 1 : 0), + 2, 8, + ); + return [...bridges]; + }, + + getDelegations(): DelegationChain[] { + // Simulate expiry countdown + const times = ['2h', '1h 45m', '1h 30m', '1h', '45m', '30m', '15m', '5m']; + for (const d of delegations) { + d.expiresIn = times[Math.floor(Math.random() * times.length)]; + } + return [...delegations]; + }, + }; +} diff --git a/packages/agent-os-vscode/src/mockBackend/mockUtils.ts b/packages/agent-os-vscode/src/mockBackend/mockUtils.ts new file mode 100644 index 00000000..54020d68 --- /dev/null +++ b/packages/agent-os-vscode/src/mockBackend/mockUtils.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Shared utilities for mock backend services. + * + * Provides bounded random-walk helpers used by both + * MockSLOBackend and MockTopologyBackend. + */ + +/** Clamp a number between min and max. */ +export function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +/** Add bounded random walk to a value. */ +export function drift( + current: number, + step: number, + lo: number, + hi: number, +): number { + return clamp(current + (Math.random() - 0.48) * step, lo, hi); +} diff --git a/packages/agent-os-vscode/src/observability/MetricsExporter.ts b/packages/agent-os-vscode/src/observability/MetricsExporter.ts new file mode 100644 index 00000000..493259d2 --- /dev/null +++ b/packages/agent-os-vscode/src/observability/MetricsExporter.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Governance metrics exporter for observability. + * + * Pushes metrics to configured endpoints with retry logic. + */ + +/** Governance metrics snapshot for export. */ +export interface GovernanceMetrics { + /** System availability percentage (0-100). */ + availability: number; + /** 99th percentile latency in milliseconds. */ + latencyP99: number; + /** Policy compliance percentage (0-100). */ + compliancePercent: number; + /** Mean trust score across agents (0-1000). */ + trustScoreMean: number; + /** Total number of registered agents. */ + agentCount: number; + /** Number of policy violations today. */ + violationsToday: number; + /** ISO-8601 timestamp of the metrics snapshot. */ + timestamp: string; +} + +/** Retry configuration. */ +interface RetryConfig { + maxRetries: number; + baseDelayMs: number; + maxDelayMs: number; +} + +/** + * Export governance metrics to observability endpoints. + */ +export class MetricsExporter { + private readonly retryConfig: RetryConfig = { + maxRetries: 3, + baseDelayMs: 1000, + maxDelayMs: 10000, + }; + + constructor(private endpoint: string) {} + + /** + * Push metrics to the configured endpoint. + * + * @param metrics - Governance metrics to export. + * @throws Error if all retries are exhausted. + */ + async push(metrics: GovernanceMetrics): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt < this.retryConfig.maxRetries; attempt++) { + try { + await this.sendMetrics(metrics); + return; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + await this.delay(this.calculateBackoff(attempt)); + } + } + + throw new Error( + `Failed to push metrics after ${this.retryConfig.maxRetries} retries: ${lastError?.message}` + ); + } + + /** + * Update the metrics endpoint. + * + * @param endpoint - New endpoint URL. + */ + setEndpoint(endpoint: string): void { + this.endpoint = endpoint; + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private async sendMetrics(metrics: GovernanceMetrics): Promise { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(metrics), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } + + private calculateBackoff(attempt: number): number { + const delay = this.retryConfig.baseDelayMs * Math.pow(2, attempt); + return Math.min(delay, this.retryConfig.maxDelayMs); + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/packages/agent-os-vscode/src/observability/index.ts b/packages/agent-os-vscode/src/observability/index.ts new file mode 100644 index 00000000..5c91833a --- /dev/null +++ b/packages/agent-os-vscode/src/observability/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Observability module barrel file. + * + * Re-exports metrics exporter utilities. + */ + +export { MetricsExporter, GovernanceMetrics } from './MetricsExporter'; diff --git a/packages/agent-os/extensions/vscode/src/policyEngine.ts b/packages/agent-os-vscode/src/policyEngine.ts similarity index 100% rename from packages/agent-os/extensions/vscode/src/policyEngine.ts rename to packages/agent-os-vscode/src/policyEngine.ts diff --git a/packages/agent-os-vscode/src/server/GovernanceServer.ts b/packages/agent-os-vscode/src/server/GovernanceServer.ts new file mode 100644 index 00000000..4007153c --- /dev/null +++ b/packages/agent-os-vscode/src/server/GovernanceServer.ts @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Governance Server + * + * Local development server for browser-based governance dashboard viewing. + * Uses Node's built-in http module and ws package for WebSocket support. + * Broadcasts SLO, topology, and audit updates to all connected clients. + */ + +import * as http from 'http'; +import * as path from 'path'; +import { SLODataProvider } from '../views/sloTypes'; +import { AgentTopologyDataProvider } from '../views/topologyTypes'; +import { AuditLogger } from '../auditLogger'; +import { ServerState, ClientConnection, ServerMessage, ServerMessageType } from './serverTypes'; +import { renderBrowserDashboard } from './browserTemplate'; +import { + DEFAULT_HOST, + DEFAULT_PORT, + findAvailablePort, + generateClientId, + generateSessionToken, + generateNonce, + checkRateLimit, + validateWebSocketToken, + RateLimitRecord, + WebSocketLike, + WebSocketServerLike +} from './serverHelpers'; + +/** + * Local development server for governance dashboard visualization. + * + * Serves an HTML dashboard accessible via browser and pushes real-time + * updates to connected WebSocket clients. Uses singleton pattern to + * ensure only one server instance runs at a time. + */ +export class GovernanceServer { + + private static _instance: GovernanceServer | undefined; + + private _httpServer: http.Server | undefined; + private _wsServer: WebSocketServerLike | undefined; + private _clients: Map = new Map(); + private _port: number = 0; + private _refreshInterval: ReturnType | undefined; + private _sessionToken: string = ''; + private _requestCounts: Map = new Map(); + + private constructor( + private readonly _sloProvider: SLODataProvider, + private readonly _topologyProvider: AgentTopologyDataProvider, + private readonly _auditLogger: AuditLogger + ) {} + + /** Get or create the singleton server instance. */ + public static getInstance( + sloProvider: SLODataProvider, + topologyProvider: AgentTopologyDataProvider, + auditLogger: AuditLogger + ): GovernanceServer { + if (!GovernanceServer._instance) { + GovernanceServer._instance = new GovernanceServer( + sloProvider, + topologyProvider, + auditLogger + ); + } + return GovernanceServer._instance; + } + + /** + * Start the server on the specified or default port. + * Automatically finds an available port if the default is occupied. + */ + public async start(port?: number): Promise { + if (this._httpServer) { + return this._port; + } + this._port = await findAvailablePort(port ?? DEFAULT_PORT, DEFAULT_HOST); + // SECURITY: Session token in WebSocket URL query string — standard WS auth pattern (RFC 6455). + // Server binds to 127.0.0.1 only; token never traverses a network. + this._sessionToken = generateSessionToken(); + await this._createHttpServer(); + await this._createWebSocketServer(); + this._refreshInterval = setInterval(() => this._broadcastUpdates(), 10_000); + return this._port; + } + + /** Stop the server and clean up all resources. */ + public async stop(): Promise { + if (this._refreshInterval) { + clearInterval(this._refreshInterval); + this._refreshInterval = undefined; + } + this._requestCounts.clear(); + await this._closeServer(this._wsServer, () => { + this._wsServer = undefined; + this._clients.clear(); + }); + await this._closeServer(this._httpServer, () => { + this._httpServer = undefined; + }); + GovernanceServer._instance = undefined; + } + + /** Get the URL for accessing the dashboard. */ + public getUrl(): string { + return `http://${DEFAULT_HOST}:${this._port}`; + } + + /** Get the session token for WebSocket authentication. */ + public getSessionToken(): string { + return this._sessionToken; + } + + /** Get the current server state. */ + public getState(): ServerState { + return { + port: this._port, + url: this.getUrl(), + clients: Array.from(this._clients.values()) + }; + } + + /** Broadcast a message to all connected WebSocket clients. */ + public broadcast(type: ServerMessageType, data: unknown): void { + const message: ServerMessage = { + type, + data, + timestamp: new Date().toISOString() + }; + const payload = JSON.stringify(message); + this._wsServer?.clients?.forEach((client: WebSocketLike) => { + if (client.readyState === 1) { + client.send(payload); + } + }); + } + + /** Create and start the HTTP server. */ + private _createHttpServer(): Promise { + return new Promise((resolve, reject) => { + this._httpServer = http.createServer((req, res) => { + this._handleRequest(req, res); + }); + this._httpServer.once('error', reject); + // SECURITY: Loopback binding prevents external access. Validated by DEFAULT_HOST = '127.0.0.1'. + this._httpServer.listen(this._port, DEFAULT_HOST, () => resolve()); + }); + } + + /** Handle incoming HTTP requests. */ + private _handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + const ip = req.socket.remoteAddress || 'unknown'; + // SECURITY: Rate limiter Map without TTL eviction. Loopback-only = at most 1 entry. + if (!checkRateLimit(ip, this._requestCounts)) { + res.writeHead(429, { 'Retry-After': '60' }); + res.end('Rate limit exceeded'); + return; + } + const nonce = generateNonce(); + this._setSecurityHeaders(res, nonce); + if (req.url === '/' || req.url === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + const extensionRoot = path.resolve(__dirname, '..', '..'); + res.end(renderBrowserDashboard(this._port, this._sessionToken, nonce, extensionRoot)); + return; + } + res.writeHead(404); + res.end('Not Found'); + } + + /** Apply security headers to all HTTP responses. */ + private _setSecurityHeaders(res: http.ServerResponse, nonce: string): void { + res.setHeader('Content-Security-Policy', + "default-src 'self'; " + + `script-src 'nonce-${nonce}'; ` + + "style-src 'self' 'unsafe-inline'; " + + "connect-src 'self' ws://127.0.0.1:*"); + res.setHeader('X-Content-Type-Options', 'nosniff'); + } + + /** Create the WebSocket server. */ + private async _createWebSocketServer(): Promise { + try { + // Dynamic import to avoid hard dependency on ws + // eslint-disable-next-line @typescript-eslint/no-var-requires + const WebSocketModule = await import('ws').catch(() => null); + if (!WebSocketModule) { + return; // ws not available, server works without WebSocket + } + this._wsServer = new WebSocketModule.WebSocketServer({ + server: this._httpServer, + path: '/' + }) as WebSocketServerLike; + + this._wsServer.on('connection', (ws: WebSocketLike, req: unknown) => { + if (!validateWebSocketToken(req, this._sessionToken, this._port)) { + ws.close(4001, 'Invalid session token'); + return; + } + const id = generateClientId(); + this._clients.set(id, { id, connectedAt: new Date() }); + ws.on('close', () => this._clients.delete(id)); + this._sendInitialData(ws); + }); + } catch { + // ws package not available - server works without WebSocket + } + } + + /** Send initial data snapshot to a newly connected client. */ + private async _sendInitialData(ws: WebSocketLike): Promise { + try { + const slo = await this._sloProvider.getSnapshot(); + const topology = { + agents: this._topologyProvider.getAgents(), + bridges: this._topologyProvider.getBridges(), + delegations: this._topologyProvider.getDelegations() + }; + const audit = this._auditLogger.getRecent(50); + ws.send(JSON.stringify({ type: 'sloUpdate', data: slo })); + ws.send(JSON.stringify({ type: 'topologyUpdate', data: topology })); + ws.send(JSON.stringify({ type: 'auditUpdate', data: audit })); + } catch { + // Provider may not be ready yet; client will receive data on next broadcast cycle + } + } + + /** Broadcast current data to all clients. */ + private async _broadcastUpdates(): Promise { + try { + const slo = await this._sloProvider.getSnapshot(); + this.broadcast('sloUpdate', slo); + } catch { + // Non-critical: broadcast failure retries automatically on next 10s interval + } + } + + /** Close a server instance and run cleanup callback. */ + private _closeServer( + server: { close(cb?: () => void): void } | undefined, + cleanup: () => void + ): Promise { + if (!server) { return Promise.resolve(); } + return new Promise((resolve) => { + server.close(() => { cleanup(); resolve(); }); + }); + } +} diff --git a/packages/agent-os-vscode/src/server/browserScripts.ts b/packages/agent-os-vscode/src/server/browserScripts.ts new file mode 100644 index 00000000..e1df9857 --- /dev/null +++ b/packages/agent-os-vscode/src/server/browserScripts.ts @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Browser Dashboard Scripts + * + * Client-side JavaScript for WebSocket connection, routing, and D3.js topology. + */ + +/** Build the WebSocket client script. */ +export function buildClientScript(wsPort: number, sessionToken: string): string { + return ` + /** Escape HTML entities to prevent XSS (string-based, no DOM allocation). */ + function esc(s) { + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); + } + + let ws; + let reconnectTimer; + const statusDot = document.getElementById('status-dot'); + + function connect() { + // Intentionally ws:// (not wss://): server binds to 127.0.0.1 only, TLS unnecessary for loopback + ws = new WebSocket('ws://127.0.0.1:${wsPort}?token=${sessionToken}'); + ws.onopen = () => { statusDot.classList.remove('disconnected'); }; + ws.onclose = () => { statusDot.classList.add('disconnected'); scheduleReconnect(); }; + ws.onerror = () => { ws.close(); }; + ws.onmessage = (event) => { handleMessage(JSON.parse(event.data)); }; + } + + function scheduleReconnect() { + if (reconnectTimer) { clearTimeout(reconnectTimer); } + reconnectTimer = setTimeout(connect, 3000); + } + + function handleMessage(msg) { + if (msg.type === 'sloUpdate') { updateSLO(msg.data); } + else if (msg.type === 'topologyUpdate') { updateTopology(msg.data); } + else if (msg.type === 'auditUpdate') { updateAudit(msg.data); } + } + + function updateStaleness(fetchedAt) { + var el = document.getElementById('staleness-badge'); + if (!el) { return; } + if (!fetchedAt) { el.textContent = ''; return; } + var ageSec = Math.round((Date.now() - new Date(fetchedAt).getTime()) / 1000); + if (isNaN(ageSec) || ageSec < 0 || ageSec < 10) { el.textContent = ''; return; } + el.textContent = ageSec < 60 ? ageSec + 's ago' : Math.round(ageSec / 60) + 'm ago'; + el.style.color = ageSec > 30 ? '#cca700' : ''; + } + + function updateSLO(snapshot) { + if (!snapshot) { return; } + setMetric('avail-val', snapshot.availability?.currentPercent, '%'); + setMetric('latency-val', snapshot.latency?.p99Ms, 'ms'); + setMetric('compliance-val', snapshot.policyCompliance?.compliancePercent, '%'); + setMetric('trust-val', snapshot.trustScore?.meanScore, ''); + updateStaleness(snapshot.fetchedAt); + } + + function setMetric(id, value, suffix) { + const el = document.getElementById(id); + if (el && value !== undefined) { el.textContent = value.toFixed(1) + suffix; } + } + + function updateTopology(data) { + if (!data || !window.renderTopologyGraph) { return; } + window.renderTopologyGraph(data.agents || [], data.delegations || []); + } + + function updateAudit(entries) { + const list = document.getElementById('audit-list'); + if (!list || !Array.isArray(entries)) { return; } + list.innerHTML = entries.slice(0, 50).map(e => buildAuditItem(e)).join(''); + } + + function buildAuditItem(entry) { + const time = new Date(entry.timestamp).toLocaleTimeString(); + const cls = entry.type === 'blocked' ? 'health-breach' : 'health-ok'; + return '
    ' + + esc(entry.type) + '' + esc(time) + + '' + esc(entry.reason || entry.violation || '-') + '
    '; + } + + function handleRoute() { + const hash = window.location.hash.slice(1) || 'slo'; + document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + const tab = document.getElementById('tab-' + hash); + const nav = document.querySelector('[data-tab="' + hash + '"]'); + if (tab) { tab.classList.add('active'); } + if (nav) { nav.classList.add('active'); } + } + + window.addEventListener('hashchange', handleRoute); + document.addEventListener('DOMContentLoaded', () => { handleRoute(); connect(); }); + + document.getElementById('toggle-sidebar')?.addEventListener('click', () => { + document.querySelector('.sidebar').classList.toggle('collapsed'); + }); + + var helpToggle = document.getElementById('help-toggle'); + var helpPanel = document.getElementById('help-panel'); + var helpClose = document.getElementById('help-close'); + var helpSearch = document.getElementById('help-search'); + + if (helpToggle) { + helpToggle.addEventListener('click', function() { + if (!helpPanel) { return; } + var isOpen = helpPanel.classList.toggle('visible'); + helpToggle.setAttribute('aria-expanded', String(isOpen)); + if (isOpen && helpSearch) { helpSearch.focus(); } + }); + } + if (helpClose) { + helpClose.addEventListener('click', function() { + if (!helpPanel) { return; } + helpPanel.classList.remove('visible'); + if (helpToggle) { helpToggle.setAttribute('aria-expanded', 'false'); helpToggle.focus(); } + }); + } + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && helpPanel && helpPanel.classList.contains('visible')) { + helpPanel.classList.remove('visible'); + if (helpToggle) { helpToggle.setAttribute('aria-expanded', 'false'); helpToggle.focus(); } + } + }); + if (helpSearch) { + helpSearch.addEventListener('input', function(e) { + var q = e.target.value.toLowerCase(); + var sections = helpPanel ? helpPanel.querySelectorAll('section') : []; + sections.forEach(function(s) { + s.style.display = (s.textContent || '').toLowerCase().indexOf(q) !== -1 ? '' : 'none'; + }); + }); + }`; +} + +/** Build static help content as HTML sections for the browser dashboard. */ +export function buildHelpContent(): string { + return ` +

    Overview

    +

    Agent OS provides kernel-level safety for AI coding assistants. It intercepts tool calls, enforces policies, and produces tamper-proof audit trails.

    +

    SLO Dashboard

    +

    Availability: Percentage of successful governance evaluations in the current window.

    +

    Latency P99: 99th-percentile response time for policy evaluation calls.

    +

    Burn Rate: Ratio of actual error consumption to budgeted rate. Values above 1.0 indicate the budget is depleting faster than planned.

    +

    Error Budgets: Remaining tolerance for failures before the SLO is breached.

    +

    Trust Distribution: Histogram of agent trust scores across four buckets (0-250, 251-500, 501-750, 751-1000).

    +

    Agent Topology

    +

    Agents: Registered AI agents identified by DID (Decentralized Identifier).

    +

    Bridges: Protocol connectors (A2A, MCP, IATP) linking agents across trust boundaries.

    +

    Trust Score: Mean trust across all agents (0-1000 scale).

    +

    Audit Log

    +

    Chronological record of governance decisions. Filter by severity (info, warning, critical) or search by action, DID, or file path.

    +

    Active Policies

    +

    DENY: Rejects the tool call and returns an error to the agent.

    +

    BLOCK: Silently prevents execution without notifying the agent.

    +

    AUDIT: Allows execution but logs a compliance event.

    +

    ALLOW: Permits execution with no additional overhead.

    +

    Glossary

    +
+ + + + +
TermDefinition
SLOService Level Objective - a target reliability metric
SLIService Level Indicator - measured value tracking an SLO
DIDDecentralized Identifier for agent identity
Burn RateSpeed at which error budget is consumed
Trust Score0-1000 composite reliability rating
+

Troubleshooting

+ + + +
SymptomFix
Dashboard shows no dataCheck that the governance backend is running and agentOS.governance settings are configured.
WebSocket disconnectedVerify the server is running on the expected port. The client auto-reconnects every 3 seconds.
Stale data warningIncrease agentOS.governance.refreshIntervalMs or check backend health.
+

Security Design Decisions

+ + + +
DecisionRationale
CSP nonce-gated scriptsPrevents injection of unauthorized scripts into webviews.
Session token on WebSocketAuthenticates browser clients to the local governance server.
Loopback-only bindingServer binds to 127.0.0.1, not exposed to network.
`; +} + +/** Build the D3.js topology graph script. */ +export function buildTopologyScript(): string { + return ` + window.renderTopologyGraph = function(agents, delegations) { + const svg = d3.select('#topology-svg'); + svg.selectAll('*').remove(); + const width = svg.node().parentElement.clientWidth; + const height = 500; + svg.attr('viewBox', [0, 0, width, height]); + + const nodes = agents.map(a => ({ id: a.did, trustScore: a.trustScore, ...a })); + const links = delegations.map(d => ({ + source: d.fromDid, + target: d.toDid, + capability: d.capability + })); + + const simulation = d3.forceSimulation(nodes) + .force('link', d3.forceLink(links).id(d => d.id).distance(100)) + .force('charge', d3.forceManyBody().strength(-200)) + .force('center', d3.forceCenter(width / 2, height / 2)); + + const link = svg.append('g').selectAll('line').data(links).join('line') + .attr('class', 'link'); + const node = svg.append('g').selectAll('g').data(nodes).join('g') + .attr('class', 'node'); + node.append('circle').attr('r', 20).attr('fill', d => trustColor(d.trustScore)); + node.append('text').attr('class', 'node-label').attr('dy', 30) + .attr('text-anchor', 'middle').text(d => d.did.slice(-8)); + + simulation.on('tick', () => { + link.attr('x1', d => d.source.x).attr('y1', d => d.source.y) + .attr('x2', d => d.target.x).attr('y2', d => d.target.y); + node.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')'); + }); + }; + + function trustColor(score) { + if (score > 700) { return '#4ec9b0'; } + if (score >= 400) { return '#dcdcaa'; } + return '#f14c4c'; + }`; +} diff --git a/packages/agent-os-vscode/src/server/browserStyles.ts b/packages/agent-os-vscode/src/server/browserStyles.ts new file mode 100644 index 00000000..87283cf8 --- /dev/null +++ b/packages/agent-os-vscode/src/server/browserStyles.ts @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Browser Dashboard Styles + * + * CSS styles for the browser-based governance dashboard. + */ + +/** Build the CSS styles for the browser dashboard. */ +export function buildBrowserStyles(): string { + return ` + :root { + --bg-primary: #1e1e1e; + --bg-secondary: #252526; + --bg-tertiary: #2d2d30; + --text-primary: #cccccc; + --text-secondary: #9d9d9d; + --accent-blue: #0078d4; + --health-ok: #4ec9b0; + --health-warn: #dcdcaa; + --health-breach: #f14c4c; + --border: #3c3c3c; + } + * { box-sizing: border-box; margin: 0; padding: 0; } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + } + .container { display: flex; min-height: 100vh; } + .sidebar { + width: 220px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + padding: 16px; + flex-shrink: 0; + } + .sidebar.collapsed { width: 50px; } + .sidebar.collapsed .nav-label { display: none; } + .main { flex: 1; padding: 24px; overflow-y: auto; } + .nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 4px; + cursor: pointer; + color: var(--text-secondary); + text-decoration: none; + margin-bottom: 4px; + } + .nav-item:hover, .nav-item.active { + background: var(--bg-tertiary); + color: var(--text-primary); + } + .nav-icon { font-size: 18px; } + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + .header h1 { font-size: 20px; font-weight: 500; } + .status-indicator { display: flex; align-items: center; gap: 8px; } + .status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--health-ok); + } + .status-dot.disconnected { background: var(--health-breach); } + .tab-content { display: none; } + .tab-content.active { display: block; } + .card { + background: var(--bg-secondary); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + } + .card-title { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 12px; + } + .metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + } + .metric { text-align: center; } + .metric-value { font-size: 32px; font-weight: 600; } + .metric-label { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; + } + .health-ok { color: var(--health-ok); } + .health-warn { color: var(--health-warn); } + .health-breach { color: var(--health-breach); } + #topology-graph { + width: 100%; + height: 500px; + background: var(--bg-tertiary); + border-radius: 8px; + } + #topology-graph svg { width: 100%; height: 100%; } + .node circle { cursor: pointer; } + .link { stroke: var(--border); stroke-opacity: 0.6; } + .node-label { + font-size: 10px; + fill: var(--text-secondary); + pointer-events: none; + } + .audit-list { max-height: 400px; overflow-y: auto; } + .audit-item { + padding: 10px; + border-bottom: 1px solid var(--border); + display: flex; + gap: 12px; + } + .audit-type { font-weight: 500; width: 80px; } + .audit-time { color: var(--text-secondary); font-size: 12px; width: 100px; } + .toggle-btn { + background: none; + border: none; + color: var(--text-primary); + cursor: pointer; + font-size: 18px; + } + .chart-container { height: 120px; } + .help-btn { + background: var(--bg-secondary); border: 1px solid var(--border); + border-radius: 4px; width: 28px; height: 28px; + cursor: pointer; font-size: 14px; color: var(--text-primary); + display: flex; align-items: center; justify-content: center; + margin-left: 8px; + } + .help-btn:hover { background: var(--bg-tertiary); } + .help-panel { + position: fixed; top: 0; right: 0; bottom: 0; width: 380px; + background: var(--bg-primary); border-left: 1px solid var(--border); + z-index: 100; transform: translateX(100%); transition: transform 0.2s ease; + display: flex; flex-direction: column; + } + .help-panel.visible { transform: translateX(0); } + .help-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px; border-bottom: 1px solid var(--border); + } + .help-header h2 { margin: 0; font-size: 16px; } + .help-close-btn { + background: none; border: none; font-size: 20px; + cursor: pointer; color: var(--text-primary); padding: 4px 8px; + } + .help-search { + margin: 12px 16px; padding: 6px 10px; + background: var(--bg-secondary); border: 1px solid var(--border); + border-radius: 4px; color: var(--text-primary); font-size: 13px; + } + .help-body { + flex: 1; overflow-y: auto; padding: 16px; + font-size: 13px; line-height: 1.6; + } + .help-body h2 { font-size: 15px; margin: 16px 0 8px; color: var(--text-primary); } + .help-body p { margin: 0 0 8px; } + .help-body strong { color: var(--text-primary); } + .help-body table { width: 100%; border-collapse: collapse; margin: 8px 0; } + .help-body td, .help-body th { + padding: 6px 8px; border-bottom: 1px solid var(--border); + text-align: left; font-size: 12px; + } + .help-body th { color: var(--text-primary); }`; +} diff --git a/packages/agent-os-vscode/src/server/browserTemplate.ts b/packages/agent-os-vscode/src/server/browserTemplate.ts new file mode 100644 index 00000000..09bb5b6a --- /dev/null +++ b/packages/agent-os-vscode/src/server/browserTemplate.ts @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Browser Dashboard Template + * + * Full HTML document for the browser-based governance dashboard. + * Includes D3.js for topology graph visualization + * and WebSocket client for real-time updates. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { buildBrowserStyles } from './browserStyles'; +import { buildClientScript, buildTopologyScript, buildHelpContent } from './browserScripts'; + +/** Build the HTML structure for the dashboard body. */ +function buildBodyContent(): string { + return ` +
+ +
+
+

Agent OS Governance

+
+ + Live + + +
+
+ ${buildSLOTab()} + ${buildTopologyTab()} + ${buildAuditTab()} + +
+
`; +} + +/** Build the SLO dashboard tab content. */ +function buildSLOTab(): string { + return ` +
+
+
+
--
+
Availability
+
+
+
--
+
P99 Latency
+
+
+
--
+
Compliance
+
+
+
--
+
Mean Trust
+
+
+
`; +} + +/** Build the topology graph tab content. */ +function buildTopologyTab(): string { + return ` +
+
+ +
+
`; +} + +/** Build the audit log tab content. */ +function buildAuditTab(): string { + return ` +
+
+
Recent Events
+
+
+
`; +} + +/** + * Render the complete browser dashboard HTML document. + * + * @param wsPort - WebSocket port for real-time updates + * @param sessionToken - Session token for WebSocket authentication + * @param nonce - CSP nonce for inline script allowlisting + * @param extensionPath - Root path of the extension for loading vendored assets + * @returns Full HTML document string + */ +export function renderBrowserDashboard( + wsPort: number, + sessionToken: string, + nonce: string, + extensionPath: string, +): string { + const d3Source = fs.readFileSync(path.join(extensionPath, 'assets', 'vendor', 'd3.v7.8.5.min.js'), 'utf8'); + return ` + + + + + + Agent OS Governance Dashboard + + + + + ${buildBodyContent()} + + + +`; +} diff --git a/packages/agent-os-vscode/src/server/index.ts b/packages/agent-os-vscode/src/server/index.ts new file mode 100644 index 00000000..410d4748 --- /dev/null +++ b/packages/agent-os-vscode/src/server/index.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Server Module Index + * + * Exports all server-related types and classes for the governance dashboard. + */ + +export { GovernanceServer } from './GovernanceServer'; +export { renderBrowserDashboard } from './browserTemplate'; +export { buildBrowserStyles } from './browserStyles'; +export { buildClientScript, buildTopologyScript } from './browserScripts'; +export { + DEFAULT_HOST, + DEFAULT_PORT, + findAvailablePort, + generateClientId +} from './serverHelpers'; +export type { + ServerConfig, + ServerState, + ClientConnection, + ServerMessage, + ServerMessageType +} from './serverTypes'; diff --git a/packages/agent-os-vscode/src/server/serverHelpers.ts b/packages/agent-os-vscode/src/server/serverHelpers.ts new file mode 100644 index 00000000..e6c61d45 --- /dev/null +++ b/packages/agent-os-vscode/src/server/serverHelpers.ts @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Server Helper Functions + * + * Utility functions for the governance server including port detection + * and client ID generation. + */ + +import * as http from 'http'; +import { randomBytes } from 'crypto'; + +/** Default host for the governance server — bound to loopback only. */ +export const DEFAULT_HOST = '127.0.0.1'; + +/** Default port to attempt binding. */ +export const DEFAULT_PORT = 9845; + +/** + * Check if a specific port is available for binding. + * + * @param port - Port number to check + * @param host - Host to bind to + * @returns Promise resolving to true if port is available + */ +export function isPortAvailable(port: number, host: string): Promise { + return new Promise((resolve) => { + const testServer = http.createServer(); + testServer.once('error', () => resolve(false)); + testServer.once('listening', () => { + testServer.close(() => resolve(true)); + }); + testServer.listen(port, host); + }); +} + +/** + * Find an available port starting from the preferred one. + * Tries up to 10 consecutive ports. + * + * @param startPort - Preferred port to start searching from + * @param host - Host to bind to + * @returns Promise resolving to the first available port + */ +export async function findAvailablePort( + startPort: number, + host: string +): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + const port = startPort + attempt; + const available = await isPortAvailable(port, host); + if (available) { + return port; + } + } + throw new Error(`No available port found starting from ${startPort}`); +} + +/** + * Generate a unique client connection ID. + * + * @returns Unique string identifier for a client + */ +export function generateClientId(): string { + return `client_${Date.now()}_${randomBytes(4).toString('hex')}`; +} + +/** + * Generate a cryptographically secure session token. + * + * @returns 32-character hex token + */ +// SECURITY: 128 bits of crypto.randomBytes for session auth. Transmitted in WS URL +// query param (WebSocket upgrade has no custom header support per RFC 6455). +export function generateSessionToken(): string { + return randomBytes(16).toString('hex'); +} + +/** + * Generate a random nonce for Content-Security-Policy inline script allowlisting. + * + * @returns 32-character hex nonce + */ +export function generateNonce(): string { + return randomBytes(16).toString('hex'); +} + +/** Rate limit record for a single IP. */ +export interface RateLimitRecord { + count: number; + resetAt: number; +} + +/** + * Check if an IP has exceeded the rate limit (100 requests per minute). + * + * @param ip - Client IP address + * @param requestCounts - Map tracking per-IP request counts + * @returns true if the request is allowed + */ +export function checkRateLimit( + ip: string, + requestCounts: Map +): boolean { + const now = Date.now(); + const windowMs = 60_000; + const maxRequests = 100; + + const record = requestCounts.get(ip); + if (!record || now > record.resetAt) { + requestCounts.set(ip, { count: 1, resetAt: now + windowMs }); + return true; + } + if (record.count >= maxRequests) { + return false; + } + record.count++; + return true; +} + +/** + * Validate a session token from a WebSocket upgrade request URL. + * + * @param req - Incoming HTTP request (from ws connection event) + * @param expectedToken - The expected session token + * @param port - Server port for URL parsing + * @returns true if the token matches + */ +export function validateWebSocketToken( + req: unknown, + expectedToken: string, + port: number +): boolean { + try { + const incomingReq = req as { url?: string }; + const url = incomingReq?.url; + if (!url) { + return false; + } + const parsed = new URL(url, `http://localhost:${port}`); + return parsed.searchParams.get('token') === expectedToken; + } catch { + return false; // Malformed URL in upgrade request + } +} + +/** Minimal WebSocket interface for type safety without importing ws types. */ +export interface WebSocketLike { + /** WebSocket ready state (1 = OPEN). */ + readyState: number; + /** Send data to the client. */ + send(data: string): void; + /** Close the connection with optional code and reason. */ + close(code?: number, reason?: string): void; + /** Register event listener. */ + on(event: string, listener: (...args: unknown[]) => void): void; +} + +/** Minimal WebSocket server interface. */ +export interface WebSocketServerLike { + /** Set of connected clients. */ + clients?: Set; + /** Register connection event listener. */ + on(event: string, listener: (ws: WebSocketLike, req: unknown) => void): void; + /** Close the server. */ + close(callback?: () => void): void; +} diff --git a/packages/agent-os-vscode/src/server/serverTypes.ts b/packages/agent-os-vscode/src/server/serverTypes.ts new file mode 100644 index 00000000..5965aa38 --- /dev/null +++ b/packages/agent-os-vscode/src/server/serverTypes.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Server Types for Local Dev Server + * + * Type definitions for the governance dashboard local development server. + * Enables browser-based viewing of SLO, topology, and audit dashboards. + */ + +/** Configuration options for the local dev server. */ +export interface ServerConfig { + /** Default port to attempt binding (fallback to next available). */ + defaultPort: number; + /** Host to bind the server to (typically localhost). */ + host: string; +} + +/** Represents a connected WebSocket client. */ +export interface ClientConnection { + /** Unique identifier for this client connection. */ + id: string; + /** Timestamp when the client connected. */ + connectedAt: Date; +} + +/** Current state of the governance server. */ +export interface ServerState { + /** Port the server is currently listening on. */ + port: number; + /** Full URL to access the dashboard. */ + url: string; + /** List of currently connected WebSocket clients. */ + clients: ClientConnection[]; +} + +/** Message types sent to browser clients via WebSocket. */ +export type ServerMessageType = 'sloUpdate' | 'topologyUpdate' | 'auditUpdate' | 'policyUpdate'; + +/** WebSocket message payload structure. */ +export interface ServerMessage { + /** Type of the message for client routing. */ + type: ServerMessageType; + /** Message payload (varies by type). */ + data: unknown; + /** ISO timestamp of when the message was sent. */ + timestamp: string; +} diff --git a/packages/agent-os-vscode/src/services/liveClient.ts b/packages/agent-os-vscode/src/services/liveClient.ts new file mode 100644 index 00000000..1adfb17f --- /dev/null +++ b/packages/agent-os-vscode/src/services/liveClient.ts @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Live SRE Client + * + * HTTP client that polls agent-failsafe REST endpoints and caches + * the latest snapshot. Tracks staleness and escalates to per-endpoint + * fetching when response latency exceeds a threshold. + */ + +import * as vscode from 'vscode'; +import axios, { AxiosInstance } from 'axios'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Configuration for the live SRE client. */ +export interface SREClientOptions { + /** Base URL of agent-failsafe REST server (e.g. "http://127.0.0.1:9377"). */ + endpoint: string; + /** Polling interval in ms. Clamped to minimum 5000. */ + refreshIntervalMs?: number; + /** Latency threshold (ms) that triggers per-endpoint escalation. */ + escalationLatencyMs?: number; + /** Bearer token for Authorization header (optional). */ + token?: string; +} + +/** Cached response with staleness tracking. */ +export interface CachedSnapshot { + /** Raw JSON from /sre/snapshot (or null if never fetched). */ + data: Record | null; + /** When the last successful fetch completed. */ + lastUpdatedAt: Date | null; + /** Human-readable error from the most recent failure (null if healthy). */ + error: string | null; + /** True when data is absent or the last fetch failed. */ + stale: boolean; + /** True when the client has escalated to per-endpoint fetching. */ + escalated: boolean; +} + +const MIN_INTERVAL_MS = 5000; +const REQUEST_TIMEOUT_MS = 5000; +const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; // 5 MB +const ESCALATION_WINDOW = 5; +const DE_ESCALATION_WINDOW = 10; +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']); + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/** + * Validate that an endpoint URL targets a loopback address. + * + * @param endpoint - URL to validate + * @returns true if the hostname is a loopback address + */ +export function isLoopbackEndpoint(endpoint: string): boolean { + try { + const url = new URL(endpoint); + return LOOPBACK_HOSTS.has(url.hostname); + } catch { + return false; + } +} + +/** Polls agent-failsafe REST endpoints and caches the latest snapshot. */ +export class LiveSREClient { + private _http: AxiosInstance; + private _timer: ReturnType | undefined; + private _cache: CachedSnapshot; + private _intervalMs: number; + private _escalationMs: number; + private _latencies: number[] = []; + private _consecutiveFast = 0; + private _polling = false; + private _disposed = false; + private readonly _onDidChange = new vscode.EventEmitter(); + + /** Fires after each successful poll that updates the cache. */ + readonly onDidChange: vscode.Event = this._onDidChange.event; + + constructor(private readonly _options: SREClientOptions) { + if (!isLoopbackEndpoint(_options.endpoint)) { + throw new Error('Governance endpoint must target a loopback address (127.0.0.1, localhost, ::1)'); + } + this._intervalMs = Math.max(_options.refreshIntervalMs ?? 10_000, MIN_INTERVAL_MS); + this._escalationMs = _options.escalationLatencyMs ?? 2000; + this._http = axios.create({ + baseURL: _options.endpoint, + timeout: REQUEST_TIMEOUT_MS, + maxContentLength: MAX_RESPONSE_BYTES, + maxBodyLength: MAX_RESPONSE_BYTES, + maxRedirects: 0, + headers: _options.token + ? { Authorization: `Bearer ${_options.token}` } + : {}, + }); + this._cache = { data: null, lastUpdatedAt: null, error: null, stale: true, escalated: false }; + } + + /** Start polling. First fetch is immediate. */ + start(): void { + if (this._timer) { return; } + this._poll(); + this._timer = setInterval(() => this._poll(), this._intervalMs); + } + + /** Stop polling and release resources. */ + dispose(): void { + this._disposed = true; + if (this._timer) { + clearInterval(this._timer); + this._timer = undefined; + } + this._onDidChange.dispose(); + } + + /** Get the current cached snapshot (read-only). */ + getSnapshot(): Readonly { + return this._cache; + } + + /** Update the auth token for subsequent requests. */ + setToken(token: string | undefined): void { + if (token) { + this._http.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } else { + delete this._http.defaults.headers.common['Authorization']; + } + } + + // ----------------------------------------------------------------------- + // Private + // ----------------------------------------------------------------------- + + private async _poll(): Promise { + if (this._polling) { return; } + this._polling = true; + const start = Date.now(); + try { + const data = this._cache.escalated + ? await this._fetchPerEndpoint() + : await this._fetchSnapshot(); + const latency = Date.now() - start; + this._cache = { data, lastUpdatedAt: new Date(), error: null, stale: false, escalated: this._cache.escalated }; + this._trackLatency(latency); + if (!this._disposed) { this._onDidChange.fire(); } + } catch (err: unknown) { + this._cache = { ...this._cache, error: _sanitizeError(err), stale: true }; + } finally { + this._polling = false; + } + } + + private async _fetchSnapshot(): Promise> { + const res = await this._http.get('/sre/snapshot'); + if (typeof res.data !== 'object' || res.data === null) { + throw new Error('Invalid response: not an object'); + } + return res.data as Record; + } + + private async _fetchPerEndpoint(): Promise> { + const [snapshot, fleet, events] = await Promise.all([ + this._http.get('/sre/snapshot').then(r => r.data).catch(() => ({})), + this._http.get('/sre/fleet').then(r => r.data).catch(() => ({ agents: [] })), + this._http.get('/sre/events').then(r => r.data).catch(() => ({ events: [] })), + ]); + return { ...snapshot, fleet: fleet?.agents ?? [], auditEvents: events?.events ?? [] }; + } + + private _trackLatency(ms: number): void { + this._latencies.push(ms); + if (this._latencies.length > ESCALATION_WINDOW) { + this._latencies.shift(); + } + const avg = this._latencies.reduce((a, b) => a + b, 0) / this._latencies.length; + + if (!this._cache.escalated && this._latencies.length >= ESCALATION_WINDOW && avg > this._escalationMs) { + this._cache = { ...this._cache, escalated: true }; + this._consecutiveFast = 0; + } else if (this._cache.escalated && ms < this._escalationMs) { + this._consecutiveFast++; + if (this._consecutiveFast >= DE_ESCALATION_WINDOW) { + this._cache = { ...this._cache, escalated: false }; + this._latencies = []; + this._consecutiveFast = 0; + } + } else { + this._consecutiveFast = 0; + } + } +} + +/** Classify an error into a safe, fixed message. Never expose URLs or headers. */ +function _sanitizeError(err: unknown): string { + if (!err || typeof err !== 'object') { return 'Unknown error'; } + const axiosErr = err as { code?: string; response?: { status?: number } }; + if (axiosErr.code === 'ECONNREFUSED') { return 'Connection refused'; } + if (axiosErr.code === 'ECONNABORTED') { return 'Request timeout'; } + if (axiosErr.code === 'ERR_NETWORK') { return 'Network error'; } + if (axiosErr.response?.status) { return `Server error (${axiosErr.response.status})`; } + return 'Connection failed'; +} diff --git a/packages/agent-os-vscode/src/services/providerFactory.ts b/packages/agent-os-vscode/src/services/providerFactory.ts new file mode 100644 index 00000000..2233bbe5 --- /dev/null +++ b/packages/agent-os-vscode/src/services/providerFactory.ts @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Provider Factory + * + * Creates data providers for the governance dashboard. On activation, + * attempts to start a local agent-failsafe REST server. If agent-failsafe + * is installed, dashboards populate with live governance data. If not, + * returns disconnected providers with empty state. + */ + +import { SLODataProvider, SLOSnapshot } from '../views/sloTypes'; +import { AgentTopologyDataProvider, AgentNode } from '../views/topologyTypes'; +import { PolicyDataProvider, PolicySnapshot } from '../views/policyTypes'; +import { LiveSREClient } from './liveClient'; +import { translateSLO, translateTopology, translatePolicy } from './translators'; +import { SREServerManager, isAgentFailsafeAvailable, promptAndInstall } from './sreServer'; + +/** Configuration for the provider factory. */ +export interface ProviderConfig { + /** Python interpreter path. */ + pythonPath: string; + /** Explicit endpoint override (bypasses auto-start). */ + endpoint?: string; + /** Optional bearer token for authenticated endpoints. */ + token?: string; + /** Polling interval in ms (minimum 5000). */ + refreshIntervalMs?: number; +} + +/** Bundle of all data providers used by the extension. */ +export interface Providers { + /** SLO data source. */ + slo: SLODataProvider; + /** Agent topology data source. */ + topology: AgentTopologyDataProvider; + /** Policy data source. */ + policy: PolicyDataProvider; + /** LiveSREClient instance for event subscription (null if disconnected). */ + liveClient: LiveSREClient | null; + /** Status message describing the connection state. */ + status: 'live' | 'disconnected' | 'not-installed'; + /** Release all resources (HTTP client + subprocess). */ + dispose(): void; +} + +function emptySnapshot(): SLOSnapshot { + return { + availability: { currentPercent: 0, targetPercent: 0, errorBudgetRemainingPercent: 0, burnRate: 0 }, + latency: { p50Ms: 0, p95Ms: 0, p99Ms: 0, targetMs: 0, errorBudgetRemainingPercent: 0 }, + policyCompliance: { totalEvaluations: 0, violationsToday: 0, compliancePercent: 0, trend: 'stable' }, + trustScore: { meanScore: 0, minScore: 0, agentsBelowThreshold: 0, distribution: [0, 0, 0, 0] }, + }; +} + +function emptyPolicy(): PolicySnapshot { + return { rules: [], recentViolations: [], totalEvaluationsToday: 0, totalViolationsToday: 0 }; +} + +/** + * Create data providers. Attempts to start a local agent-failsafe server. + * + * @param config - Provider configuration with Python path. + * @returns Providers bundle with status indicating live, disconnected, or not-installed. + */ +export async function createProviders(config: ProviderConfig): Promise { + // Explicit endpoint override — connect directly (for advanced users) + if (config.endpoint) { + return createLiveProviders(config.endpoint, config); + } + + // Auto-detect agent-failsafe; offer to install if missing + let available = await isAgentFailsafeAvailable(config.pythonPath); + if (!available) { + const installed = await promptAndInstall(config.pythonPath); + if (!installed) { + return createDisconnectedProviders('not-installed'); + } + available = await isAgentFailsafeAvailable(config.pythonPath); + if (!available) { + return createDisconnectedProviders('not-installed'); + } + } + + const server = new SREServerManager(config.pythonPath); + const result = await server.start(); + if (!result.ok) { + return createDisconnectedProviders('disconnected'); + } + + return createLiveProviders(result.endpoint, config, server); +} + +function createDisconnectedProviders(status: 'disconnected' | 'not-installed'): Providers { + return { + slo: { getSnapshot: async () => emptySnapshot() }, + topology: { getAgents: () => [], getBridges: () => [], getDelegations: () => [] }, + policy: { getSnapshot: async () => emptyPolicy() }, + liveClient: null, + status, + dispose() { /* nothing to release */ }, + }; +} + +function createLiveProviders( + endpoint: string, + config: ProviderConfig, + server?: SREServerManager, +): Providers { + const client = new LiveSREClient({ + endpoint, + token: config.token, + refreshIntervalMs: config.refreshIntervalMs, + }); + client.start(); + + return { + slo: { + async getSnapshot(): Promise { + const snap = client.getSnapshot(); + if (!snap.data || snap.stale) { return emptySnapshot(); } + return translateSLO(snap.data) ?? emptySnapshot(); + }, + }, + topology: { + getAgents(): AgentNode[] { + const snap = client.getSnapshot(); + if (!snap.data || snap.stale) { return []; } + return translateTopology(snap.data); + }, + getBridges() { return []; }, + getDelegations() { return []; }, + }, + policy: { + async getSnapshot(): Promise { + const snap = client.getSnapshot(); + if (!snap.data || snap.stale) { return emptyPolicy(); } + return translatePolicy(snap.data); + }, + }, + liveClient: client, + status: 'live', + dispose() { + client.dispose(); + server?.stop(); + }, + }; +} diff --git a/packages/agent-os-vscode/src/services/sreServer.ts b/packages/agent-os-vscode/src/services/sreServer.ts new file mode 100644 index 00000000..12637618 --- /dev/null +++ b/packages/agent-os-vscode/src/services/sreServer.ts @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * SRE Server Lifecycle Manager + * + * Manages a local agent-failsafe REST server subprocess. + * Spawns on activation, health-checks, and kills on deactivation. + * The extension owns the server lifecycle — no user setup required. + */ + +import { ChildProcess, spawn } from 'child_process'; +import axios from 'axios'; + +import * as vscode from 'vscode'; + +const DEFAULT_PORT = 9377; +const HEALTH_TIMEOUT_MS = 5000; +const HEALTH_RETRIES = 10; +const HEALTH_INTERVAL_MS = 500; + +/** Result of attempting to start the SRE server. */ +export interface ServerStartResult { + /** Whether the server is running and healthy. */ + ok: boolean; + /** Loopback endpoint URL if running, empty if not. */ + endpoint: string; + /** Human-readable status message. */ + message: string; +} + +/** Shell metacharacters that must not appear in a spawn() path argument. */ +const SHELL_METACHAR = /[;&|`$(){}]/; + +/** Validate a Python path is non-empty and free of shell injection characters. */ +export function isValidPythonPath(p: string): boolean { + if (!p || !p.trim()) { return false; } + if (SHELL_METACHAR.test(p)) { return false; } + return true; +} + +/** + * Check if agent-failsafe is importable by the given Python interpreter. + * + * @param pythonPath - Path to Python executable + * @returns true if agent_failsafe.rest_server is importable + */ +export async function isAgentFailsafeAvailable(pythonPath: string): Promise { + if (!isValidPythonPath(pythonPath)) { return false; } + return new Promise((resolve) => { + const proc = spawn(pythonPath, [ + '-c', 'import agent_failsafe.rest_server; print("ok")', + ], { timeout: HEALTH_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] }); + let output = ''; + proc.stdout?.on('data', (d: Buffer) => { output += d.toString(); }); + proc.on('close', (code) => { resolve(code === 0 && output.trim() === 'ok'); }); + proc.on('error', () => { resolve(false); }); + }); +} + +/** + * Prompt the user to install agent-failsafe and run pip install. + * + * @param pythonPath - Path to Python executable + * @returns true if installation succeeded + */ +export async function promptAndInstall(pythonPath: string): Promise { + const choice = await vscode.window.showInformationMessage( + 'agent-failsafe is not installed. Install it to enable live governance data?', + 'Install', 'Not Now', + ); + if (choice !== 'Install') { return false; } + + return vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Installing agent-failsafe...' }, + () => runPipInstall(pythonPath), + ); +} + +function runPipInstall(pythonPath: string): Promise { + if (!isValidPythonPath(pythonPath)) { return Promise.resolve(false); } + return new Promise((resolve) => { + const proc = spawn(pythonPath, [ + '-m', 'pip', 'install', 'agent-failsafe[server]', + ], { timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] }); + + let stderr = ''; + proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('close', (code) => { + if (code === 0) { + vscode.window.showInformationMessage('agent-failsafe installed successfully.'); + resolve(true); + } else { + vscode.window.showErrorMessage( + `Failed to install agent-failsafe: ${stderr.slice(0, 200)}`, + ); + resolve(false); + } + }); + proc.on('error', () => { + vscode.window.showErrorMessage('Failed to run pip. Check your Python path.'); + resolve(false); + }); + }); +} + +/** + * Manages the lifecycle of a local agent-failsafe REST server. + * + * Spawns `python -m agent_failsafe.rest_server` as a child process, + * waits for it to become healthy, and provides the loopback endpoint. + */ +export class SREServerManager { + private _proc: ChildProcess | undefined; + private _port: number; + private _pythonPath: string; + + constructor(pythonPath: string, port?: number) { + this._pythonPath = pythonPath.trim(); + this._port = port ?? DEFAULT_PORT; + } + + /** + * Start the REST server and wait for it to become healthy. + * + * @returns Result with endpoint URL if successful + */ + async start(): Promise { + if (!isValidPythonPath(this._pythonPath)) { + return { ok: false, endpoint: '', message: 'Invalid python path' }; + } + const endpoint = `http://127.0.0.1:${this._port}`; + + // Check if something is already running on the port + if (await this._isHealthy(endpoint)) { + return { ok: true, endpoint, message: 'Server already running' }; + } + + this._proc = spawn(this._pythonPath, [ + '-m', 'agent_failsafe.rest_server', + ], { + stdio: ['ignore', 'ignore', 'ignore'], + detached: false, + }); + + this._proc.on('error', () => { this._proc = undefined; }); + this._proc.on('exit', () => { this._proc = undefined; }); + + // Wait for health check + for (let i = 0; i < HEALTH_RETRIES; i++) { + await this._sleep(HEALTH_INTERVAL_MS); + if (!this._proc) { + return { ok: false, endpoint: '', message: 'Server process exited unexpectedly' }; + } + if (await this._isHealthy(endpoint)) { + return { ok: true, endpoint, message: 'Server started' }; + } + } + + this.stop(); + return { ok: false, endpoint: '', message: 'Server did not become healthy within timeout' }; + } + + /** Stop the server subprocess. */ + stop(): void { + if (this._proc) { + this._proc.kill(); + this._proc = undefined; + } + } + + /** Get the loopback endpoint URL. */ + getEndpoint(): string { + return this._proc ? `http://127.0.0.1:${this._port}` : ''; + } + + private async _isHealthy(endpoint: string): Promise { + try { + const res = await axios.get(`${endpoint}/sre/snapshot`, { + timeout: 2000, maxRedirects: 0, + }); + return res.status === 200; + } catch { + return false; + } + } + + private _sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/packages/agent-os-vscode/src/services/translators.ts b/packages/agent-os-vscode/src/services/translators.ts new file mode 100644 index 00000000..0273d84b --- /dev/null +++ b/packages/agent-os-vscode/src/services/translators.ts @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * REST Response Translators + * + * Pure functions that validate and map agent-failsafe REST responses + * to the extension's typed provider interfaces. Each translator + * rejects invalid input (returns null) rather than coercing. + */ + +import { SLOSnapshot } from '../views/sloTypes'; +import { AgentNode, ExecutionRing } from '../views/topologyTypes'; +import { PolicyRule, PolicySnapshot, PolicyAction } from '../views/policyTypes'; + +// --------------------------------------------------------------------------- +// Validation constants +// --------------------------------------------------------------------------- + +const MAX_FLEET_AGENTS = 1000; +const MAX_AUDIT_EVENTS = 500; +const MAX_POLICIES = 200; +const MAX_STRING_LENGTH = 500; + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- + +function isObject(v: unknown): v is Record { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +function isFiniteNumber(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v); +} + +function truncate(s: unknown, max: number = MAX_STRING_LENGTH): string { + const str = typeof s === 'string' ? s : ''; + return str.length > max ? str.slice(0, max) : str; +} + +function clampRate(v: unknown): number | null { + if (!isFiniteNumber(v)) { return null; } + if (v < 0 || v > 1) { return null; } + return v; +} + +function safeDate(v: unknown): Date { + if (typeof v === 'string') { + const d = new Date(v); + if (!isNaN(d.getTime())) { return d; } + } + return new Date(); +} + +// --------------------------------------------------------------------------- +// SLO translator +// --------------------------------------------------------------------------- + +/** + * Translate a raw /sre/snapshot response into an SLOSnapshot. + * + * @param raw - Untyped JSON from the REST endpoint + * @returns Typed SLOSnapshot or null if validation fails + */ +export function translateSLO(raw: unknown): SLOSnapshot | null { + if (!isObject(raw)) { return null; } + + const sli = isObject(raw.sli) ? raw.sli : {}; + const rawRate = sli.pass_rate ?? sli.passRate; + const hasRate = rawRate !== undefined && rawRate !== null; + const passRate = hasRate ? clampRate(rawRate) : null; + + // If a rate was provided but invalid (negative, Infinity, NaN), reject + if (hasRate && passRate === null) { return null; } + + const rawTotal = sli.total_decisions ?? sli.totalDecisions; + const totalDecisions = isFiniteNumber(rawTotal) ? rawTotal : 0; + + // Only populate compliance from live data — zeros for fields not yet served by the REST endpoint + const compliancePercent = passRate !== null ? passRate * 100 : 0; + const violationsToday = passRate !== null + ? Math.max(0, totalDecisions - Math.round(totalDecisions * passRate)) : 0; + + return { + availability: { currentPercent: 0, targetPercent: 0, errorBudgetRemainingPercent: 0, burnRate: 0 }, + latency: { p50Ms: 0, p95Ms: 0, p99Ms: 0, targetMs: 0, errorBudgetRemainingPercent: 0 }, + policyCompliance: { + totalEvaluations: totalDecisions, + violationsToday, + compliancePercent, + trend: 'stable', + }, + trustScore: { meanScore: 0, minScore: 0, agentsBelowThreshold: 0, distribution: [0, 0, 0, 0] }, + fetchedAt: new Date().toISOString(), + }; +} + +// --------------------------------------------------------------------------- +// Topology translator +// --------------------------------------------------------------------------- + +const CIRCUIT_STATES = new Set(['closed', 'open', 'half-open']); + +function translateOneAgent(raw: unknown): AgentNode | null { + if (!isObject(raw)) { return null; } + const did = raw.agentId ?? raw.agent_id; + if (typeof did !== 'string' || did.length === 0) { return null; } + + const successRate = clampRate(raw.successRate ?? raw.success_rate); + const rawCircuit = raw.circuitState ?? raw.circuit_state; + const circuitState = (typeof rawCircuit === 'string' && CIRCUIT_STATES.has(rawCircuit)) + ? rawCircuit as AgentNode['circuitState'] : 'closed'; + const lastActive = truncate(raw.lastActiveAt ?? raw.last_active_at ?? '', MAX_STRING_LENGTH); + const rawTaskCount = raw.taskCount ?? raw.task_count; + const rawAvgLatency = raw.avgLatencyMs ?? raw.avg_latency_ms; + const rawTrustStage = raw.trustStage ?? raw.trust_stage; + const taskCount = isFiniteNumber(rawTaskCount) ? rawTaskCount : undefined; + const avgLatency = isFiniteNumber(rawAvgLatency) ? rawAvgLatency : undefined; + const trustStage = typeof rawTrustStage === 'string' ? truncate(rawTrustStage, 10) : undefined; + + return { + did: truncate(did), + trustScore: Math.round((successRate ?? 0.5) * 1000), + ring: ExecutionRing.Ring2User, + registeredAt: lastActive, + lastActivity: lastActive, + capabilities: [], + circuitState, + taskCount, + avgLatencyMs: avgLatency, + trustStage, + }; +} + +/** + * Translate a raw /sre/fleet or /sre/snapshot fleet array into AgentNode[]. + * + * @param raw - Untyped JSON (expects { fleet: [...] } or { agents: [...] }) + * @returns AgentNode array (empty on invalid input, never null) + */ +export function translateTopology(raw: unknown): AgentNode[] { + if (!isObject(raw)) { return []; } + const fleet = Array.isArray(raw.fleet) ? raw.fleet + : Array.isArray(raw.agents) ? raw.agents : []; + + return fleet + .slice(0, MAX_FLEET_AGENTS) + .map(translateOneAgent) + .filter((a): a is AgentNode => a !== null); +} + +// --------------------------------------------------------------------------- +// Policy translator +// --------------------------------------------------------------------------- + +const VALID_ACTIONS = new Set(['ALLOW', 'DENY', 'AUDIT', 'BLOCK']); + +function translateOnePolicy(raw: unknown, index: number): PolicyRule | null { + if (!isObject(raw)) { return null; } + const name = typeof raw.name === 'string' ? truncate(raw.name) : `policy-${index}`; + const action = VALID_ACTIONS.has(raw.action as PolicyAction) + ? raw.action as PolicyAction : 'AUDIT'; + + return { + id: typeof raw.id === 'string' ? truncate(raw.id, 64) : `rule-${index}`, + name, + description: truncate(raw.description ?? ''), + action, + pattern: truncate(raw.pattern ?? '*'), + scope: 'global', + enabled: raw.enabled !== false, + evaluationsToday: isFiniteNumber(raw.evaluationsToday) ? raw.evaluationsToday : 0, + violationsToday: isFiniteNumber(raw.violationsToday) ? raw.violationsToday : 0, + }; +} + +/** ASI coverage entry from agent-failsafe. */ +export interface ASICoverageEntry { + label: string; + covered: boolean; + feature: string; +} + +/** + * Translate a raw /sre/snapshot response into a PolicySnapshot. + * + * @param raw - Untyped JSON from the REST endpoint + * @returns Typed PolicySnapshot (empty on invalid input, never null) + */ +export function translatePolicy(raw: unknown): PolicySnapshot { + if (!isObject(raw)) { + return { rules: [], recentViolations: [], totalEvaluationsToday: 0, totalViolationsToday: 0 }; + } + + const rawPolicies = Array.isArray(raw.policies) ? raw.policies : []; + const rules = rawPolicies + .slice(0, MAX_POLICIES) + .map(translateOnePolicy) + .filter((r): r is PolicyRule => r !== null); + + const rawEvents = Array.isArray(raw.auditEvents) ? raw.auditEvents : []; + const violations = rawEvents.slice(0, MAX_AUDIT_EVENTS).map((e: unknown, i: number) => { + if (!isObject(e)) { return null; } + return { + id: typeof e.id === 'string' ? truncate(e.id, 64) : `evt-${i}`, + ruleId: truncate(e.ruleId ?? ''), + ruleName: truncate(e.ruleName ?? e.type ?? ''), + timestamp: safeDate(e.timestamp), + context: truncate(e.context ?? e.details ?? ''), + action: 'AUDIT' as PolicyAction, + }; + }).filter((v): v is NonNullable => v !== null); + + const asiCoverage = isObject(raw.asiCoverage) + ? raw.asiCoverage as Record : undefined; + + return { + rules, + recentViolations: violations, + totalEvaluationsToday: rules.reduce((sum, r) => sum + r.evaluationsToday, 0), + totalViolationsToday: violations.length, + asiCoverage, + fetchedAt: new Date().toISOString(), + }; +} diff --git a/packages/agent-os/extensions/vscode/src/statusBar.ts b/packages/agent-os-vscode/src/statusBar.ts similarity index 100% rename from packages/agent-os/extensions/vscode/src/statusBar.ts rename to packages/agent-os-vscode/src/statusBar.ts diff --git a/packages/agent-os-vscode/src/test/export/reportGenerator.test.ts b/packages/agent-os-vscode/src/test/export/reportGenerator.test.ts new file mode 100644 index 00000000..d87d8b7a --- /dev/null +++ b/packages/agent-os-vscode/src/test/export/reportGenerator.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Report Generator Tests + * + * Unit tests for the governance report generator. + */ + +import * as assert from 'assert'; +import { ReportGenerator, ReportData } from '../../export/ReportGenerator'; +import { SLOSnapshot } from '../../views/sloTypes'; +import { AgentNode, BridgeStatus, DelegationChain, ExecutionRing } from '../../views/topologyTypes'; + +suite('ReportGenerator', () => { + let generator: ReportGenerator; + let mockReportData: ReportData; + + setup(() => { + generator = new ReportGenerator(); + + const sloSnapshot: SLOSnapshot = { + availability: { + currentPercent: 99.9, + targetPercent: 99.5, + errorBudgetRemainingPercent: 80, + burnRate: 1.2, + }, + latency: { + p50Ms: 45, + p95Ms: 120, + p99Ms: 250, + targetMs: 300, + errorBudgetRemainingPercent: 65, + }, + policyCompliance: { + totalEvaluations: 1500, + violationsToday: 3, + compliancePercent: 99.8, + trend: 'up', + }, + trustScore: { + meanScore: 820, + minScore: 450, + agentsBelowThreshold: 1, + distribution: [2, 5, 12, 25], + }, + }; + + const agents: AgentNode[] = [ + { + did: 'did:mesh:test1', + trustScore: 900, + ring: ExecutionRing.Ring1Supervisor, + registeredAt: '2026-03-20T00:00:00Z', + lastActivity: '2026-03-22T12:00:00Z', + capabilities: ['tool_call'], + }, + ]; + + const bridges: BridgeStatus[] = [ + { protocol: 'A2A', connected: true, peerCount: 3 }, + ]; + + const delegations: DelegationChain[] = []; + + mockReportData = { + sloSnapshot, + agents, + bridges, + delegations, + auditEvents: [ + { timestamp: new Date(), type: 'test', details: { action: 'allowed' } }, + ], + timeRange: { + start: new Date('2026-03-21T00:00:00Z'), + end: new Date('2026-03-22T00:00:00Z'), + }, + }; + }); + + test('generates valid HTML', () => { + const html = generator.generate(mockReportData); + + assert.ok(html.startsWith('')); + assert.ok(html.includes('')); + }); + + test('embeds data as JSON script tag', () => { + const html = generator.generate(mockReportData); + + assert.ok(html.includes('type="application/json"')); + assert.ok(html.includes('report-data')); + }); + + test('includes Chart.js CDN reference', () => { + const html = generator.generate(mockReportData); + + assert.ok(html.includes('chart.js') || html.includes('Chart')); + }); + + test('includes timestamp watermark', () => { + const html = generator.generate(mockReportData); + + assert.ok(html.includes('Generated:')); + }); + + test('includes time range', () => { + const html = generator.generate(mockReportData); + + assert.ok(html.includes('Period:') || html.includes('2026-03-21')); + }); + + test('includes SLO metrics section', () => { + const html = generator.generate(mockReportData); + + assert.ok(html.includes('SLO') || html.includes('slo')); + }); + + test('includes topology section', () => { + const html = generator.generate(mockReportData); + + assert.ok(html.includes('Topology') || html.includes('Agent')); + }); + + test('includes audit section', () => { + const html = generator.generate(mockReportData); + + assert.ok(html.includes('Audit')); + }); + + test('includes print-friendly CSS', () => { + const html = generator.generate(mockReportData); + + assert.ok(html.includes('@media print') || html.includes('print')); + }); +}); diff --git a/packages/agent-os-vscode/src/test/export/storageProviders.test.ts b/packages/agent-os-vscode/src/test/export/storageProviders.test.ts new file mode 100644 index 00000000..b97badd8 --- /dev/null +++ b/packages/agent-os-vscode/src/test/export/storageProviders.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Storage Provider Tests + * + * Unit tests for storage providers including credential validation. + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { LocalStorageProvider } from '../../export/LocalStorageProvider'; +import { CredentialError } from '../../export/CredentialError'; + +suite('StorageProviders', () => { + suite('LocalStorageProvider', () => { + let tempDir: string; + let provider: LocalStorageProvider; + + setup(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-os-test-')); + provider = new LocalStorageProvider(tempDir); + }); + + teardown(() => { + // Clean up temp directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test('validates writable directory', async () => { + await provider.validateCredentials(); + // Should not throw + }); + + test('throws CredentialError for unwritable directory', async () => { + const badProvider = new LocalStorageProvider('/nonexistent/path/that/does/not/exist'); + + try { + await badProvider.validateCredentials(); + assert.fail('Should have thrown CredentialError'); + } catch (e) { + assert.ok(e instanceof CredentialError); + assert.strictEqual((e as CredentialError).reason, 'invalid'); + } + }); + + test('uploads file and returns file:// URL', async () => { + const html = 'Test Report'; + const filename = 'test-report.html'; + + const result = await provider.upload(html, filename); + + assert.ok(result.url.startsWith('file://')); + assert.ok(result.url.includes(filename)); + assert.ok(result.expiresAt > new Date()); + }); + + test('creates file on disk', async () => { + const html = 'Content Check'; + const filename = 'content-check.html'; + + const result = await provider.upload(html, filename); + const filePath = result.url.replace('file://', ''); + + assert.ok(fs.existsSync(filePath)); + const content = fs.readFileSync(filePath, 'utf-8'); + assert.strictEqual(content, html); + }); + }); + + suite('CredentialError', () => { + test('contains provider and reason', () => { + const error = new CredentialError( + 'Test error message', + 's3', + 'expired' + ); + + assert.strictEqual(error.provider, 's3'); + assert.strictEqual(error.reason, 'expired'); + assert.strictEqual(error.message, 'Test error message'); + assert.strictEqual(error.name, 'CredentialError'); + }); + + test('is instanceof Error', () => { + const error = new CredentialError('Test', 'azure', 'missing'); + assert.ok(error instanceof Error); + }); + }); +}); diff --git a/packages/agent-os-vscode/src/test/governanceStatusBarTypes.test.ts b/packages/agent-os-vscode/src/test/governanceStatusBarTypes.test.ts new file mode 100644 index 00000000..72e510d2 --- /dev/null +++ b/packages/agent-os-vscode/src/test/governanceStatusBarTypes.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Unit tests for pure functions and constants from governanceStatusBarTypes.ts. + * + * All tested functions are pure with no VS Code dependency. + */ + +import * as assert from 'assert'; +import { + formatTime, + truncateAgentDid, + buildModeTooltip, + buildViolationTooltip, + MODE_LABELS, + MODE_DESCRIPTIONS, +} from '../governanceStatusBarTypes'; + +suite('governanceStatusBarTypes — formatTime', () => { + test('returns a non-empty string', () => { + const result = formatTime(new Date(2026, 2, 22, 14, 30, 45)); + assert.strictEqual(typeof result, 'string'); + assert.ok(result.length > 0); + }); +}); + +suite('governanceStatusBarTypes — truncateAgentDid', () => { + test('truncates a long DID to expected format', () => { + const did = 'did:mesh:' + 'a'.repeat(40); + const result = truncateAgentDid(did); + assert.ok(result.length <= 25, `expected max 25 chars, got ${result.length}`); + assert.ok(result.includes('...')); + }); + + test('preserves first 16 chars of a long DID', () => { + const did = 'did:mesh:' + 'a'.repeat(40); + const result = truncateAgentDid(did); + assert.strictEqual(result.slice(0, 16), did.slice(0, 16)); + }); + + test('preserves last 6 chars of a long DID', () => { + const did = 'did:mesh:' + 'a'.repeat(40); + const result = truncateAgentDid(did); + assert.strictEqual(result.slice(-6), did.slice(-6)); + }); + + test('returns unchanged when DID is 24 chars or shorter', () => { + const did = 'did:mesh:short'; + assert.strictEqual(truncateAgentDid(did), did); + }); + + test('returns unchanged at exactly 24 chars', () => { + const did = 'did:mesh:exactly24chars!'; + assert.strictEqual(did.length, 24); + assert.strictEqual(truncateAgentDid(did), did); + }); +}); + +suite('governanceStatusBarTypes — buildModeTooltip', () => { + test('contains the mode label for strict', () => { + const tip = buildModeTooltip('strict', 5, new Date()); + assert.ok(tip.includes('Strict')); + }); + + test('contains active policies count', () => { + const tip = buildModeTooltip('strict', 5, new Date()); + assert.ok(tip.includes('Active policies: 5')); + }); + + test('contains mode description for permissive', () => { + const tip = buildModeTooltip('permissive', 0, new Date()); + assert.ok(tip.includes('Permissive')); + assert.ok(tip.includes('logged but not blocked')); + }); + + test('contains audit-only description', () => { + const tip = buildModeTooltip('audit-only', 3, new Date()); + assert.ok(tip.includes('Audit-Only')); + }); + + test('includes click instruction', () => { + const tip = buildModeTooltip('strict', 1, new Date()); + assert.ok(tip.includes('Click to configure policy')); + }); +}); + +suite('governanceStatusBarTypes — buildViolationTooltip', () => { + test('contains violation count with plural', () => { + const tip = buildViolationTooltip(3, { errors: 2, warnings: 1 }); + assert.ok(tip.includes('3 violations')); + }); + + test('uses singular for exactly 1 violation', () => { + const tip = buildViolationTooltip(1, { errors: 1, warnings: 0 }); + assert.ok(tip.includes('1 violation today')); + assert.ok(!tip.includes('1 violations')); + }); + + test('shows error and warning breakdown', () => { + const tip = buildViolationTooltip(3, { errors: 2, warnings: 1 }); + assert.ok(tip.includes('Errors: 2')); + assert.ok(tip.includes('Warnings: 1')); + }); + + test('handles zero violations without crashing', () => { + const tip = buildViolationTooltip(0, { errors: 0, warnings: 0 }); + assert.ok(tip.includes('0 violations')); + }); + + test('includes last violation time when provided', () => { + const lastViolation = new Date(2026, 2, 22, 10, 15, 30); + const tip = buildViolationTooltip(1, { errors: 1, warnings: 0, lastViolation }); + assert.ok(tip.includes('Last violation:')); + }); + + test('omits last violation line when not provided', () => { + const tip = buildViolationTooltip(2, { errors: 1, warnings: 1 }); + assert.ok(!tip.includes('Last violation:')); + }); + + test('includes click instruction', () => { + const tip = buildViolationTooltip(0, { errors: 0, warnings: 0 }); + assert.ok(tip.includes('Click to view audit log')); + }); +}); + +suite('governanceStatusBarTypes — MODE_LABELS', () => { + test('strict maps to Strict', () => { + assert.strictEqual(MODE_LABELS['strict'], 'Strict'); + }); + + test('permissive maps to Permissive', () => { + assert.strictEqual(MODE_LABELS['permissive'], 'Permissive'); + }); + + test('audit-only maps to Audit-Only', () => { + assert.strictEqual(MODE_LABELS['audit-only'], 'Audit-Only'); + }); +}); + +suite('governanceStatusBarTypes — MODE_DESCRIPTIONS', () => { + test('each mode has a non-empty description', () => { + const modes: Array<'strict' | 'permissive' | 'audit-only'> = [ + 'strict', 'permissive', 'audit-only', + ]; + for (const mode of modes) { + assert.ok(MODE_DESCRIPTIONS[mode].length > 0, `${mode} description is empty`); + } + }); +}); diff --git a/packages/agent-os-vscode/src/test/language/governanceCodeActions.test.ts b/packages/agent-os-vscode/src/test/language/governanceCodeActions.test.ts new file mode 100644 index 00000000..a8693314 --- /dev/null +++ b/packages/agent-os-vscode/src/test/language/governanceCodeActions.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Unit tests for getSuppressComment from governanceCodeActions.ts. + * + * getSuppressComment is a pure string function with no VS Code dependency. + * QUICK_FIXES and GovernanceCodeActionProvider are skipped because they use + * vscode.WorkspaceEdit and vscode.CodeAction. + */ + +import * as assert from 'assert'; +import { getSuppressComment } from '../../language/governanceCodeActions'; + +suite('governanceCodeActions — getSuppressComment', () => { + test('returns noqa comment for python', () => { + assert.strictEqual(getSuppressComment('python', 'GOV001'), ' # noqa: GOV001'); + }); + + test('returns hash-style ignore for yaml', () => { + assert.strictEqual(getSuppressComment('yaml', 'GOV003'), ' # @agent-os-ignore GOV003'); + }); + + test('returns hash-style ignore for json', () => { + assert.strictEqual(getSuppressComment('json', 'GOV004'), ' # @agent-os-ignore GOV004'); + }); + + test('returns slash-style ignore for typescript', () => { + assert.strictEqual( + getSuppressComment('typescript', 'GOV001'), + ' // @agent-os-ignore GOV001', + ); + }); + + test('returns slash-style ignore for javascript', () => { + assert.strictEqual( + getSuppressComment('javascript', 'GOV002'), + ' // @agent-os-ignore GOV002', + ); + }); + + test('defaults to slash-style ignore for unknown language', () => { + assert.strictEqual( + getSuppressComment('unknown', 'GOV005'), + ' // @agent-os-ignore GOV005', + ); + }); + + test('defaults to slash-style ignore for empty language id', () => { + assert.strictEqual( + getSuppressComment('', 'GOV099'), + ' // @agent-os-ignore GOV099', + ); + }); + + test('all comments start with two-space indent', () => { + const languages = ['python', 'yaml', 'json', 'typescript', 'javascript', 'rust']; + for (const lang of languages) { + const comment = getSuppressComment(lang, 'GOV001'); + assert.ok(comment.startsWith(' '), `${lang}: expected 2-space indent`); + } + }); +}); + +suite('governanceCodeActions — getSuppressComment (extended)', () => { + test('preserves the exact GOV code in the output', () => { + const codes = ['GOV001', 'GOV003', 'GOV005', 'GOV101', 'GOV103', 'GOV201', 'GOV202']; + for (const code of codes) { + const comment = getSuppressComment('python', code); + assert.ok(comment.includes(code), + `Comment for ${code} should contain the exact code`); + } + }); + + test('python comments use hash-style (not slash-style)', () => { + const comment = getSuppressComment('python', 'GOV101'); + assert.ok(comment.includes('#'), 'Python comment should use # style'); + assert.ok(!comment.includes('//'), 'Python comment should not use // style'); + }); + + test('yaml and json use identical comment format', () => { + const yamlComment = getSuppressComment('yaml', 'GOV003'); + const jsonComment = getSuppressComment('json', 'GOV003'); + assert.strictEqual(yamlComment, jsonComment, + 'yaml and json should produce identical suppress comments'); + }); + + test('typescript and javascript use identical comment format prefix', () => { + const tsComment = getSuppressComment('typescript', 'GOV001'); + const jsComment = getSuppressComment('javascript', 'GOV001'); + assert.strictEqual(tsComment, jsComment, + 'typescript and javascript should produce identical suppress comments'); + }); + + test('python uses noqa prefix, not @agent-os-ignore', () => { + const comment = getSuppressComment('python', 'GOV102'); + assert.ok(comment.includes('noqa:'), 'Python should use noqa: prefix'); + assert.ok(!comment.includes('@agent-os-ignore'), + 'Python should not use @agent-os-ignore'); + }); + + test('non-python languages use @agent-os-ignore prefix', () => { + const languages = ['yaml', 'json', 'typescript', 'javascript', 'go', 'rust']; + for (const lang of languages) { + const comment = getSuppressComment(lang, 'GOV001'); + assert.ok(comment.includes('@agent-os-ignore'), + `${lang} should use @agent-os-ignore prefix`); + } + }); + + test('output is a single line (no newlines)', () => { + const languages = ['python', 'yaml', 'json', 'typescript', 'javascript', '']; + for (const lang of languages) { + const comment = getSuppressComment(lang, 'GOV005'); + assert.ok(!comment.includes('\n'), + `Comment for "${lang}" should not contain newlines`); + } + }); + + test('handles GOV2xx (cross-language) codes correctly', () => { + const comment = getSuppressComment('typescript', 'GOV201'); + assert.strictEqual(comment, ' // @agent-os-ignore GOV201'); + }); + + test('handles GOV1xx (python) codes in non-python contexts', () => { + // A python-specific rule code used in yaml context should still work + const comment = getSuppressComment('yaml', 'GOV103'); + assert.strictEqual(comment, ' # @agent-os-ignore GOV103'); + }); +}); diff --git a/packages/agent-os-vscode/src/test/language/governanceDiagnosticProvider.test.ts b/packages/agent-os-vscode/src/test/language/governanceDiagnosticProvider.test.ts new file mode 100644 index 00000000..0d858f78 --- /dev/null +++ b/packages/agent-os-vscode/src/test/language/governanceDiagnosticProvider.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Unit tests for governance diagnostic constants and the isValidAgentDID helper. + * + * GovernanceDiagnosticProvider, buildPolicyFileRules, buildPythonRules, and + * buildCrossLanguageRules all depend on vscode APIs and cannot be tested here. + * We test the pure constants and helper functions that are exported. + */ + +import * as assert from 'assert'; +import { + DIAGNOSTIC_SOURCE, + DIAGNOSTIC_COLLECTION_NAME, + VALID_POLICY_ACTIONS, + VALID_RING_VALUES, + TRUST_SCORE_MIN, + TRUST_SCORE_MAX, + SUPPORTED_LANGUAGES, +} from '../../language/governanceRules'; +import { isValidAgentDID } from '../../language/governanceIntegrationRules'; + +suite('governanceDiagnosticProvider — DIAGNOSTIC_SOURCE', () => { + test('is a non-empty string', () => { + assert.ok(typeof DIAGNOSTIC_SOURCE === 'string'); + assert.ok(DIAGNOSTIC_SOURCE.length > 0, 'DIAGNOSTIC_SOURCE should not be empty'); + }); + + test('equals "Agent OS Governance"', () => { + assert.strictEqual(DIAGNOSTIC_SOURCE, 'Agent OS Governance'); + }); +}); + +suite('governanceDiagnosticProvider — DIAGNOSTIC_COLLECTION_NAME', () => { + test('is a non-empty string', () => { + assert.ok(typeof DIAGNOSTIC_COLLECTION_NAME === 'string'); + assert.ok(DIAGNOSTIC_COLLECTION_NAME.length > 0); + }); + + test('uses dotted namespace format', () => { + assert.ok(DIAGNOSTIC_COLLECTION_NAME.includes('.'), + 'Collection name should use dotted namespace format'); + }); +}); + +suite('governanceDiagnosticProvider — VALID_POLICY_ACTIONS', () => { + test('contains ALLOW', () => { + assert.ok(VALID_POLICY_ACTIONS.includes('ALLOW')); + }); + + test('contains DENY', () => { + assert.ok(VALID_POLICY_ACTIONS.includes('DENY')); + }); + + test('contains AUDIT', () => { + assert.ok(VALID_POLICY_ACTIONS.includes('AUDIT')); + }); + + test('contains BLOCK', () => { + assert.ok(VALID_POLICY_ACTIONS.includes('BLOCK')); + }); + + test('has exactly 4 actions', () => { + assert.strictEqual(VALID_POLICY_ACTIONS.length, 4); + }); + + test('all actions are uppercase strings', () => { + for (const action of VALID_POLICY_ACTIONS) { + assert.strictEqual(action, action.toUpperCase(), + `Action "${action}" should be uppercase`); + } + }); +}); + +suite('governanceDiagnosticProvider — VALID_RING_VALUES', () => { + test('contains 0, 1, 2, 3', () => { + assert.deepStrictEqual(VALID_RING_VALUES, [0, 1, 2, 3]); + }); + + test('maps to ExecutionRing enum values', () => { + // Ring0Root=0, Ring1Supervisor=1, Ring2User=2, Ring3Sandbox=3 + for (const ring of VALID_RING_VALUES) { + assert.ok(ring >= 0 && ring <= 3, + `Ring value ${ring} should be between 0 and 3`); + } + }); +}); + +suite('governanceDiagnosticProvider — trust score bounds', () => { + test('TRUST_SCORE_MIN is 0', () => { + assert.strictEqual(TRUST_SCORE_MIN, 0); + }); + + test('TRUST_SCORE_MAX is 1000', () => { + assert.strictEqual(TRUST_SCORE_MAX, 1000); + }); + + test('range covers 1001 discrete values', () => { + assert.strictEqual(TRUST_SCORE_MAX - TRUST_SCORE_MIN + 1, 1001); + }); +}); + +suite('governanceDiagnosticProvider — SUPPORTED_LANGUAGES', () => { + test('is a non-empty array', () => { + assert.ok(Array.isArray(SUPPORTED_LANGUAGES)); + assert.ok(SUPPORTED_LANGUAGES.length > 0); + }); + + test('includes all expected languages', () => { + const expected = ['javascript', 'typescript', 'python', 'yaml', 'json']; + for (const lang of expected) { + assert.ok(SUPPORTED_LANGUAGES.includes(lang), + `Should include "${lang}"`); + } + }); + + test('includes shell script variants', () => { + assert.ok(SUPPORTED_LANGUAGES.includes('shellscript')); + assert.ok(SUPPORTED_LANGUAGES.includes('bash')); + assert.ok(SUPPORTED_LANGUAGES.includes('sh')); + }); + + test('all entries are lowercase strings', () => { + for (const lang of SUPPORTED_LANGUAGES) { + assert.strictEqual(lang, lang.toLowerCase(), + `Language "${lang}" should be lowercase`); + } + }); +}); + +suite('governanceDiagnosticProvider — isValidAgentDID edge cases', () => { + test('rejects did:mesh with special characters', () => { + assert.strictEqual(isValidAgentDID('did:mesh:' + 'a'.repeat(31) + '!'), false); + }); + + test('rejects did:mesh with spaces', () => { + assert.strictEqual(isValidAgentDID('did:mesh: ' + 'a'.repeat(32)), false); + }); + + test('rejects did:myth with numeric persona', () => { + assert.strictEqual(isValidAgentDID('did:myth:123:' + 'a'.repeat(32)), false); + }); + + test('rejects did:myth with empty persona', () => { + assert.strictEqual(isValidAgentDID('did:myth::' + 'a'.repeat(32)), false); + }); + + test('accepts did:mesh with exactly 32 hex chars', () => { + assert.strictEqual(isValidAgentDID('did:mesh:' + 'abcdef01'.repeat(4)), true); + }); + + test('accepts did:myth with any lowercase persona name', () => { + // The regex accepts any [a-z]+ persona, not just known ones + assert.strictEqual( + isValidAgentDID('did:myth:custompersona:' + 'a'.repeat(32)), + true, + ); + }); + + test('rejects did:myth with mixed-case persona', () => { + assert.strictEqual( + isValidAgentDID('did:myth:Sentinel:' + 'a'.repeat(32)), + false, + ); + }); +}); diff --git a/packages/agent-os-vscode/src/test/language/governanceIntegrationRules.test.ts b/packages/agent-os-vscode/src/test/language/governanceIntegrationRules.test.ts new file mode 100644 index 00000000..8f8eb1e7 --- /dev/null +++ b/packages/agent-os-vscode/src/test/language/governanceIntegrationRules.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Unit tests for isValidAgentDID from governanceIntegrationRules.ts. + * + * isValidAgentDID is a pure regex-based validator with no VS Code dependency. + * buildPythonRules and buildCrossLanguageRules are skipped because their rule + * objects reference vscode.DiagnosticSeverity. + */ + +import * as assert from 'assert'; +import { isValidAgentDID } from '../../language/governanceIntegrationRules'; + +suite('governanceIntegrationRules — isValidAgentDID (did:mesh format)', () => { + test('accepts valid did:mesh with 32 hex chars', () => { + assert.strictEqual(isValidAgentDID('did:mesh:' + 'a'.repeat(32)), true); + }); + + test('accepts valid did:mesh with mixed-case hex', () => { + assert.strictEqual(isValidAgentDID('did:mesh:' + 'A1B2'.repeat(8)), true); + }); + + test('accepts did:mesh with more than 32 hex chars', () => { + assert.strictEqual(isValidAgentDID('did:mesh:' + 'f'.repeat(64)), true); + }); + + test('rejects did:mesh with fewer than 32 hex chars', () => { + assert.strictEqual(isValidAgentDID('did:mesh:short'), false); + }); + + test('rejects did:mesh with 31 hex chars', () => { + assert.strictEqual(isValidAgentDID('did:mesh:' + 'a'.repeat(31)), false); + }); + + test('rejects did:mesh with non-hex characters', () => { + assert.strictEqual(isValidAgentDID('did:mesh:' + 'g'.repeat(32)), false); + }); +}); + +suite('governanceIntegrationRules — isValidAgentDID (did:myth format)', () => { + test('accepts valid did:myth:sentinel with 32 hex chars', () => { + assert.strictEqual( + isValidAgentDID('did:myth:sentinel:' + 'b'.repeat(32)), + true, + ); + }); + + test('accepts valid did:myth:judge with 32 hex chars', () => { + assert.strictEqual( + isValidAgentDID('did:myth:judge:' + 'c'.repeat(32)), + true, + ); + }); + + test('accepts valid did:myth:scrivener with 32 hex chars', () => { + assert.strictEqual( + isValidAgentDID('did:myth:scrivener:' + 'd'.repeat(32)), + true, + ); + }); + + test('accepts valid did:myth:overseer with 32 hex chars', () => { + assert.strictEqual( + isValidAgentDID('did:myth:overseer:' + 'e'.repeat(32)), + true, + ); + }); + + test('rejects did:myth with missing hash', () => { + assert.strictEqual(isValidAgentDID('did:myth:bad'), false); + }); + + test('rejects did:myth with 31 hex chars (too short)', () => { + assert.strictEqual( + isValidAgentDID('did:myth:sentinel:' + 'b'.repeat(31)), + false, + ); + }); + + test('rejects did:myth with 33 hex chars (too long)', () => { + assert.strictEqual( + isValidAgentDID('did:myth:sentinel:' + 'b'.repeat(33)), + false, + ); + }); +}); + +suite('governanceIntegrationRules — isValidAgentDID (invalid inputs)', () => { + test('rejects wrong scheme prefix', () => { + assert.strictEqual(isValidAgentDID('did:other:something'), false); + }); + + test('rejects empty string', () => { + assert.strictEqual(isValidAgentDID(''), false); + }); + + test('rejects non-DID string', () => { + assert.strictEqual(isValidAgentDID('not-a-did'), false); + }); + + test('rejects bare prefix without hash', () => { + assert.strictEqual(isValidAgentDID('did:mesh:'), false); + }); + + test('rejects did:myth with uppercase persona', () => { + assert.strictEqual( + isValidAgentDID('did:myth:SENTINEL:' + 'a'.repeat(32)), + false, + ); + }); +}); diff --git a/packages/agent-os-vscode/src/test/language/governanceRules.test.ts b/packages/agent-os-vscode/src/test/language/governanceRules.test.ts new file mode 100644 index 00000000..918cc07a --- /dev/null +++ b/packages/agent-os-vscode/src/test/language/governanceRules.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Unit tests for constants and pure values exported from governanceRules.ts. + * + * buildPolicyFileRules and isPolicyFile are skipped because they depend on + * vscode.DiagnosticSeverity and vscode.TextDocument. + */ + +import * as assert from 'assert'; +import { + DIAGNOSTIC_SOURCE, + DIAGNOSTIC_COLLECTION_NAME, + VALID_POLICY_ACTIONS, + VALID_RING_VALUES, + TRUST_SCORE_MIN, + TRUST_SCORE_MAX, + SUPPORTED_LANGUAGES, +} from '../../language/governanceRules'; + +suite('governanceRules — DIAGNOSTIC_SOURCE', () => { + test('equals Agent OS Governance', () => { + assert.strictEqual(DIAGNOSTIC_SOURCE, 'Agent OS Governance'); + }); +}); + +suite('governanceRules — DIAGNOSTIC_COLLECTION_NAME', () => { + test('equals agentOS.governance', () => { + assert.strictEqual(DIAGNOSTIC_COLLECTION_NAME, 'agentOS.governance'); + }); +}); + +suite('governanceRules — VALID_POLICY_ACTIONS', () => { + test('contains ALLOW, DENY, AUDIT, BLOCK', () => { + assert.deepStrictEqual(VALID_POLICY_ACTIONS, ['ALLOW', 'DENY', 'AUDIT', 'BLOCK']); + }); + + test('has exactly four entries', () => { + assert.strictEqual(VALID_POLICY_ACTIONS.length, 4); + }); +}); + +suite('governanceRules — VALID_RING_VALUES', () => { + test('contains rings 0 through 3', () => { + assert.deepStrictEqual(VALID_RING_VALUES, [0, 1, 2, 3]); + }); +}); + +suite('governanceRules — trust score bounds', () => { + test('TRUST_SCORE_MIN is 0', () => { + assert.strictEqual(TRUST_SCORE_MIN, 0); + }); + + test('TRUST_SCORE_MAX is 1000', () => { + assert.strictEqual(TRUST_SCORE_MAX, 1000); + }); + + test('min is less than max', () => { + assert.ok(TRUST_SCORE_MIN < TRUST_SCORE_MAX); + }); +}); + +suite('governanceRules — SUPPORTED_LANGUAGES', () => { + test('includes python', () => { + assert.ok(SUPPORTED_LANGUAGES.includes('python')); + }); + + test('includes yaml', () => { + assert.ok(SUPPORTED_LANGUAGES.includes('yaml')); + }); + + test('includes typescript', () => { + assert.ok(SUPPORTED_LANGUAGES.includes('typescript')); + }); + + test('includes javascript', () => { + assert.ok(SUPPORTED_LANGUAGES.includes('javascript')); + }); + + test('includes json', () => { + assert.ok(SUPPORTED_LANGUAGES.includes('json')); + }); + + test('includes shell variants', () => { + assert.ok(SUPPORTED_LANGUAGES.includes('shellscript')); + assert.ok(SUPPORTED_LANGUAGES.includes('bash')); + assert.ok(SUPPORTED_LANGUAGES.includes('sh')); + }); + + test('has expected count', () => { + assert.strictEqual(SUPPORTED_LANGUAGES.length, 8); + }); +}); diff --git a/packages/agent-os-vscode/src/test/mockBackend/MockPolicyBackend.test.ts b/packages/agent-os-vscode/src/test/mockBackend/MockPolicyBackend.test.ts new file mode 100644 index 00000000..f9c96318 --- /dev/null +++ b/packages/agent-os-vscode/src/test/mockBackend/MockPolicyBackend.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Mock Policy Backend Unit Tests + */ + +import * as assert from 'assert'; +import { createMockPolicyBackend } from '../../mockBackend/MockPolicyBackend'; + +suite('MockPolicyBackend Test Suite', () => { + test('Returns 5 default rules', async () => { + const backend = createMockPolicyBackend(); + const snapshot = await backend.getSnapshot(); + assert.strictEqual(snapshot.rules.length, 5); + }); + + test('All rules have required fields', async () => { + const backend = createMockPolicyBackend(); + const snapshot = await backend.getSnapshot(); + + snapshot.rules.forEach(rule => { + assert.ok(rule.id, 'Rule should have id'); + assert.ok(rule.name, 'Rule should have name'); + assert.ok(rule.description !== undefined, 'Rule should have description'); + assert.ok(['ALLOW', 'DENY', 'AUDIT', 'BLOCK'].includes(rule.action)); + assert.ok(rule.pattern !== undefined, 'Rule should have pattern'); + assert.ok(['file', 'tool', 'agent', 'global'].includes(rule.scope)); + assert.ok(typeof rule.enabled === 'boolean'); + assert.ok(typeof rule.evaluationsToday === 'number'); + assert.ok(typeof rule.violationsToday === 'number'); + }); + }); + + test('Returns recent violations', async () => { + const backend = createMockPolicyBackend(); + const snapshot = await backend.getSnapshot(); + assert.ok(snapshot.recentViolations.length > 0, 'Should have recent violations'); + + snapshot.recentViolations.forEach(v => { + assert.ok(v.id, 'Violation should have id'); + assert.ok(v.ruleId, 'Violation should have ruleId'); + assert.ok(v.ruleName, 'Violation should have ruleName'); + assert.ok(v.timestamp, 'Violation should have timestamp'); + assert.ok(v.context, 'Violation should have context'); + }); + }); + + test('Evaluation counts are at least the base values', async () => { + const backend = createMockPolicyBackend(); + const snapshot = await backend.getSnapshot(); + const total = snapshot.rules.reduce((s, r) => s + r.evaluationsToday, 0); + // Base: 342 + 156 + 28 + 89 + 0 = 615. Random adds 0-4 per enabled rule. + assert.ok(total >= 615, `Total evaluations should be >= base (615), got ${total}`); + }); + + test('Totals match rule sums', async () => { + const backend = createMockPolicyBackend(); + const snapshot = await backend.getSnapshot(); + + const evalSum = snapshot.rules.reduce((s, r) => s + r.evaluationsToday, 0); + const violSum = snapshot.rules.reduce((s, r) => s + r.violationsToday, 0); + + assert.strictEqual(snapshot.totalEvaluationsToday, evalSum); + assert.strictEqual(snapshot.totalViolationsToday, violSum); + }); + + test('Disabled rule has zero counts', async () => { + const backend = createMockPolicyBackend(); + const snapshot = await backend.getSnapshot(); + + const disabledRule = snapshot.rules.find(r => !r.enabled); + assert.ok(disabledRule, 'Should have at least one disabled rule'); + assert.strictEqual(disabledRule.evaluationsToday, 0); + assert.strictEqual(disabledRule.violationsToday, 0); + }); +}); diff --git a/packages/agent-os-vscode/src/test/mockBackend/MockSLOBackend.test.ts b/packages/agent-os-vscode/src/test/mockBackend/MockSLOBackend.test.ts new file mode 100644 index 00000000..e3a9f6cd --- /dev/null +++ b/packages/agent-os-vscode/src/test/mockBackend/MockSLOBackend.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Unit tests for MockSLOBackend. + * + * Validates the shape, bounds, and drift behavior of the simulated SLO data feed. + */ + +import * as assert from 'assert'; +import { createMockSLOBackend } from '../../mockBackend/MockSLOBackend'; + +suite('MockSLOBackend Test Suite', () => { + test('returns SLOSnapshot with all 4 required sections', async () => { + const backend = createMockSLOBackend(); + const snapshot = await backend.getSnapshot(); + + assert.ok(snapshot.availability, 'snapshot should have availability'); + assert.ok(snapshot.latency, 'snapshot should have latency'); + assert.ok(snapshot.policyCompliance, 'snapshot should have policyCompliance'); + assert.ok(snapshot.trustScore, 'snapshot should have trustScore'); + }); + + test('availability currentPercent is between 90 and 100', async () => { + const backend = createMockSLOBackend(); + for (let i = 0; i < 10; i++) { + const snapshot = await backend.getSnapshot(); + const val = snapshot.availability.currentPercent; + assert.ok(val >= 90 && val <= 100, + `availability currentPercent ${val} should be between 90 and 100`); + } + }); + + test('availability targetPercent is a positive number', async () => { + const backend = createMockSLOBackend(); + const snapshot = await backend.getSnapshot(); + assert.ok(typeof snapshot.availability.targetPercent === 'number'); + assert.ok(snapshot.availability.targetPercent > 0, + `targetPercent ${snapshot.availability.targetPercent} should be positive`); + }); + + test('latency P50 <= P95 <= P99 invariant holds over 10 calls', async () => { + const backend = createMockSLOBackend(); + for (let i = 0; i < 10; i++) { + const snapshot = await backend.getSnapshot(); + const { p50Ms, p95Ms, p99Ms } = snapshot.latency; + assert.ok(p50Ms <= p95Ms, + `Call ${i}: p50 (${p50Ms}) should be <= p95 (${p95Ms})`); + assert.ok(p95Ms <= p99Ms, + `Call ${i}: p95 (${p95Ms}) should be <= p99 (${p99Ms})`); + } + }); + + test('latency targetMs is positive', async () => { + const backend = createMockSLOBackend(); + const snapshot = await backend.getSnapshot(); + assert.ok(snapshot.latency.targetMs > 0, + `targetMs ${snapshot.latency.targetMs} should be positive`); + }); + + test('error budget remaining is between 0 and 100', async () => { + const backend = createMockSLOBackend(); + for (let i = 0; i < 5; i++) { + const snapshot = await backend.getSnapshot(); + const val = snapshot.availability.errorBudgetRemainingPercent; + assert.ok(val >= 0 && val <= 100, + `errorBudgetRemainingPercent ${val} should be between 0 and 100`); + } + }); + + test('burn rate is positive', async () => { + const backend = createMockSLOBackend(); + for (let i = 0; i < 5; i++) { + const snapshot = await backend.getSnapshot(); + assert.ok(snapshot.availability.burnRate > 0, + `burnRate ${snapshot.availability.burnRate} should be positive`); + } + }); + + test('trust score meanScore is between 0 and 1000', async () => { + const backend = createMockSLOBackend(); + for (let i = 0; i < 5; i++) { + const snapshot = await backend.getSnapshot(); + const val = snapshot.trustScore.meanScore; + assert.ok(val >= 0 && val <= 1000, + `meanScore ${val} should be between 0 and 1000`); + } + }); + + test('trust score distribution has exactly 4 elements', async () => { + const backend = createMockSLOBackend(); + const snapshot = await backend.getSnapshot(); + assert.strictEqual(snapshot.trustScore.distribution.length, 4, + 'distribution should have exactly 4 buckets'); + }); + + test('trust score distribution elements are non-negative', async () => { + const backend = createMockSLOBackend(); + const snapshot = await backend.getSnapshot(); + for (let i = 0; i < snapshot.trustScore.distribution.length; i++) { + assert.ok(snapshot.trustScore.distribution[i] >= 0, + `distribution[${i}] = ${snapshot.trustScore.distribution[i]} should be >= 0`); + } + }); + + test('values drift between consecutive calls (not all identical)', async () => { + const backend = createMockSLOBackend(); + const first = await backend.getSnapshot(); + let drifted = false; + for (let i = 0; i < 20; i++) { + const next = await backend.getSnapshot(); + if (next.availability.currentPercent !== first.availability.currentPercent || + next.latency.p50Ms !== first.latency.p50Ms || + next.trustScore.meanScore !== first.trustScore.meanScore) { + drifted = true; + break; + } + } + assert.ok(drifted, 'Values should drift between consecutive calls'); + }); + + test('policy compliance percent is between 0 and 100', async () => { + const backend = createMockSLOBackend(); + for (let i = 0; i < 5; i++) { + const snapshot = await backend.getSnapshot(); + const val = snapshot.policyCompliance.compliancePercent; + assert.ok(val >= 0 && val <= 100, + `compliancePercent ${val} should be between 0 and 100`); + } + }); + + test('policy compliance totalEvaluations increases over calls', async () => { + const backend = createMockSLOBackend(); + const first = await backend.getSnapshot(); + const second = await backend.getSnapshot(); + assert.ok(second.policyCompliance.totalEvaluations >= first.policyCompliance.totalEvaluations, + 'totalEvaluations should not decrease between calls'); + }); + + test('policy compliance trend is one of up, down, stable', async () => { + const backend = createMockSLOBackend(); + const snapshot = await backend.getSnapshot(); + assert.ok( + ['up', 'down', 'stable'].includes(snapshot.policyCompliance.trend), + `trend "${snapshot.policyCompliance.trend}" should be up, down, or stable`, + ); + }); + + test('latency errorBudgetRemainingPercent is between 0 and 100', async () => { + const backend = createMockSLOBackend(); + for (let i = 0; i < 5; i++) { + const snapshot = await backend.getSnapshot(); + const val = snapshot.latency.errorBudgetRemainingPercent; + assert.ok(val >= 0 && val <= 100, + `latency errorBudgetRemainingPercent ${val} should be between 0 and 100`); + } + }); +}); diff --git a/packages/agent-os-vscode/src/test/mockBackend/MockTopologyBackend.test.ts b/packages/agent-os-vscode/src/test/mockBackend/MockTopologyBackend.test.ts new file mode 100644 index 00000000..20b76258 --- /dev/null +++ b/packages/agent-os-vscode/src/test/mockBackend/MockTopologyBackend.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Unit tests for MockTopologyBackend. + * + * Validates the shape, bounds, and drift behavior of the simulated + * agent mesh topology data feed. + */ + +import * as assert from 'assert'; +import { createMockTopologyBackend } from '../../mockBackend/MockTopologyBackend'; + +suite('MockTopologyBackend Test Suite', () => { + suite('getAgents', () => { + test('returns an array with length > 0', () => { + const backend = createMockTopologyBackend(); + const agents = backend.getAgents(); + assert.ok(Array.isArray(agents), 'getAgents should return an array'); + assert.ok(agents.length > 0, 'agents array should not be empty'); + }); + + test('each agent has a did string starting with did:mesh:', () => { + const backend = createMockTopologyBackend(); + const agents = backend.getAgents(); + for (const agent of agents) { + assert.ok(typeof agent.did === 'string', 'did should be a string'); + assert.ok(agent.did.startsWith('did:mesh:'), + `did "${agent.did}" should start with "did:mesh:"`); + } + }); + + test('each agent trustScore is between 0 and 1000', () => { + const backend = createMockTopologyBackend(); + // Call multiple times to exercise drift + for (let i = 0; i < 5; i++) { + const agents = backend.getAgents(); + for (const agent of agents) { + assert.ok(agent.trustScore >= 0 && agent.trustScore <= 1000, + `trustScore ${agent.trustScore} for ${agent.did} should be between 0 and 1000`); + } + } + }); + + test('each agent ring is 0, 1, 2, or 3', () => { + const backend = createMockTopologyBackend(); + for (let i = 0; i < 5; i++) { + const agents = backend.getAgents(); + for (const agent of agents) { + assert.ok([0, 1, 2, 3].includes(agent.ring), + `ring ${agent.ring} for ${agent.did} should be 0, 1, 2, or 3`); + } + } + }); + + test('each agent has capabilities array', () => { + const backend = createMockTopologyBackend(); + const agents = backend.getAgents(); + for (const agent of agents) { + assert.ok(Array.isArray(agent.capabilities), + `capabilities for ${agent.did} should be an array`); + } + }); + + test('each agent has registeredAt and lastActivity strings', () => { + const backend = createMockTopologyBackend(); + const agents = backend.getAgents(); + for (const agent of agents) { + assert.ok(typeof agent.registeredAt === 'string', + 'registeredAt should be a string'); + assert.ok(typeof agent.lastActivity === 'string', + 'lastActivity should be a string'); + } + }); + + test('trust scores change between calls (drift behavior)', () => { + const backend = createMockTopologyBackend(); + const first = backend.getAgents(); + const firstScores = first.map(a => a.trustScore); + let drifted = false; + for (let i = 0; i < 20; i++) { + const next = backend.getAgents(); + const nextScores = next.map(a => a.trustScore); + if (nextScores.some((s, idx) => s !== firstScores[idx])) { + drifted = true; + break; + } + } + assert.ok(drifted, 'Trust scores should drift between calls'); + }); + }); + + suite('getBridges', () => { + test('returns an array with length > 0', () => { + const backend = createMockTopologyBackend(); + // Must call getAgents first to initialize callCount + backend.getAgents(); + const bridges = backend.getBridges(); + assert.ok(Array.isArray(bridges), 'getBridges should return an array'); + assert.ok(bridges.length > 0, 'bridges array should not be empty'); + }); + + test('each bridge has protocol, connected, and peerCount', () => { + const backend = createMockTopologyBackend(); + backend.getAgents(); + const bridges = backend.getBridges(); + for (const bridge of bridges) { + assert.ok(typeof bridge.protocol === 'string', + 'protocol should be a string'); + assert.ok(typeof bridge.connected === 'boolean', + 'connected should be a boolean'); + assert.ok(typeof bridge.peerCount === 'number', + 'peerCount should be a number'); + assert.ok(bridge.peerCount >= 0, + `peerCount ${bridge.peerCount} should be >= 0`); + } + }); + + test('includes expected protocol names', () => { + const backend = createMockTopologyBackend(); + backend.getAgents(); + const bridges = backend.getBridges(); + const protocols = bridges.map(b => b.protocol); + assert.ok(protocols.includes('A2A'), 'Should include A2A bridge'); + assert.ok(protocols.includes('MCP'), 'Should include MCP bridge'); + assert.ok(protocols.includes('IATP'), 'Should include IATP bridge'); + }); + }); + + suite('getDelegations', () => { + test('returns an array', () => { + const backend = createMockTopologyBackend(); + const delegations = backend.getDelegations(); + assert.ok(Array.isArray(delegations), 'getDelegations should return an array'); + }); + + test('returns at least one delegation', () => { + const backend = createMockTopologyBackend(); + const delegations = backend.getDelegations(); + assert.ok(delegations.length > 0, 'Should have at least one delegation'); + }); + + test('each delegation has fromDid, toDid, capability, expiresIn', () => { + const backend = createMockTopologyBackend(); + const delegations = backend.getDelegations(); + for (const d of delegations) { + assert.ok(typeof d.fromDid === 'string', 'fromDid should be a string'); + assert.ok(typeof d.toDid === 'string', 'toDid should be a string'); + assert.ok(typeof d.capability === 'string', 'capability should be a string'); + assert.ok(typeof d.expiresIn === 'string', 'expiresIn should be a string'); + } + }); + + test('delegation DIDs start with did:mesh:', () => { + const backend = createMockTopologyBackend(); + const delegations = backend.getDelegations(); + for (const d of delegations) { + assert.ok(d.fromDid.startsWith('did:mesh:'), + `fromDid "${d.fromDid}" should start with "did:mesh:"`); + assert.ok(d.toDid.startsWith('did:mesh:'), + `toDid "${d.toDid}" should start with "did:mesh:"`); + } + }); + + test('expiresIn values change between calls (drift behavior)', () => { + const backend = createMockTopologyBackend(); + const first = backend.getDelegations(); + const firstExpiry = first.map(d => d.expiresIn); + let changed = false; + for (let i = 0; i < 20; i++) { + const next = backend.getDelegations(); + const nextExpiry = next.map(d => d.expiresIn); + if (nextExpiry.some((e, idx) => e !== firstExpiry[idx])) { + changed = true; + break; + } + } + assert.ok(changed, 'Delegation expiresIn values should change between calls'); + }); + }); + + suite('returns fresh copies', () => { + test('getAgents returns a new array each call', () => { + const backend = createMockTopologyBackend(); + const a1 = backend.getAgents(); + const a2 = backend.getAgents(); + assert.notStrictEqual(a1, a2, 'Should return a new array reference'); + }); + + test('getBridges returns a new array each call', () => { + const backend = createMockTopologyBackend(); + backend.getAgents(); + const b1 = backend.getBridges(); + const b2 = backend.getBridges(); + assert.notStrictEqual(b1, b2, 'Should return a new array reference'); + }); + + test('getDelegations returns a new array each call', () => { + const backend = createMockTopologyBackend(); + const d1 = backend.getDelegations(); + const d2 = backend.getDelegations(); + assert.notStrictEqual(d1, d2, 'Should return a new array reference'); + }); + }); +}); diff --git a/packages/agent-os-vscode/src/test/observability/metricsExporter.test.ts b/packages/agent-os-vscode/src/test/observability/metricsExporter.test.ts new file mode 100644 index 00000000..7e3f4c22 --- /dev/null +++ b/packages/agent-os-vscode/src/test/observability/metricsExporter.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Metrics Exporter Tests + * + * Unit tests for the OpenTelemetry-compatible metrics exporter. + */ + +import * as assert from 'assert'; +import { MetricsExporter, GovernanceMetrics } from '../../observability/MetricsExporter'; + +suite('MetricsExporter', () => { + let exporter: MetricsExporter; + + setup(() => { + // Use a mock endpoint that won't actually receive requests in tests + exporter = new MetricsExporter('http://localhost:0/metrics'); + }); + + suite('GovernanceMetrics interface', () => { + test('accepts valid metrics object', () => { + const metrics: GovernanceMetrics = { + availability: 99.95, + latencyP99: 250, + compliancePercent: 98.5, + trustScoreMean: 820, + agentCount: 15, + violationsToday: 2, + timestamp: new Date().toISOString(), + }; + + assert.strictEqual(metrics.availability, 99.95); + assert.strictEqual(metrics.latencyP99, 250); + assert.strictEqual(metrics.compliancePercent, 98.5); + assert.strictEqual(metrics.trustScoreMean, 820); + assert.strictEqual(metrics.agentCount, 15); + assert.strictEqual(metrics.violationsToday, 2); + assert.ok(metrics.timestamp); + }); + }); + + suite('MetricsExporter', () => { + test('has push method', () => { + assert.ok(typeof exporter.push === 'function'); + }); + + test('push returns a promise', () => { + const metrics: GovernanceMetrics = { + availability: 99.9, + latencyP99: 100, + compliancePercent: 100, + trustScoreMean: 900, + agentCount: 10, + violationsToday: 0, + timestamp: new Date().toISOString(), + }; + + const result = exporter.push(metrics); + assert.ok(result instanceof Promise); + }); + + test('handles endpoint errors gracefully', async () => { + const metrics: GovernanceMetrics = { + availability: 99.9, + latencyP99: 100, + compliancePercent: 100, + trustScoreMean: 900, + agentCount: 10, + violationsToday: 0, + timestamp: new Date().toISOString(), + }; + + // Should not throw even with invalid endpoint + try { + await exporter.push(metrics); + } catch { + // Expected to fail with invalid endpoint, that's OK + } + }); + }); + + suite('Metrics formatting', () => { + test('timestamp is ISO-8601 format', () => { + const timestamp = new Date().toISOString(); + const metrics: GovernanceMetrics = { + availability: 99.9, + latencyP99: 100, + compliancePercent: 100, + trustScoreMean: 900, + agentCount: 10, + violationsToday: 0, + timestamp, + }; + + // Verify ISO-8601 format + const parsed = new Date(metrics.timestamp); + assert.ok(!isNaN(parsed.getTime())); + }); + + test('all numeric fields are numbers', () => { + const metrics: GovernanceMetrics = { + availability: 99.9, + latencyP99: 100, + compliancePercent: 100, + trustScoreMean: 900, + agentCount: 10, + violationsToday: 0, + timestamp: new Date().toISOString(), + }; + + assert.strictEqual(typeof metrics.availability, 'number'); + assert.strictEqual(typeof metrics.latencyP99, 'number'); + assert.strictEqual(typeof metrics.compliancePercent, 'number'); + assert.strictEqual(typeof metrics.trustScoreMean, 'number'); + assert.strictEqual(typeof metrics.agentCount, 'number'); + assert.strictEqual(typeof metrics.violationsToday, 'number'); + }); + }); +}); diff --git a/packages/agent-os-vscode/src/test/runTest.ts b/packages/agent-os-vscode/src/test/runTest.ts new file mode 100644 index 00000000..c7168b27 --- /dev/null +++ b/packages/agent-os-vscode/src/test/runTest.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Test Runner Entry Point + * + * Launches VS Code Extension Development Host and runs tests. + */ + +import * as path from 'path'; +import { runTests } from '@vscode/test-electron'; + +async function main() { + try { + // The folder containing the Extension Manifest package.json + const extensionDevelopmentPath = path.resolve(__dirname, '../../'); + + // The path to the extension test script + const extensionTestsPath = path.resolve(__dirname, './suite/index'); + + // Download VS Code, unzip it and run the integration test + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + // Use minimal launch args compatible with all VS Code versions + launchArgs: ['--disable-telemetry'], + }); + } catch (err) { + console.error('Failed to run tests:', err); + process.exit(1); + } +} + +main(); diff --git a/packages/agent-os-vscode/src/test/server/governanceServer.test.ts b/packages/agent-os-vscode/src/test/server/governanceServer.test.ts new file mode 100644 index 00000000..f0ce43e6 --- /dev/null +++ b/packages/agent-os-vscode/src/test/server/governanceServer.test.ts @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Governance Server Tests + * + * Unit tests for the local development server. + */ + +import * as assert from 'assert'; +import * as path from 'path'; + +const EXTENSION_ROOT = path.resolve(__dirname, '..', '..', '..'); +import { + findAvailablePort, + isPortAvailable, + generateClientId, + generateSessionToken, + checkRateLimit, + validateWebSocketToken, + RateLimitRecord, + DEFAULT_HOST +} from '../../server/serverHelpers'; +import { renderBrowserDashboard } from '../../server/browserTemplate'; +import { GovernanceServer } from '../../server/GovernanceServer'; + +suite('GovernanceServer Helpers', () => { + test('isPortAvailable returns boolean', async () => { + const available = await isPortAvailable(49876, 'localhost'); + assert.ok(typeof available === 'boolean'); + }); + + test('findAvailablePort finds port in range', async () => { + const port = await findAvailablePort(49877, 'localhost'); + assert.ok(port >= 49877 && port < 49887); + }); + + test('generateClientId produces unique prefixed IDs', () => { + const id1 = generateClientId(); + const id2 = generateClientId(); + assert.notStrictEqual(id1, id2); + assert.ok(id1.startsWith('client_')); + }); + + test('generateClientId embeds timestamp', () => { + const before = Date.now(); + const id = generateClientId(); + const timestamp = parseInt(id.split('_')[1], 10); + assert.ok(timestamp >= before && timestamp <= Date.now()); + }); +}); + +suite('Server Security', () => { + test('DEFAULT_HOST binds to 127.0.0.1', () => { + assert.strictEqual(DEFAULT_HOST, '127.0.0.1'); + }); + + test('browser template includes CSP meta tag', () => { + const html = renderBrowserDashboard(9845, 'test-token', 'test-nonce', EXTENSION_ROOT); + assert.ok( + html.includes('http-equiv="Content-Security-Policy"'), + 'CSP meta tag should be present in the HTML head' + ); + assert.ok( + html.includes("default-src 'self'"), + 'CSP should contain default-src directive' + ); + }); + + test('browser template loads D3 from local vendor (no CDN)', () => { + const html = renderBrowserDashboard(9845, 'test-token', 'test-nonce', EXTENSION_ROOT); + assert.ok( + !html.includes('cdn.jsdelivr.net'), + 'Should not reference CDN — D3 is vendored locally' + ); + assert.ok( + html.includes('https://d3js.org v7.8.5'), + 'D3 source should be inlined in the HTML' + ); + }); + + test('SRI hash is not a placeholder', () => { + const html = renderBrowserDashboard(9845, 'test-token', 'test-nonce', EXTENSION_ROOT); + assert.ok( + !html.includes('PLACEHOLDER'), + 'SRI hash should not be a placeholder value' + ); + }); + + test('generateClientId uses crypto-strength randomness', () => { + const id = generateClientId(); + // crypto.randomBytes(4) produces 8 hex chars + const parts = id.split('_'); + const random = parts[2]; + assert.strictEqual(random.length, 8, 'random segment should be 8 hex chars'); + assert.ok(/^[0-9a-f]{8}$/.test(random), 'random segment should be hex'); + }); +}); + +suite('Session Token', () => { + test('generateSessionToken returns 32-char hex string', () => { + const token = generateSessionToken(); + assert.strictEqual(token.length, 32, 'token should be 32 hex chars'); + assert.ok(/^[0-9a-f]{32}$/.test(token), 'token should be valid hex'); + }); + test('generateSessionToken produces unique tokens', () => { + const t1 = generateSessionToken(); + const t2 = generateSessionToken(); + assert.notStrictEqual(t1, t2, 'tokens should be unique'); + }); + test('session token is embedded in WebSocket URL', () => { + const token = 'abc123def456789012345678abcdef01'; + const html = renderBrowserDashboard(9845, token, 'test-nonce', EXTENSION_ROOT); + assert.ok( + html.includes(`?token=${token}`), + 'WebSocket URL should include session token as query parameter' + ); + }); +}); + +suite('Rate Limiting', () => { + test('allows requests under the limit', () => { + const counts = new Map(); + assert.ok(checkRateLimit('127.0.0.1', counts), 'first request should be allowed'); + assert.ok(checkRateLimit('127.0.0.1', counts), 'second request should be allowed'); + }); + + test('blocks requests over 100 per minute', () => { + const counts = new Map(); + for (let i = 0; i < 100; i++) { + checkRateLimit('127.0.0.1', counts); + } + assert.ok( + !checkRateLimit('127.0.0.1', counts), + 'request 101 should be blocked' + ); + }); + + test('resets after window expires', () => { + const counts = new Map(); + counts.set('127.0.0.1', { count: 100, resetAt: Date.now() - 1 }); + assert.ok( + checkRateLimit('127.0.0.1', counts), + 'should allow after window reset' + ); + }); + + test('tracks IPs independently', () => { + const counts = new Map(); + for (let i = 0; i < 100; i++) { + checkRateLimit('10.0.0.1', counts); + } + assert.ok( + checkRateLimit('10.0.0.2', counts), + 'different IP should not be rate limited' + ); + }); +}); + +suite('WebSocket Token Validation', () => { + test('accepts valid token', () => { + const req = { url: '/?token=abc123' }; + assert.ok(validateWebSocketToken(req, 'abc123', 9845)); + }); + + test('rejects invalid token', () => { + const req = { url: '/?token=wrong' }; + assert.ok(!validateWebSocketToken(req, 'abc123', 9845)); + }); + + test('rejects missing token', () => { + const req = { url: '/' }; + assert.ok(!validateWebSocketToken(req, 'abc123', 9845)); + }); + + test('rejects missing url', () => { + assert.ok(!validateWebSocketToken({}, 'abc123', 9845)); + assert.ok(!validateWebSocketToken(null, 'abc123', 9845)); + }); + + test('rejects malformed url without throwing', () => { + const req = { url: '://bad\x00url' }; + assert.ok(!validateWebSocketToken(req, 'abc123', 9845)); + }); +}); + +suite('CSP Nonce', () => { + test('inline scripts include nonce attribute', () => { + const html = renderBrowserDashboard(9845, 'test-token', 'abc123nonce', EXTENSION_ROOT); + const nonceCount = (html.match(/nonce="abc123nonce"/g) || []).length; + assert.ok(nonceCount >= 3, 'should have nonce on D3 + topology + client scripts'); + }); + + test('CSP meta tag includes nonce directive', () => { + const html = renderBrowserDashboard(9845, 'test-token', 'abc123nonce', EXTENSION_ROOT); + assert.ok( + html.includes("'nonce-abc123nonce'"), + 'CSP should include nonce directive' + ); + }); + + test('CSP includes connect-src directive', () => { + const html = renderBrowserDashboard(9845, 'test-token', 'test-nonce', EXTENSION_ROOT); + assert.ok( + html.includes("connect-src 'self'"), + 'CSP should include connect-src for WebSocket' + ); + }); +}); + +suite('GovernanceServer Class', () => { + const mockSlo = { getSnapshot: async () => ({}) } as any; + const mockTopo = { getAgents: () => [], getBridges: () => [], getDelegations: () => [] } as any; + const mockAudit = { getRecent: () => [] } as any; + + test('getInstance returns singleton', () => { + const a = GovernanceServer.getInstance(mockSlo, mockTopo, mockAudit); + const b = GovernanceServer.getInstance(mockSlo, mockTopo, mockAudit); + assert.strictEqual(a, b); + }); + + test('getSessionToken is empty before start', () => { + const server = GovernanceServer.getInstance(mockSlo, mockTopo, mockAudit); + assert.strictEqual(server.getSessionToken(), ''); + }); + + test('start generates a session token and returns a port', async () => { + const server = GovernanceServer.getInstance(mockSlo, mockTopo, mockAudit); + const port = await server.start(49890); + assert.ok(port >= 49890); + assert.ok(server.getSessionToken().length === 32); + assert.ok(server.getUrl().includes(String(port))); + assert.ok(server.getState().clients.length === 0); + await server.stop(); + }); +}); + +suite('Local Vendor Security', () => { + test('D3.js inlined from local vendor file', () => { + const html = renderBrowserDashboard(9845, 'test-token', 'test-nonce', EXTENSION_ROOT); + assert.ok( + !html.includes('cdn.jsdelivr.net'), + 'Should not reference any CDN' + ); + }); + + test('Chart.js CDN dependency removed', () => { + const html = renderBrowserDashboard(9845, 'test-token', 'test-nonce', EXTENSION_ROOT); + assert.ok( + !html.includes('chart.js'), + 'Chart.js CDN script should be removed' + ); + }); +}); diff --git a/packages/agent-os-vscode/src/test/server/vendorAssets.test.ts b/packages/agent-os-vscode/src/test/server/vendorAssets.test.ts new file mode 100644 index 00000000..a0310feb --- /dev/null +++ b/packages/agent-os-vscode/src/test/server/vendorAssets.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +const EXTENSION_ROOT = path.resolve(__dirname, '..', '..', '..'); + +suite('Vendor Assets', () => { + test('d3.v7.8.5.min.js exists and is non-empty', () => { + const filePath = path.join(EXTENSION_ROOT, 'assets', 'vendor', 'd3.v7.8.5.min.js'); + assert.ok(fs.existsSync(filePath), 'D3 vendor file should exist'); + const stat = fs.statSync(filePath); + assert.ok(stat.size > 100_000, `D3 should be > 100KB, got ${stat.size}`); + }); + + test('chart.v4.4.1.umd.min.js exists and is non-empty', () => { + const filePath = path.join(EXTENSION_ROOT, 'assets', 'vendor', 'chart.v4.4.1.umd.min.js'); + assert.ok(fs.existsSync(filePath), 'Chart.js vendor file should exist'); + const stat = fs.statSync(filePath); + assert.ok(stat.size > 100_000, `Chart.js should be > 100KB, got ${stat.size}`); + }); + + test('no CDN references remain in production source', () => { + const srcDir = path.join(EXTENSION_ROOT, 'src'); + const files = walkSync(srcDir) + .filter(f => (f.endsWith('.ts') || f.endsWith('.tsx')) && !f.includes('test')); + const violations: string[] = []; + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + if (content.includes('cdn.jsdelivr.net')) { + violations.push(path.relative(EXTENSION_ROOT, file)); + } + } + assert.deepStrictEqual(violations, [], `CDN references found in: ${violations.join(', ')}`); + }); +}); + +function walkSync(dir: string): string[] { + const results: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { results.push(...walkSync(full)); } + else { results.push(full); } + } + return results; +} diff --git a/packages/agent-os-vscode/src/test/services/liveClient.test.ts b/packages/agent-os-vscode/src/test/services/liveClient.test.ts new file mode 100644 index 00000000..649af66c --- /dev/null +++ b/packages/agent-os-vscode/src/test/services/liveClient.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * LiveSREClient Tests + * + * Tests for URL validation, error sanitization, polling guard, + * and escalation logic. HTTP calls are not exercised (requires mock server). + */ + +import * as assert from 'assert'; +import { LiveSREClient, isLoopbackEndpoint } from '../../services/liveClient'; + +suite('isLoopbackEndpoint', () => { + test('accepts 127.0.0.1', () => { + assert.ok(isLoopbackEndpoint('http://127.0.0.1:9377')); + }); + test('accepts localhost', () => { + assert.ok(isLoopbackEndpoint('http://localhost:9377')); + }); + test('accepts ::1', () => { + assert.ok(isLoopbackEndpoint('http://[::1]:9377')); + }); + test('rejects external host', () => { + assert.ok(!isLoopbackEndpoint('http://evil.com:9377')); + }); + test('rejects 0.0.0.0', () => { + assert.ok(!isLoopbackEndpoint('http://0.0.0.0:9377')); + }); + test('rejects empty string', () => { + assert.ok(!isLoopbackEndpoint('')); + }); + test('rejects non-URL', () => { + assert.ok(!isLoopbackEndpoint('not-a-url')); + }); + test('rejects javascript: scheme', () => { + assert.ok(!isLoopbackEndpoint('javascript:alert(1)')); + }); +}); + +suite('LiveSREClient constructor', () => { + test('rejects non-loopback endpoint', () => { + assert.throws( + () => new LiveSREClient({ endpoint: 'http://evil.com:9377' }), + /loopback/ + ); + }); + test('accepts loopback endpoint', () => { + const client = new LiveSREClient({ endpoint: 'http://127.0.0.1:9377' }); + assert.ok(client); + client.dispose(); + }); + test('clamps interval below 5000ms', () => { + const client = new LiveSREClient({ + endpoint: 'http://127.0.0.1:9377', + refreshIntervalMs: 1000, + }); + // Cannot directly inspect _intervalMs, but client should not throw + assert.ok(client); + client.dispose(); + }); + test('initial snapshot is stale with no data', () => { + const client = new LiveSREClient({ endpoint: 'http://127.0.0.1:9377' }); + const snap = client.getSnapshot(); + assert.strictEqual(snap.data, null); + assert.strictEqual(snap.stale, true); + assert.strictEqual(snap.error, null); + assert.strictEqual(snap.escalated, false); + client.dispose(); + }); + test('dispose is idempotent', () => { + const client = new LiveSREClient({ endpoint: 'http://127.0.0.1:9377' }); + client.dispose(); + client.dispose(); // Should not throw + }); +}); + +suite('LiveSREClient token management', () => { + test('setToken updates authorization header', () => { + const client = new LiveSREClient({ endpoint: 'http://127.0.0.1:9377' }); + client.setToken('new-token'); + // Cannot directly inspect headers, but should not throw + assert.ok(client); + client.setToken(undefined); // Clear token + client.dispose(); + }); +}); diff --git a/packages/agent-os-vscode/src/test/services/providerFactory.test.ts b/packages/agent-os-vscode/src/test/services/providerFactory.test.ts new file mode 100644 index 00000000..6d437e45 --- /dev/null +++ b/packages/agent-os-vscode/src/test/services/providerFactory.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Provider Factory Tests + * + * Verifies that the factory returns disconnected providers when + * agent-failsafe is not available, and handles endpoint override. + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { createProviders } from '../../services/providerFactory'; + +suite('providerFactory', () => { + let originalShowInfo: typeof vscode.window.showInformationMessage; + setup(() => { + originalShowInfo = vscode.window.showInformationMessage; + (vscode.window as any).showInformationMessage = async () => undefined; + }); + teardown(() => { + (vscode.window as any).showInformationMessage = originalShowInfo; + }); + + test('unavailable python returns not-installed providers', async () => { + const providers = await createProviders({ pythonPath: 'nonexistent-python-binary' }); + assert.strictEqual(providers.status, 'not-installed'); + const slo = await providers.slo.getSnapshot(); + assert.strictEqual(slo.policyCompliance.totalEvaluations, 0); + assert.deepStrictEqual(providers.topology.getAgents(), []); + const policy = await providers.policy.getSnapshot(); + assert.strictEqual(policy.rules.length, 0); + providers.dispose(); + }); + + test('disconnected providers never return mock/fake data', async () => { + const providers = await createProviders({ pythonPath: 'nonexistent-python-binary' }); + const slo = await providers.slo.getSnapshot(); + assert.strictEqual(slo.policyCompliance.compliancePercent, 0); + assert.strictEqual(slo.trustScore.meanScore, 0); + providers.dispose(); + }); + + test('dispose is idempotent', async () => { + const providers = await createProviders({ pythonPath: 'nonexistent-python-binary' }); + providers.dispose(); + providers.dispose(); + }); + + test('explicit endpoint override bypasses auto-start', async () => { + const providers = await createProviders({ + pythonPath: 'python', + endpoint: 'http://127.0.0.1:9377', + }); + assert.strictEqual(providers.status, 'live'); + assert.ok(providers.slo); + providers.dispose(); + }); +}); diff --git a/packages/agent-os-vscode/src/test/services/sreServer.test.ts b/packages/agent-os-vscode/src/test/services/sreServer.test.ts new file mode 100644 index 00000000..dad15e52 --- /dev/null +++ b/packages/agent-os-vscode/src/test/services/sreServer.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * SRE Server Lifecycle Tests + * + * Tests for agent-failsafe detection, subprocess management, + * and health checking. Uses real Python interpreter where available. + */ + +import * as assert from 'assert'; +import { isAgentFailsafeAvailable, isValidPythonPath, SREServerManager } from '../../services/sreServer'; + +suite('isValidPythonPath', () => { + test('rejects empty string', () => { assert.strictEqual(isValidPythonPath(''), false); }); + test('rejects whitespace', () => { assert.strictEqual(isValidPythonPath(' '), false); }); + test('rejects semicolons', () => { assert.strictEqual(isValidPythonPath('python; rm -rf /'), false); }); + test('rejects backticks', () => { assert.strictEqual(isValidPythonPath('`whoami`'), false); }); + test('rejects pipe', () => { assert.strictEqual(isValidPythonPath('python | cat'), false); }); + test('rejects dollar sign', () => { assert.strictEqual(isValidPythonPath('$HOME/python'), false); }); + test('accepts python3', () => { assert.strictEqual(isValidPythonPath('python3'), true); }); + test('accepts unix path', () => { assert.strictEqual(isValidPythonPath('/usr/bin/python'), true); }); + test('accepts windows path', () => { assert.strictEqual(isValidPythonPath('C:\\Python311\\python.exe'), true); }); +}); + +suite('isAgentFailsafeAvailable', () => { + test('returns false for nonexistent python binary', async () => { + const result = await isAgentFailsafeAvailable('nonexistent-python-xyz'); + assert.strictEqual(result, false); + }); + + test('returns false for whitespace-only path', async () => { + const result = await isAgentFailsafeAvailable(' '); + assert.strictEqual(result, false); + }); + + test('returns boolean for system python', async () => { + const result = await isAgentFailsafeAvailable('python'); + assert.ok(typeof result === 'boolean'); + }); +}); + +suite('SREServerManager', () => { + test('constructor accepts python path and optional port', () => { + const mgr = new SREServerManager('python', 19377); + assert.ok(mgr); + mgr.stop(); + }); + + test('getEndpoint returns empty before start', () => { + const mgr = new SREServerManager('python'); + assert.strictEqual(mgr.getEndpoint(), ''); + mgr.stop(); + }); + + test('stop is idempotent', () => { + const mgr = new SREServerManager('python'); + mgr.stop(); + mgr.stop(); + }); + + test('start with invalid python returns not ok', async () => { + const mgr = new SREServerManager('nonexistent-python-xyz'); + const result = await mgr.start(); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.endpoint, ''); + assert.ok(result.message.length > 0); + }); + + test('start returns endpoint URL on port 9377 by default', () => { + const mgr = new SREServerManager('python'); + // Don't actually start — just verify the default port + assert.strictEqual(mgr.getEndpoint(), ''); + mgr.stop(); + }); +}); diff --git a/packages/agent-os-vscode/src/test/services/translators.test.ts b/packages/agent-os-vscode/src/test/services/translators.test.ts new file mode 100644 index 00000000..e225f35f --- /dev/null +++ b/packages/agent-os-vscode/src/test/services/translators.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Translator Tests + * + * Pure function tests — no I/O, no mocks. Validates that REST response + * shapes are correctly mapped, invalid input is rejected, and size + * limits are enforced. + */ + +import * as assert from 'assert'; +import { translateSLO, translateTopology, translatePolicy } from '../../services/translators'; + +suite('translateSLO', () => { + test('valid snapshot returns typed SLOSnapshot', () => { + const result = translateSLO({ sli: { passRate: 0.97, totalDecisions: 200 } }); + assert.ok(result); + assert.strictEqual(result.policyCompliance.compliancePercent, 97); + assert.strictEqual(result.policyCompliance.totalEvaluations, 200); + assert.ok(result.fetchedAt); + }); + test('pass_rate 0.0 maps to compliancePercent 0', () => { + const result = translateSLO({ sli: { passRate: 0.0 } }); + assert.ok(result); + assert.strictEqual(result.policyCompliance.compliancePercent, 0); + }); + test('pass_rate 1.0 maps to compliancePercent 100', () => { + const result = translateSLO({ sli: { passRate: 1.0 } }); + assert.ok(result); + assert.strictEqual(result.policyCompliance.compliancePercent, 100); + }); + test('missing sli key returns zero-value SLO (not null)', () => { + const result = translateSLO({}); + assert.ok(result); + assert.strictEqual(result.policyCompliance.totalEvaluations, 0); + }); + test('negative pass_rate rejected', () => { + assert.strictEqual(translateSLO({ sli: { passRate: -0.5 } }), null); + }); + test('non-object input returns null', () => { + assert.strictEqual(translateSLO('string'), null); + assert.strictEqual(translateSLO(42), null); + assert.strictEqual(translateSLO(null), null); + assert.strictEqual(translateSLO(undefined), null); + assert.strictEqual(translateSLO([1, 2]), null); + }); + test('Infinity/NaN in pass_rate rejected', () => { + assert.strictEqual(translateSLO({ sli: { passRate: Infinity } }), null); + assert.strictEqual(translateSLO({ sli: { passRate: NaN } }), null); + }); + test('snake_case keys accepted', () => { + const result = translateSLO({ sli: { pass_rate: 0.9, total_decisions: 50 } }); + assert.ok(result); + assert.strictEqual(result.policyCompliance.compliancePercent, 90); + assert.strictEqual(result.policyCompliance.totalEvaluations, 50); + }); + test('missing pass_rate yields zero compliance (not phantom violations)', () => { + const result = translateSLO({ sli: { totalDecisions: 100 } }); + assert.ok(result); + assert.strictEqual(result.policyCompliance.compliancePercent, 0); + assert.strictEqual(result.policyCompliance.violationsToday, 0); + }); + test('availability and latency are zero (not fabricated)', () => { + const result = translateSLO({ sli: { passRate: 0.95 } }); + assert.ok(result); + assert.strictEqual(result.availability.currentPercent, 0); + assert.strictEqual(result.latency.p99Ms, 0); + assert.strictEqual(result.trustScore.meanScore, 0); + }); +}); + +suite('translateTopology', () => { + test('valid fleet maps to AgentNode[]', () => { + const result = translateTopology({ + fleet: [{ agentId: 'agent-1', successRate: 0.85, circuitState: 'closed', taskCount: 10 }], + }); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].did, 'agent-1'); + assert.strictEqual(result[0].trustScore, 850); + assert.strictEqual(result[0].circuitState, 'closed'); + assert.strictEqual(result[0].taskCount, 10); + }); + test('empty fleet returns empty array', () => { + assert.deepStrictEqual(translateTopology({ fleet: [] }), []); + }); + test('agent with missing agentId is skipped', () => { + const result = translateTopology({ fleet: [{ successRate: 0.5 }] }); + assert.strictEqual(result.length, 0); + }); + test('fleet capped at 1000 agents', () => { + const fleet = Array.from({ length: 1500 }, (_, i) => ({ agentId: `a-${i}`, successRate: 0.5 })); + const result = translateTopology({ fleet }); + assert.strictEqual(result.length, 1000); + }); + test('long DID strings truncated at 500 chars', () => { + const longDid = 'x'.repeat(600); + const result = translateTopology({ fleet: [{ agentId: longDid, successRate: 0.5 }] }); + assert.strictEqual(result[0].did.length, 500); + }); + test('success_rate maps to trustScore 0-1000', () => { + const result = translateTopology({ fleet: [{ agentId: 'a', successRate: 0.0 }] }); + assert.strictEqual(result[0].trustScore, 0); + }); + test('invalid circuit_state defaults to closed', () => { + const result = translateTopology({ fleet: [{ agentId: 'a', successRate: 0.5, circuitState: 'invalid' }] }); + assert.strictEqual(result[0].circuitState, 'closed'); + }); + test('non-object input returns empty array', () => { + assert.deepStrictEqual(translateTopology(null), []); + assert.deepStrictEqual(translateTopology('bad'), []); + }); + test('accepts agents key (from /sre/fleet)', () => { + const result = translateTopology({ agents: [{ agentId: 'b', successRate: 0.9 }] }); + assert.strictEqual(result.length, 1); + }); + test('optional fields populated when present', () => { + const result = translateTopology({ + fleet: [{ agentId: 'a', successRate: 0.8, taskCount: 42, avgLatencyMs: 150, trustStage: 'IBT' }], + }); + assert.strictEqual(result[0].taskCount, 42); + assert.strictEqual(result[0].avgLatencyMs, 150); + assert.strictEqual(result[0].trustStage, 'IBT'); + }); + test('optional fields absent when not provided', () => { + const result = translateTopology({ fleet: [{ agentId: 'a', successRate: 0.5 }] }); + assert.strictEqual(result[0].taskCount, undefined); + assert.strictEqual(result[0].avgLatencyMs, undefined); + assert.strictEqual(result[0].trustStage, undefined); + }); + test('snake_case keys accepted for fleet agents', () => { + const result = translateTopology({ fleet: [{ agent_id: 'x', success_rate: 0.7 }] }); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].did, 'x'); + assert.strictEqual(result[0].trustScore, 700); + }); +}); + +suite('translatePolicy', () => { + test('valid policies maps to PolicyRule[]', () => { + const result = translatePolicy({ + policies: [{ name: 'no-secrets', action: 'DENY', pattern: '*.key' }], + auditEvents: [], + }); + assert.strictEqual(result.rules.length, 1); + assert.strictEqual(result.rules[0].name, 'no-secrets'); + assert.strictEqual(result.rules[0].action, 'DENY'); + }); + test('empty policies returns empty snapshot (not null)', () => { + const result = translatePolicy({}); + assert.ok(result); + assert.strictEqual(result.rules.length, 0); + assert.strictEqual(result.totalEvaluationsToday, 0); + }); + test('unknown action defaults to AUDIT', () => { + const result = translatePolicy({ policies: [{ name: 'test', action: 'UNKNOWN' }] }); + assert.strictEqual(result.rules[0].action, 'AUDIT'); + }); + test('policies capped at 200', () => { + const policies = Array.from({ length: 300 }, (_, i) => ({ name: `p-${i}` })); + const result = translatePolicy({ policies }); + assert.strictEqual(result.rules.length, 200); + }); + test('auditEvents capped at 500', () => { + const auditEvents = Array.from({ length: 600 }, (_, i) => ({ id: `e-${i}` })); + const result = translatePolicy({ auditEvents }); + assert.strictEqual(result.recentViolations.length, 500); + }); + test('asiCoverage preserved when present', () => { + const asi = { 'ASI-01': { label: 'Intent', covered: true, feature: 'Interceptor' } }; + const result = translatePolicy({ asiCoverage: asi }); + assert.deepStrictEqual(result.asiCoverage, asi); + }); + test('non-object input returns empty snapshot', () => { + const result = translatePolicy(null); + assert.strictEqual(result.rules.length, 0); + }); + test('fetchedAt is set', () => { + const result = translatePolicy({}); + assert.ok(result.fetchedAt); + }); +}); diff --git a/packages/agent-os-vscode/src/test/suite/index.ts b/packages/agent-os-vscode/src/test/suite/index.ts new file mode 100644 index 00000000..bc8379ff --- /dev/null +++ b/packages/agent-os-vscode/src/test/suite/index.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Test Suite Loader + * + * Discovers and runs all Mocha test files in the test directory. + */ + +import * as path from 'path'; +import Mocha from 'mocha'; +import { glob } from 'glob'; + +export async function run(): Promise { + // Create the mocha test + const mocha = new Mocha({ + ui: 'tdd', + color: true, + timeout: 60000, + }); + + const testsRoot = path.resolve(__dirname, '..'); + + // Find all test files recursively + const files = await glob('**/*.test.js', { cwd: testsRoot }); + + // Add files to the test suite + files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f))); + + return new Promise((resolve, reject) => { + try { + // Run the mocha test + mocha.run((failures: number) => { + if (failures > 0) { + reject(new Error(`${failures} tests failed.`)); + } else { + resolve(); + } + }); + } catch (runErr) { + console.error(runErr); + reject(runErr); + } + }); +} diff --git a/packages/agent-os-vscode/src/test/utils/escapeHtml.test.ts b/packages/agent-os-vscode/src/test/utils/escapeHtml.test.ts new file mode 100644 index 00000000..a46d9002 --- /dev/null +++ b/packages/agent-os-vscode/src/test/utils/escapeHtml.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import * as assert from 'assert'; +import { escapeHtml } from '../../utils/escapeHtml'; + +suite('escapeHtml', () => { + test('escapes & to &', () => { + assert.strictEqual(escapeHtml('a&b'), 'a&b'); + }); + + test('escapes < to <', () => { + assert.strictEqual(escapeHtml(' + +`; +} diff --git a/packages/agent-os-vscode/src/webviews/shared/types.ts b/packages/agent-os-vscode/src/webviews/shared/types.ts new file mode 100644 index 00000000..f199517f --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/shared/types.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Shared Detail Panel Data Types + * + * Contracts for messages flowing from extension host to detail webviews. + * Each panel receives its data type via postMessage with a typed payload. + */ + +// --------------------------------------------------------------------------- +// SLO Detail +// --------------------------------------------------------------------------- + +/** Full SLO snapshot data for the detail panel. */ +export interface SLODetailData { + availability: number; + availabilityTarget: number; + availabilityBudgetRemaining: number; + burnRate: number; + /** 24 data points for sparkline visualization. */ + burnRateSeries: number[]; + latencyP50: number; + latencyP95: number; + latencyP99: number; + latencyTarget: number; + latencyBudgetRemaining: number; + compliancePercent: number; + /** Always 100 for compliance. */ + complianceTarget: number; + violationsToday: number; + complianceTrend: 'up' | 'down' | 'stable'; + trustMean: number; + trustMin: number; + /** Distribution buckets: [0-250, 251-500, 501-750, 751-1000]. */ + trustDistribution: [number, number, number, number]; + fetchedAt: string | null; +} + +// --------------------------------------------------------------------------- +// Topology Detail (Phase 2) +// --------------------------------------------------------------------------- + +export interface TopologyNode { + id: string; + trust: number; + ring: number; + label: string; +} + +export interface TopologyEdge { + source: string; + target: string; + capability: string; +} + +export interface TopologyBridge { + protocol: string; + connected: boolean; + peerCount: number; +} + +export interface TopologyDetailData { + nodes: TopologyNode[]; + edges: TopologyEdge[]; + bridges: TopologyBridge[]; + fetchedAt: string | null; +} + +// --------------------------------------------------------------------------- +// Audit Detail (Phase 3) +// --------------------------------------------------------------------------- + +export interface AuditEntry { + id: string; + timestamp: string; + action: string; + agentDid: string | null; + severity: 'info' | 'warning' | 'critical'; + result: string; + file: string | null; +} + +export interface AuditDetailData { + entries: AuditEntry[]; + fetchedAt: string | null; +} + +// --------------------------------------------------------------------------- +// Policy Detail (Phase 3) +// --------------------------------------------------------------------------- + +export interface PolicyRuleDetail { + id: string; + name: string; + action: 'ALLOW' | 'DENY' | 'AUDIT' | 'BLOCK'; + pattern: string; + enabled: boolean; + evaluationsToday: number; + violationsToday: number; +} + +export interface PolicyDetailData { + rules: PolicyRuleDetail[]; + totalEvaluations: number; + totalViolations: number; + fetchedAt: string | null; +} + +// --------------------------------------------------------------------------- +// Hub Composite (Phase 3) +// --------------------------------------------------------------------------- + +export interface HubDetailData { + slo: SLODetailData | null; + topology: TopologyDetailData | null; + audit: AuditDetailData | null; + policy: PolicyDetailData | null; + fetchedAt: string | null; +} diff --git a/packages/agent-os-vscode/src/webviews/shared/useExtensionMessage.ts b/packages/agent-os-vscode/src/webviews/shared/useExtensionMessage.ts new file mode 100644 index 00000000..2fab347f --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/shared/useExtensionMessage.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * React hook for receiving typed messages from the extension host. + * + * Wraps the low-level onMessage listener with automatic cleanup + * and message-type filtering. + */ + +import { useState, useEffect } from 'react'; +import { onMessage } from './vscode'; + +/** + * Subscribe to extension host messages of a specific type. + * + * Returns the latest payload for the given message type, or null + * if no message has been received yet. + * + * @param type - Message type to filter on (e.g. 'sloDetailUpdate') + * @returns Latest payload of type T, or null + */ +export function useExtensionMessage(type: string): T | null { + const [data, setData] = useState(null); + + useEffect(() => { + const cleanup = onMessage((msg) => { + if (msg.type === type) { + setData(msg.data as T); + } + }); + return cleanup; + }, [type]); + + return data; +} diff --git a/packages/agent-os-vscode/src/webviews/shared/vscode.ts b/packages/agent-os-vscode/src/webviews/shared/vscode.ts new file mode 100644 index 00000000..a23a6573 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/shared/vscode.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * VS Code Webview API Wrapper + * + * Typed interface for communication between webview and extension host. + * Ensures acquireVsCodeApi is called exactly once per webview lifecycle. + */ + +/** Typed subset of the VS Code webview API. */ +interface VSCodeAPI { + postMessage(message: { type: string; [key: string]: unknown }): void; + getState(): T | undefined; + setState(state: T): void; +} + +declare function acquireVsCodeApi(): VSCodeAPI; + +let api: VSCodeAPI | undefined; + +/** + * Get the VS Code webview API singleton. + * + * @returns Typed VS Code API instance + */ +export function getVSCodeAPI(): VSCodeAPI { + if (!api) { + api = acquireVsCodeApi(); + } + return api; +} + +/** Message shape received from the extension host. */ +export interface ExtensionMessage { + type: string; + [key: string]: unknown; +} + +/** + * Subscribe to messages from the extension host. + * + * @param handler - Callback invoked for each message + * @returns Cleanup function to remove the listener + */ +export function onMessage(handler: (msg: ExtensionMessage) => void): () => void { + const listener = (e: MessageEvent) => handler(e.data); + window.addEventListener('message', listener); + return () => window.removeEventListener('message', listener); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/GovernanceStore.ts b/packages/agent-os-vscode/src/webviews/sidebar/GovernanceStore.ts new file mode 100644 index 00000000..0b07ba7f --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/GovernanceStore.ts @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** Extension-scoped state owner for the governance sidebar. + * Event-driven refresh from LiveSREClient/AuditLogger with heartbeat safety net. */ + +import type * as vscode from 'vscode'; +import type { SidebarState, SlotConfig, PanelId, AttentionMode } from './types'; +import { DEFAULT_SLOTS } from './types'; +import { GovernanceEventBus, Disposable } from './governanceEventBus'; +import { + DataProviders, + fetchSLO, fetchTopology, fetchAudit, fetchPolicy, + fetchStats, fetchKernel, fetchMemory, deriveHub, +} from './dataAggregator'; +import { + fetchSLODetail, fetchTopologyDetail, fetchAuditDetail, + fetchPolicyDetail, fetchHubDetail, +} from './detailFetchers'; +import { + type PanelTiming, createTiming, recordDuration, + shouldIsolate, shouldRejoin, markIsolated, markRejoined, + recordFastTick, resetFastTick, +} from './panelLatencyTracker'; +import { rankPanelsByUrgency } from './priorityEngine'; +import { wireStoreEvents, ChangeSource } from './storeEventWiring'; + +const SLOT_CONFIG_KEY = 'agentOS.slotConfig'; +const ATTENTION_MODE_KEY = 'agentOS.attentionMode'; +const DEFAULT_THRESHOLD_MS = 2000; +const HEARTBEAT_MULTIPLIER = 3; +const MIN_HEARTBEAT_MS = 15000; +type DataSourceKey = 'slo' | 'topology' | 'audit' | 'policy' | 'stats' | 'kernel' | 'memory'; +const ALL_SOURCES: DataSourceKey[] = ['slo', 'topology', 'audit', 'policy', 'stats', 'kernel', 'memory']; +const LIVE_SOURCES: DataSourceKey[] = ['slo', 'topology', 'policy']; +const LOCAL_SOURCES: DataSourceKey[] = ['audit', 'stats', 'kernel', 'memory']; +const SOURCE_TO_PANEL: Record = { + slo: 'slo-dashboard', topology: 'agent-topology', audit: 'audit-log', + policy: 'active-policies', stats: 'safety-stats', kernel: 'kernel-debugger', memory: 'memory-browser', +}; + +export class GovernanceStore { + private _state: SidebarState; + private _lastJson = ''; + private _visible = false; + private _fetching = false; + private _interval: ReturnType | undefined; + private readonly _timings = new Map(); + private readonly _isolatedTimers = new Map>(); + private readonly _eventSubs: vscode.Disposable[] = []; + private readonly _thresholdMs: number; + private readonly _detailSubs = new Map void>>(); + constructor( + private _providers: DataProviders, + private readonly _bus: GovernanceEventBus, + private readonly _workspaceState: vscode.Memento, + private readonly _refreshIntervalMs: number, + thresholdMs?: number, + liveClient?: ChangeSource, + auditLogger?: ChangeSource, + ) { + this._thresholdMs = thresholdMs ?? DEFAULT_THRESHOLD_MS; + const slots = _workspaceState.get(SLOT_CONFIG_KEY) ?? DEFAULT_SLOTS; + const mode = _workspaceState.get(ATTENTION_MODE_KEY) ?? 'auto'; + this._state = { + slots, userSlots: slots, attentionMode: mode, + slo: null, audit: null, topology: null, policy: null, + stats: null, kernel: null, memory: null, hub: null, + stalePanels: [], + }; + for (const k of ALL_SOURCES) { this._timings.set(k, createTiming()); } + this._interval = setInterval(() => this._tick(), + Math.max(this._refreshIntervalMs * HEARTBEAT_MULTIPLIER, MIN_HEARTBEAT_MS)); + this._eventSubs = wireStoreEvents( + liveClient, auditLogger, + () => this._fetchGroupAndEmit(LIVE_SOURCES), + () => this._fetchGroupAndEmit(LOCAL_SOURCES), + ); + } + getState(): SidebarState { return this._state; } + subscribe(listener: (state: SidebarState) => void): Disposable { + return this._bus.subscribe((event) => { + if (event.type === 'stateChanged') { listener(event.state); } + }); + } + /** Subscribe a detail panel to receive rich data on each refresh cycle. */ + onDetailSubscribe(panelType: string, cb: (data: unknown) => void): Disposable { + if (!this._detailSubs.has(panelType)) { this._detailSubs.set(panelType, new Set()); } + this._detailSubs.get(panelType)!.add(cb); + this._fetchDetailAndNotify(panelType).catch(() => { /* initial fetch error is non-fatal */ }); + return { + dispose: () => { + const set = this._detailSubs.get(panelType); + set?.delete(cb); + if (set?.size === 0) { this._detailSubs.delete(panelType); } + }, + }; + } + refreshNow(): void { this._tick(); } + setSlots(slots: SlotConfig): void { + this._state = { ...this._state, slots, userSlots: slots }; + this._workspaceState.update(SLOT_CONFIG_KEY, slots); + this._bus.publish({ type: 'slotConfigChanged', slots }); + this._emitIfChanged(); + } + setAttentionMode(mode: AttentionMode): void { + const s = this._state; + this._state = mode === 'manual' + ? { ...s, attentionMode: mode, slots: s.userSlots } + : { ...s, attentionMode: mode, userSlots: s.slots }; + this._workspaceState.update(ATTENTION_MODE_KEY, mode); + this._emitIfChanged(); + } + setVisible(visible: boolean): void { + this._visible = visible; + this._bus.publish({ type: 'visibilityChanged', visible }); + if (visible) { this._emitIfChanged(); } + } + /** Hot-swap providers and re-wire event subscriptions (called after async createProviders). */ + upgradeProviders(providers: DataProviders, liveClient?: ChangeSource, auditLogger?: ChangeSource): void { + this._providers = providers; + // Tear down old event subs and re-wire with new sources + for (const sub of this._eventSubs) { sub.dispose(); } + this._eventSubs.length = 0; + this._eventSubs.push(...wireStoreEvents( + liveClient, auditLogger, + () => this._fetchGroupAndEmit(LIVE_SOURCES), + () => this._fetchGroupAndEmit(LOCAL_SOURCES), + )); + // Immediate refresh with the new providers + this._tick(); + } + dispose(): void { + if (this._interval) { clearInterval(this._interval); this._interval = undefined; } + for (const t of this._isolatedTimers.values()) { clearInterval(t); } + this._isolatedTimers.clear(); + for (const sub of this._eventSubs) { sub.dispose(); } + this._eventSubs.length = 0; + this._detailSubs.clear(); + } + private async _tick(): Promise { + if (this._fetching) { return; } + this._fetching = true; + try { + const activeSources = ALL_SOURCES.filter(k => !this._isIsolated(k)); + await this._fetchSources(activeSources); + this._applyPriority(); + this._emitIfChanged(); + for (const pt of this._detailSubs.keys()) { + this._fetchDetailAndNotify(pt).catch(() => { /* detail fetch errors are non-fatal */ }); + } + } finally { + this._fetching = false; + } + } + /** Fetch detail data for a panel type and notify its subscribers. */ + private async _fetchDetailAndNotify(panelType: string): Promise { + const subs = this._detailSubs.get(panelType); + if (!subs || subs.size === 0) { return; } + const fetchers: Record Promise> = { + slo: () => fetchSLODetail(this._providers), + topology: async () => fetchTopologyDetail(this._providers), + audit: async () => fetchAuditDetail(this._providers), + policy: () => fetchPolicyDetail(this._providers), + hub: () => fetchHubDetail(this._providers), + }; + const data = await (fetchers[panelType]?.() ?? Promise.resolve(null)); + for (const cb of subs) { cb(data); } + } + private async _fetchSources(sources: DataSourceKey[]): Promise { + const results = await Promise.all(sources.map(async (k) => { + const t0 = performance.now(); + const value = await this._fetchOne(k); + this._trackLatency(k, performance.now() - t0); + return [k, value] as const; + })); + const patch: Partial> = {}; + for (const [k, value] of results) { patch[k] = value; } + const merged = { ...this._state, ...patch } as SidebarState; + this._state = { ...merged, hub: deriveHub(merged) }; + } + private async _fetchOne(key: DataSourceKey): Promise { + try { + switch (key) { + case 'slo': return await fetchSLO(this._providers) ?? this._state.slo; + case 'topology': return fetchTopology(this._providers) ?? this._state.topology; + case 'audit': return fetchAudit(this._providers) ?? this._state.audit; + case 'policy': return await fetchPolicy(this._providers) ?? this._state.policy; + case 'stats': return fetchStats(this._providers) ?? this._state.stats; + case 'kernel': return fetchKernel(this._providers) ?? this._state.kernel; + case 'memory': return fetchMemory(this._providers) ?? this._state.memory; + } + } catch { return this._state[key]; } + } + private _trackLatency(key: DataSourceKey, elapsed: number): void { + let timing = recordDuration(this._timings.get(key)!, elapsed); + const panelId = SOURCE_TO_PANEL[key]; + if (timing.isolated) { + timing = elapsed <= this._thresholdMs ? recordFastTick(timing) : resetFastTick(timing); + if (shouldRejoin(timing)) { + timing = markRejoined(timing); + this._clearIsolatedTimer(key); + this._updateStalePanels(key, false); + this._bus.publish({ type: 'panelRejoined', panelId }); + } + } else if (shouldIsolate(timing, this._thresholdMs)) { + timing = markIsolated(timing); + this._startIsolatedTimer(key); + this._updateStalePanels(key, true); + this._bus.publish({ type: 'panelIsolated', panelId }); + } + this._timings.set(key, timing); + } + + private _isIsolated(key: DataSourceKey): boolean { return this._timings.get(key)?.isolated === true; } + private _startIsolatedTimer(key: DataSourceKey): void { + const timer = setInterval(() => { + this._fetchSources([key]) + .then(() => { this._applyPriority(); this._emitIfChanged(); }) + .catch(() => { /* provider errors handled by fetchOne fallbacks */ }); + }, this._refreshIntervalMs / 2); + this._isolatedTimers.set(key, timer); + } + + private _clearIsolatedTimer(key: DataSourceKey): void { + const t = this._isolatedTimers.get(key); + if (t) { clearInterval(t); this._isolatedTimers.delete(key); } + } + private _updateStalePanels(key: DataSourceKey, stale: boolean): void { + const panelId = SOURCE_TO_PANEL[key]; + const current = this._state.stalePanels; + if (stale && !current.includes(panelId)) { + this._state = { ...this._state, stalePanels: [...current, panelId] }; + } else if (!stale) { + this._state = { ...this._state, stalePanels: current.filter(p => p !== panelId) }; + } + } + private async _fetchGroupAndEmit(sources: DataSourceKey[]): Promise { + if (this._fetching) { return; } + this._fetching = true; + try { + const active = sources.filter(k => !this._isIsolated(k)); + await this._fetchSources(active); + this._applyPriority(); + this._emitIfChanged(); + } finally { + this._fetching = false; + } + } + private _applyPriority(): void { + if (this._state.attentionMode !== 'auto') { return; } + const ranked = rankPanelsByUrgency(this._state, this._state.userSlots); + const s = this._state.slots; + if (ranked.slotA !== s.slotA || ranked.slotB !== s.slotB || ranked.slotC !== s.slotC) { + this._state = { ...this._state, slots: ranked }; + } + } + private _emitIfChanged(): void { + if (!this._visible) { return; } + const json = JSON.stringify(this._state); + if (json === this._lastJson) { return; } + this._lastJson = json; + this._bus.publish({ type: 'stateChanged', state: this._state }); + } +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/PanelPicker.tsx b/packages/agent-os-vscode/src/webviews/sidebar/PanelPicker.tsx new file mode 100644 index 00000000..5dfccba6 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/PanelPicker.tsx @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * PanelPicker Overlay + * + * Full-screen overlay for assigning panels to the 3 sidebar slots. + * Uses draft state so changes are only committed on Apply. + */ +import React, { useState, useEffect, useRef } from 'react'; +import { + PanelId, + SlotConfig, + PANEL_LABELS, +} from './types'; +import { + SlotKey, + SLOT_KEYS, + findSlotForPanel, + slotBadgeLetter, + hasChanges, +} from './pickerUtils'; + +interface PanelPickerProps { + current: SlotConfig; + onApply: (slots: SlotConfig) => void; + onCancel: () => void; +} + +const ALL_PANELS: PanelId[] = [ + 'governance-hub', + 'slo-dashboard', + 'audit-log', + 'agent-topology', + 'active-policies', + 'safety-stats', + 'kernel-debugger', + 'memory-browser', +]; + +const SLOT_LABELS: Record = { + slotA: 'Slot A', + slotB: 'Slot B', + slotC: 'Slot C', +}; + +/** Header with title, Cancel, and Apply buttons. */ +function PickerHeader(props: { + canApply: boolean; + onApply: () => void; + onCancel: () => void; +}): React.ReactElement { + return ( +
+ + Configure Panels + +
+ + +
+
+ ); +} + +/** A single slot zone showing the assigned panel name. */ +function SlotZone(props: { + slotKey: SlotKey; + panelId: PanelId; + isSelected: boolean; + onSelect: () => void; +}): React.ReactElement { + const borderClass = props.isSelected ? 'border-ml-accent' : 'border-ml-border'; + return ( + + ); +} + +/** Row of 3 slot zones. */ +function SlotRow(props: { + draft: SlotConfig; + activeSlot: SlotKey | null; + onSelectSlot: (key: SlotKey) => void; +}): React.ReactElement { + return ( +
+ {SLOT_KEYS.map((key) => ( + props.onSelectSlot(key)} + /> + ))} +
+ ); +} + +/** A single panel card in the picker grid. */ +function PanelCard(props: { + panelId: PanelId; + assignedSlot: SlotKey | null; + onClick: () => void; +}): React.ReactElement { + return ( + + ); +} + +/** 2x4 grid of all panel cards. */ +function PanelGrid(props: { + draft: SlotConfig; + onSelectPanel: (panelId: PanelId) => void; +}): React.ReactElement { + return ( +
+ {ALL_PANELS.map((id) => ( + props.onSelectPanel(id)} + /> + ))} +
+ ); +} + +/** Full-screen panel picker overlay. */ +export function PanelPicker(props: PanelPickerProps): React.ReactElement { + const { current, onApply, onCancel } = props; + const [draft, setDraft] = useState({ ...current }); + const [activeSlot, setActiveSlot] = useState(null); + const dialogRef = useRef(null); + + useEffect(() => { + dialogRef.current?.focus(); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Escape') { onCancel(); } + }; + + const handleSelectPanel = (panelId: PanelId): void => { + if (!activeSlot) { + return; + } + const existingSlot = findSlotForPanel(draft, panelId); + const next = { ...draft }; + if (existingSlot && existingSlot !== activeSlot) { + next[existingSlot] = draft[activeSlot]; + } + next[activeSlot] = panelId; + setDraft(next); + }; + + return ( +
+ onApply(draft)} + onCancel={onCancel} + /> + +
+ +
+
+ ); +} + +export default PanelPicker; diff --git a/packages/agent-os-vscode/src/webviews/sidebar/Sidebar.tsx b/packages/agent-os-vscode/src/webviews/sidebar/Sidebar.tsx new file mode 100644 index 00000000..9cb44a96 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/Sidebar.tsx @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Sidebar Root Component + * + * Renders the 3-slot governance sidebar. Subscribes to extension host + * state updates, manages the panel picker overlay, and runs scan rotation. + */ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { getVSCodeAPI, onMessage, ExtensionMessage } from '../shared/vscode'; +import { + SidebarState, + SlotConfig, + PanelId, + SlotKey, + AttentionMode, + DEFAULT_SLOTS, +} from './types'; +import { Slot } from './Slot'; +import { PanelPicker } from './PanelPicker'; +import { nextSlot, shouldScan, SCAN_INTERVAL_MS, IDLE_RESUME_MS } from './scanController'; + +const INITIAL_STATE: SidebarState = { + slots: DEFAULT_SLOTS, userSlots: DEFAULT_SLOTS, attentionMode: 'auto', + slo: null, audit: null, topology: null, + policy: null, stats: null, kernel: null, memory: null, hub: null, + stalePanels: [], +}; + +function handleStateMessage(msg: ExtensionMessage): SidebarState | null { + if (msg.type !== 'stateUpdate') { return null; } + return msg.state as SidebarState; +} + +/** Hook: manages scan rotation, pause/resume, and reduced motion detection. */ +function useScanRotation(mode: AttentionMode) { + const [activeSlot, setActiveSlot] = useState('slotA'); + const [reducedMotion, setReducedMotion] = useState(false); + const scanPaused = useRef(false); + const resumeTimer = useRef>(); + + useEffect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + setReducedMotion(mq.matches); + const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + useEffect(() => { + if (!shouldScan(mode, reducedMotion)) { return; } + const id = setInterval(() => { + if (!scanPaused.current) { setActiveSlot(s => nextSlot(s)); } + }, SCAN_INTERVAL_MS); + return () => clearInterval(id); + }, [mode, reducedMotion]); + + useEffect(() => { + return () => { if (resumeTimer.current) { clearTimeout(resumeTimer.current); } }; + }, []); + + const pause = useCallback(() => { + scanPaused.current = true; + if (resumeTimer.current) { clearTimeout(resumeTimer.current); } + }, []); + const resume = useCallback(() => { + resumeTimer.current = setTimeout(() => { scanPaused.current = false; }, IDLE_RESUME_MS); + }, []); + + return { activeSlot, scanning: shouldScan(mode, reducedMotion), pause, resume }; +} + +/** Root sidebar component with 3 configurable panel slots. */ +export function Sidebar(): React.ReactElement { + const [state, setState] = useState(INITIAL_STATE); + const [pickerOpen, setPickerOpen] = useState(false); + const scan = useScanRotation(state.attentionMode); + + useEffect(() => { + const cleanup = onMessage((msg) => { + const next = handleStateMessage(msg); + if (next) { setState(next); } + }); + getVSCodeAPI().postMessage({ type: 'ready' }); + return cleanup; + }, []); + + const handlePromote = useCallback((panelId: PanelId) => { + getVSCodeAPI().postMessage({ type: 'promotePanelToWebview', panelId }); + }, []); + const handleApply = useCallback((slots: SlotConfig) => { + getVSCodeAPI().postMessage({ type: 'setSlots', slots }); + setPickerOpen(false); + }, []); + const handleToggleMode = useCallback(() => { + const next: AttentionMode = state.attentionMode === 'auto' ? 'manual' : 'auto'; + getVSCodeAPI().postMessage({ type: 'setAttentionMode', mode: next }); + }, [state.attentionMode]); + + return ( +
+ setPickerOpen(true)} + /> + + {pickerOpen && ( + setPickerOpen(false)} + /> + )} +
+ ); +} + +/** Header bar with title, attention toggle, and gear button. */ +function SidebarHeader(props: { + attentionMode: AttentionMode; + onToggleMode: () => void; + onOpenPicker: () => void; +}): React.ReactElement { + const isAuto = props.attentionMode === 'auto'; + return ( +
+ + Governance + +
+ + +
+
+ ); +} + +/** Inline 14x14 gear SVG. */ +function GearIcon(): React.ReactElement { + return ( + + ); +} + +/** Renders the 3 slots with scan highlight and hover handlers. */ +function SlotStack(props: { + state: SidebarState; + activeSlot: SlotKey; + scanning: boolean; + onPromote: (id: PanelId) => void; + onPointerEnter: () => void; + onPointerLeave: () => void; +}): React.ReactElement { + const { state, activeSlot, scanning, onPromote, onPointerEnter, onPointerLeave } = props; + const { slots } = state; + const stalePanels = state.stalePanels ?? []; + + return ( +
+ +
+ +
+ +
+ ); +} + +export default Sidebar; diff --git a/packages/agent-os-vscode/src/webviews/sidebar/SidebarProvider.ts b/packages/agent-os-vscode/src/webviews/sidebar/SidebarProvider.ts new file mode 100644 index 00000000..d424710d --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/SidebarProvider.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Sidebar Provider + * + * Thin webview lifecycle bridge for the 3-slot governance sidebar. + * Delegates all state management to GovernanceStore. + */ + +import * as vscode from 'vscode'; +import * as crypto from 'crypto'; +import type { PanelId, WebviewMessage } from './types'; +import { GovernanceStore } from './GovernanceStore'; +import type { Disposable } from './governanceEventBus'; + +/** Map of PanelId to the VS Code command that opens its full webview. */ +const PROMOTE_COMMANDS: Record = { + 'slo-dashboard': 'agent-os.showSLOWebview', + 'agent-topology': 'agent-os.showTopologyGraph', + 'governance-hub': 'agent-os.showGovernanceHub', + 'audit-log': 'agent-os.showGovernanceHub', + 'active-policies': 'agent-os.openPolicyEditor', + 'safety-stats': 'agent-os.showGovernanceHub', + 'kernel-debugger': 'agent-os.showGovernanceHub', + 'memory-browser': 'agent-os.showGovernanceHub', +}; + +/** + * Provides the 3-slot governance sidebar as a single webview. + * All state owned by GovernanceStore; this class manages the webview lifecycle. + */ +export class SidebarProvider implements vscode.WebviewViewProvider, vscode.Disposable { + + public static readonly viewType = 'agent-os.sidebar'; + + private _view: vscode.WebviewView | undefined; + private _storeSubscription: Disposable | undefined; + + constructor( + private readonly _extensionUri: vscode.Uri, + private readonly _context: vscode.ExtensionContext, + private readonly _store: GovernanceStore, + ) {} + + public resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): void { + // Dispose previous subscription on re-resolve (W7 fix) + this._storeSubscription?.dispose(); + this._view = webviewView; + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this._extensionUri, 'out', 'webviews')], + }; + this._setWebviewContent(); + this._registerListeners(webviewView); + this._storeSubscription = this._store.subscribe(() => this._pushState()); + this._store.setVisible(true); + } + + private _setWebviewContent(): void { + if (!this._view) { return; } + const webview = this._view.webview; + const nonce = crypto.randomBytes(16).toString('hex'); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'out', 'webviews', 'sidebar', 'main.js'), + ); + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'out', 'webviews', 'index.css'), + ); + webview.html = /* html */ ` + + + + + + + Agent OS Sidebar + + +
+ + +`; + } + + private _registerListeners(webviewView: vscode.WebviewView): void { + webviewView.onDidChangeVisibility(() => { + const visible = webviewView.visible; + this._store.setVisible(visible); + if (visible) { this._pushState(); } + }); + webviewView.webview.onDidReceiveMessage((msg: WebviewMessage) => { + this._handleMessage(msg); + }); + } + + private _handleMessage(message: WebviewMessage): void { + switch (message.type) { + case 'ready': + this._store.refreshNow(); + this._pushState(); + break; + case 'setSlots': + this._store.setSlots(message.slots); + break; + case 'promotePanelToWebview': { + const cmd = PROMOTE_COMMANDS[message.panelId]; + if (cmd) { vscode.commands.executeCommand(cmd); } + break; + } + case 'refresh': + this._store.refreshNow(); + break; + case 'setAttentionMode': + this._store.setAttentionMode(message.mode); + break; + } + } + + private _pushState(): void { + if (!this._view?.visible) { return; } + this._view.webview.postMessage({ type: 'stateUpdate', state: this._store.getState() }); + } + + public dispose(): void { + this._storeSubscription?.dispose(); + this._store.setVisible(false); + } +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/Slot.tsx b/packages/agent-os-vscode/src/webviews/sidebar/Slot.tsx new file mode 100644 index 00000000..0f712f12 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/Slot.tsx @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Slot Component + * + * Renders a single configurable panel slot within the sidebar. + * Routes the correct data to the matched panel summary component + * and provides an expand button to promote to a full webview. + */ +import React from 'react'; +import { + PanelId, + SidebarState, + PANEL_LABELS, +} from './types'; +import { SLOSummary } from './panels/SLOSummary'; +import { AuditSummary } from './panels/AuditSummary'; +import { TopologySummary } from './panels/TopologySummary'; +import { GovernanceHubSummary } from './panels/GovernanceHubSummary'; +import { PolicySummary } from './panels/PolicySummary'; +import { StatsSummary } from './panels/StatsSummary'; +import { KernelSummary } from './panels/KernelSummary'; +import { MemorySummary } from './panels/MemorySummary'; + +/** Renders the panel content for a given panelId with routed data. */ +function PanelContent(props: { panelId: PanelId; state: SidebarState }): React.ReactElement { + const { panelId, state } = props; + switch (panelId) { + case 'slo-dashboard': return ; + case 'audit-log': return ; + case 'agent-topology': return ; + case 'governance-hub': return ; + case 'active-policies': return ; + case 'safety-stats': return ; + case 'kernel-debugger': return ; + case 'memory-browser': return ; + } +} + +/** Inline 12x12 expand (arrow-up-right) SVG icon. */ +function ExpandIcon(): React.ReactElement { + return ( + + ); +} + +/** Props for a single sidebar slot. */ +interface SlotProps { + position: 'A' | 'B' | 'C'; + panelId: PanelId; + state: SidebarState; + stale?: boolean; + active?: boolean; + onPromote: (panelId: PanelId) => void; +} + +/** Inline 10x10 clock SVG for the staleness indicator. */ +function ClockIcon(): React.ReactElement { + return ( + + ); +} + +/** Renders the slot header with label, stale badge, and expand button. */ +function SlotHeader( + props: { panelId: PanelId; stale?: boolean; onPromote: () => void }, +): React.ReactElement { + return ( +
+ + {PANEL_LABELS[props.panelId]} + {props.stale && ( + + + + )} + + +
+ ); +} + +/** Single slot container rendering the appropriate panel summary. */ +export function Slot(props: SlotProps): React.ReactElement { + const { panelId, state, stale, active, onPromote } = props; + const borderClass = active ? 'border-l-2 border-ml-accent' : 'border-l-2 border-transparent'; + + return ( +
+ onPromote(panelId)} + /> +
+ +
+
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/dataAggregator.ts b/packages/agent-os-vscode/src/webviews/sidebar/dataAggregator.ts new file mode 100644 index 00000000..4a4e99ef --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/dataAggregator.ts @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Data Aggregator + * + * Fetches data from all providers and maps it into the compact + * SidebarState shape. Extracted from SidebarProvider for Section 4 compliance. + */ + +import { SLODataProvider, SLOSnapshot } from '../../views/sloTypes'; +import { AgentTopologyDataProvider } from '../../views/topologyTypes'; +import { PolicyDataProvider } from '../../views/policyTypes'; +import type { + SidebarState, + SLOSummaryData, + AuditSummaryData, + TopologySummaryData, + PolicySummaryData, + StatsSummaryData, + KernelSummaryData, + MemorySummaryData, + GovernanceHubData, +} from './types'; +// Re-export detail fetchers from dedicated module (Section 4 file-size compliance) +export { + fetchSLODetail, fetchTopologyDetail, fetchAuditDetail, + fetchPolicyDetail, fetchHubDetail, +} from './detailFetchers'; + +/** Providers bundle passed to the aggregator. */ +export interface DataProviders { + slo: SLODataProvider; + topology: AgentTopologyDataProvider; + audit: { getAll(): unknown[]; getStats(): { blockedToday: number; blockedThisWeek: number; warningsToday: number; cmvkReviewsToday: number; totalLogs: number } }; + policy: PolicyDataProvider; + kernel: { getKernelSummary(): { activeAgents: number; policyViolations: number; totalCheckpoints: number; uptime: number } }; + memory: { getVfsSummary(): { directoryCount: number; fileCount: number; rootPaths: string[] } }; +} + +export async function fetchSLO(p: DataProviders): Promise { + try { + const s: SLOSnapshot = await p.slo.getSnapshot(); + return { + availability: s.availability.currentPercent, + availabilityTarget: s.availability.targetPercent, + latencyP99: s.latency.p99Ms, + latencyTarget: s.latency.targetMs, + compliancePercent: s.policyCompliance.compliancePercent, + violationsToday: s.policyCompliance.violationsToday, + trustMean: s.trustScore.meanScore, + agentsBelowThreshold: s.trustScore.agentsBelowThreshold, + }; + } catch { return null; } +} + +export function fetchTopology(p: DataProviders): TopologySummaryData | null { + try { + const agents = p.topology.getAgents(); + const bridges = p.topology.getBridges(); + const delegations = p.topology.getDelegations(); + const totalTrust = agents.reduce((sum, a) => sum + a.trustScore, 0); + return { + agentCount: agents.length, + bridgeCount: bridges.filter(b => b.connected).length, + meanTrust: agents.length > 0 ? Math.round(totalTrust / agents.length) : 0, + delegationCount: delegations.length, + }; + } catch { return null; } +} + +export function fetchAudit(p: DataProviders): AuditSummaryData | null { + try { + const all = p.audit.getAll(); + const today = new Date().toISOString().slice(0, 10); + const todayEntries = all.filter((e: unknown) => { + const entry = e as { timestamp?: Date; type?: string }; + return entry.timestamp && entry.timestamp.toISOString().slice(0, 10) === today; + }); + const violations = todayEntries.filter((e: unknown) => { + return (e as { type?: string }).type === 'blocked'; + }); + const last = all.length > 0 ? all[all.length - 1] as { timestamp?: Date; type?: string } : null; + return { + totalToday: todayEntries.length, + violationsToday: violations.length, + lastEventTime: last?.timestamp ? last.timestamp.toISOString() : null, + lastEventAction: last?.type ?? null, + }; + } catch { return null; } +} + +export async function fetchPolicy(p: DataProviders): Promise { + try { + const snap = await p.policy.getSnapshot(); + const enabled = snap.rules.filter(r => r.enabled); + return { + totalRules: snap.rules.length, + enabledRules: enabled.length, + denyRules: enabled.filter(r => r.action === 'DENY').length, + blockRules: enabled.filter(r => r.action === 'BLOCK').length, + evaluationsToday: snap.totalEvaluationsToday, + violationsToday: snap.totalViolationsToday, + }; + } catch { return null; } +} + +export function fetchStats(p: DataProviders): StatsSummaryData | null { + try { + const s = p.audit.getStats(); + return { + blockedToday: s.blockedToday, + blockedThisWeek: s.blockedThisWeek, + warningsToday: s.warningsToday, + cmvkReviews: s.cmvkReviewsToday, + totalLogs: s.totalLogs, + }; + } catch { return null; } +} + +export function fetchKernel(p: DataProviders): KernelSummaryData | null { + try { + const k = p.kernel.getKernelSummary(); + return { + activeAgents: k.activeAgents, + policyViolations: k.policyViolations, + totalCheckpoints: k.totalCheckpoints, + uptimeSeconds: k.uptime, + }; + } catch { return null; } +} + +export function fetchMemory(p: DataProviders): MemorySummaryData | null { + try { + const m = p.memory.getVfsSummary(); + return { directoryCount: m.directoryCount, fileCount: m.fileCount, rootPaths: m.rootPaths }; + } catch { return null; } +} + +export function deriveHub(state: SidebarState): GovernanceHubData { + const violations = state.audit?.violationsToday ?? 0; + const policyViolations = state.kernel?.policyViolations ?? 0; + const alerts = violations + policyViolations; + const compliance = state.slo?.compliancePercent ?? 100; + const agents = state.topology?.agentCount ?? 0; + + let health: 'healthy' | 'warning' | 'critical' = 'healthy'; + if (alerts > 0 || compliance < 95) { health = 'warning'; } + if (alerts > 5 || compliance < 90) { health = 'critical'; } + + return { overallHealth: health, activeAlerts: alerts, policyCompliance: compliance, agentCount: agents }; +} + + diff --git a/packages/agent-os-vscode/src/webviews/sidebar/detailFetchers.ts b/packages/agent-os-vscode/src/webviews/sidebar/detailFetchers.ts new file mode 100644 index 00000000..3c53c7f5 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/detailFetchers.ts @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Detail Fetchers + * + * Rich data fetchers for promoted detail webview panels. + * Each function maps raw provider data to the typed contracts + * consumed by React detail views via postMessage. + */ + +import type { SLOSnapshot } from '../../views/sloTypes'; +import type { DataProviders } from './dataAggregator'; +import type { + SLODetailData, + TopologyDetailData, + AuditDetailData, + PolicyDetailData, + HubDetailData, +} from '../shared/types'; + +/** Generate 24-point burn rate series from current value with slight variance. */ +function generateBurnRateSeries(current: number): number[] { + const points: number[] = []; + for (let i = 0; i < 24; i++) { + // SECURITY: Math.random() for synthetic sparkline demo data only. Not cryptographic. + // Will be replaced by real SRE historical data when backend is available. + const jitter = 1 + (Math.random() * 0.2 - 0.1); + points.push(Math.round(current * jitter * 100) / 100); + } + return points; +} + +/** Truncate a DID for display labels. */ +function truncateDid(did: string, maxLen = 22): string { + return did.length <= maxLen ? did : did.slice(0, maxLen) + '...'; +} + +/** Map an audit event type to a severity level. */ +function mapSeverity(type?: string): 'info' | 'warning' | 'critical' { + if (type === 'blocked') { return 'critical'; } + if (type === 'warned') { return 'warning'; } + return 'info'; +} + +/** Fetch full SLO detail data for the detail panel. */ +export async function fetchSLODetail(p: DataProviders): Promise { + try { + const s: SLOSnapshot = await p.slo.getSnapshot(); + return { + availability: s.availability.currentPercent, + availabilityTarget: s.availability.targetPercent, + availabilityBudgetRemaining: s.availability.errorBudgetRemainingPercent, + burnRate: s.availability.burnRate, + burnRateSeries: generateBurnRateSeries(s.availability.burnRate), + latencyP50: s.latency.p50Ms, + latencyP95: s.latency.p95Ms, + latencyP99: s.latency.p99Ms, + latencyTarget: s.latency.targetMs, + latencyBudgetRemaining: s.latency.errorBudgetRemainingPercent, + compliancePercent: s.policyCompliance.compliancePercent, + complianceTarget: 100, + violationsToday: s.policyCompliance.violationsToday, + complianceTrend: s.policyCompliance.trend, + trustMean: s.trustScore.meanScore, + trustMin: s.trustScore.minScore, + trustDistribution: s.trustScore.distribution, + fetchedAt: s.fetchedAt ?? new Date().toISOString(), + }; + } catch { return null; } +} + +/** Fetch full topology detail data for the detail panel. */ +export function fetchTopologyDetail(p: DataProviders): TopologyDetailData | null { + try { + const agents = p.topology.getAgents(); + const delegations = p.topology.getDelegations(); + const bridges = p.topology.getBridges(); + return { + nodes: agents.map(a => ({ + id: a.did, trust: a.trustScore, + ring: a.ring, label: truncateDid(a.did), + })), + edges: delegations.map(d => ({ + source: d.fromDid, target: d.toDid, capability: d.capability, + })), + bridges: bridges.map(b => ({ + protocol: b.protocol, connected: b.connected, peerCount: b.peerCount, + })), + fetchedAt: new Date().toISOString(), + }; + } catch { return null; } +} + +/** Fetch full audit detail data for the detail panel. */ +export function fetchAuditDetail(p: DataProviders): AuditDetailData | null { + try { + const all = p.audit.getAll(); + const entries = all.map((e: unknown, i: number) => { + const entry = e as { timestamp?: Date; type?: string; agentDid?: string; file?: string }; + return { + id: `audit-${i}`, + timestamp: entry.timestamp?.toISOString() ?? new Date().toISOString(), + action: entry.type ?? 'unknown', + agentDid: entry.agentDid ?? null, + severity: mapSeverity(entry.type), + result: entry.type ?? 'unknown', + file: entry.file ?? null, + }; + }); + return { entries, fetchedAt: new Date().toISOString() }; + } catch { return null; } +} + +/** Fetch full policy detail data for the detail panel. */ +export async function fetchPolicyDetail(p: DataProviders): Promise { + try { + const snap = await p.policy.getSnapshot(); + return { + rules: snap.rules.map(r => ({ + id: r.id, name: r.name, action: r.action, pattern: r.pattern, + enabled: r.enabled, evaluationsToday: r.evaluationsToday, + violationsToday: r.violationsToday, + })), + totalEvaluations: snap.totalEvaluationsToday, + totalViolations: snap.totalViolationsToday, + fetchedAt: snap.fetchedAt ?? new Date().toISOString(), + }; + } catch { return null; } +} + +/** Fetch composite hub detail data from all providers. */ +export async function fetchHubDetail(p: DataProviders): Promise { + try { + const [slo, policy] = await Promise.all([ + fetchSLODetail(p), fetchPolicyDetail(p), + ]); + return { + slo, + topology: fetchTopologyDetail(p), + audit: fetchAuditDetail(p), + policy, + fetchedAt: new Date().toISOString(), + }; + } catch { return null; } +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/governanceEventBus.ts b/packages/agent-os-vscode/src/webviews/sidebar/governanceEventBus.ts new file mode 100644 index 00000000..8b777bfc --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/governanceEventBus.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Governance Event Bus + * + * Minimal typed pub/sub for host-side coordination between + * GovernanceStore, SidebarProvider, and future consumers. + * Synchronous dispatch — events are coordination signals, not data transport. + */ + +import type { GovernanceEvent } from './types'; + +/** Disposable subscription handle. */ +export interface Disposable { + dispose(): void; +} + +/** + * Typed event bus for governance sidebar coordination. + */ +export class GovernanceEventBus { + private readonly _listeners = new Set<(event: GovernanceEvent) => void>(); + + /** Publish an event to all current subscribers. Fault-isolated per listener. */ + publish(event: GovernanceEvent): void { + for (const listener of this._listeners) { + try { listener(event); } catch { /* fault-isolated: one bad listener cannot break others */ } + } + } + + /** Subscribe to all events. Returns a disposable to unsubscribe. */ + subscribe(listener: (event: GovernanceEvent) => void): Disposable { + this._listeners.add(listener); + return { + dispose: () => { this._listeners.delete(listener); }, + }; + } + + /** Remove all subscribers. */ + dispose(): void { + this._listeners.clear(); + } +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/healthColors.ts b/packages/agent-os-vscode/src/webviews/sidebar/healthColors.ts new file mode 100644 index 00000000..b81cad82 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/healthColors.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Health Color Utilities + * + * Pure functions that map metric values to Tailwind text-color classes. + * Extracted from panel components for testability without JSX dependency. + */ + +/** Returns a Tailwind text-color class based on percentage health. */ +export function percentColor(value: number, target: number): string { + if (value >= target) { return 'text-ml-success'; } + if (value >= target - 1) { return 'text-ml-warning'; } + return 'text-ml-error'; +} + +/** Returns a Tailwind text-color class based on latency health. */ +export function latencyColor(value: number, target: number): string { + if (value <= target) { return 'text-ml-success'; } + if (value <= target * 1.2) { return 'text-ml-warning'; } + return 'text-ml-error'; +} + +/** Returns a Tailwind text-color class for trust scores (0-1000). */ +export function trustColor(value: number): string { + if (value >= 750) { return 'text-ml-success'; } + if (value >= 400) { return 'text-ml-warning'; } + return 'text-ml-error'; +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/main.tsx b/packages/agent-os-vscode/src/webviews/sidebar/main.tsx new file mode 100644 index 00000000..a0abe0c9 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/main.tsx @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Sidebar Entry Point + * + * Mounts the 3-slot sidebar React app into the webview. + */ + +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { Sidebar } from './Sidebar'; + +const container = document.getElementById('root'); +if (container) { + createRoot(container).render(); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/panelLatencyTracker.ts b/packages/agent-os-vscode/src/webviews/sidebar/panelLatencyTracker.ts new file mode 100644 index 00000000..7f9931cb --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/panelLatencyTracker.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Panel Latency Tracker + * + * Pure functions for per-panel latency tracking decisions. + * No timers, no side effects, no state mutations. + * The GovernanceStore calls these to decide isolation/rejoin. + */ + +const WINDOW_SIZE = 5; + +/** Rolling latency state for a single data source. */ +export interface PanelTiming { + readonly durations: readonly number[]; + readonly isolated: boolean; + readonly consecutiveFast: number; +} + +/** Create default timing for a new panel. */ +export function createTiming(): PanelTiming { + return { durations: [], isolated: false, consecutiveFast: 0 }; +} + +/** Record a fetch duration. Returns new timing (does not mutate input). */ +export function recordDuration(timing: PanelTiming, durationMs: number): PanelTiming { + const durations = [...timing.durations, durationMs].slice(-WINDOW_SIZE); + return { ...timing, durations }; +} + +/** Compute average duration from the rolling window. */ +export function averageDuration(timing: PanelTiming): number { + if (timing.durations.length === 0) { return 0; } + const sum = timing.durations.reduce((a, b) => a + b, 0); + return sum / timing.durations.length; +} + +/** Should this panel be isolated? Requires full window above threshold. */ +export function shouldIsolate(timing: PanelTiming, thresholdMs: number): boolean { + if (timing.isolated) { return false; } + if (timing.durations.length < WINDOW_SIZE) { return false; } + return averageDuration(timing) > thresholdMs; +} + +/** Should this isolated panel rejoin the main loop? Requires 5 consecutive fast ticks. */ +export function shouldRejoin(timing: PanelTiming): boolean { + if (!timing.isolated) { return false; } + return timing.consecutiveFast >= WINDOW_SIZE; +} + +/** Mark a panel as isolated. Resets consecutive fast counter. */ +export function markIsolated(timing: PanelTiming): PanelTiming { + return { ...timing, isolated: true, consecutiveFast: 0 }; +} + +/** Mark a panel as rejoined. Resets consecutive fast counter. */ +export function markRejoined(timing: PanelTiming): PanelTiming { + return { ...timing, isolated: false, consecutiveFast: 0 }; +} + +/** Increment consecutive fast count after a sub-threshold fetch. */ +export function recordFastTick(timing: PanelTiming): PanelTiming { + return { ...timing, consecutiveFast: timing.consecutiveFast + 1 }; +} + +/** Reset consecutive fast count after a slow fetch. */ +export function resetFastTick(timing: PanelTiming): PanelTiming { + return { ...timing, consecutiveFast: 0 }; +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/panels/AuditSummary.tsx b/packages/agent-os-vscode/src/webviews/sidebar/panels/AuditSummary.tsx new file mode 100644 index 00000000..0dcb2a2c --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/panels/AuditSummary.tsx @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Audit Summary Panel + * + * Compact audit event summary showing violation count, + * total events, and relative time since last event. + */ + +import React from 'react'; +import type { AuditSummaryData } from '../types'; +import { timeAgo } from '../timeUtils'; +import { Tooltip } from '../../shared/Tooltip'; +import { HELP } from '../../shared/helpContent'; + +export function AuditSummary( + { data }: { data: AuditSummaryData | null } +): React.ReactElement { + if (!data) { + return ( +
+ Awaiting audit data... +
+ ); + } + + const violationColor = data.violationsToday > 0 + ? 'text-ml-error' + : 'text-ml-success'; + + return ( +
+
+ + {data.violationsToday} + + violations +
+
+ + {data.totalToday} total today + + {data.lastEventTime && ( + + {timeAgo(data.lastEventTime)} + + )} +
+ {data.lastEventAction && ( + + {data.lastEventAction} + + )} +
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/panels/GovernanceHubSummary.tsx b/packages/agent-os-vscode/src/webviews/sidebar/panels/GovernanceHubSummary.tsx new file mode 100644 index 00000000..38bb674b --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/panels/GovernanceHubSummary.tsx @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Governance Hub Summary Panel + * + * Large health indicator with colored status circle, + * plus a 3-stat compact row for alerts, compliance, and agent count. + */ + +import React from 'react'; +import type { GovernanceHubData } from '../types'; +import { Tooltip } from '../../shared/Tooltip'; +import { HELP } from '../../shared/helpContent'; + +const HEALTH_MAP: Record = { + healthy: { label: 'Healthy', colorClass: 'text-ml-success', dotClass: 'bg-ml-success' }, + warning: { label: 'Warning', colorClass: 'text-ml-warning', dotClass: 'bg-ml-warning' }, + critical: { label: 'Critical', colorClass: 'text-ml-error', dotClass: 'bg-ml-error' }, +}; + +function HealthIndicator( + { health }: { health: GovernanceHubData['overallHealth'] } +): React.ReactElement { + const { label, colorClass, dotClass } = HEALTH_MAP[health]; + return ( +
+ + {label} +
+ ); +} + +function StatRow(props: { + alerts: number; compliance: number; agents: number; +}): React.ReactElement { + return ( +
+
+ Alerts + 0 ? 'text-ml-error' : 'text-ml-text'}`}> + {props.alerts} + +
+
+ Compliance + {props.compliance}% +
+
+ Agents + {props.agents} +
+
+ ); +} + +export function GovernanceHubSummary( + { data }: { data: GovernanceHubData | null } +): React.ReactElement { + if (!data) { + return ( +
+ Awaiting governance data... +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/panels/KernelSummary.tsx b/packages/agent-os-vscode/src/webviews/sidebar/panels/KernelSummary.tsx new file mode 100644 index 00000000..de4ee5d7 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/panels/KernelSummary.tsx @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Kernel Summary Panel + * + * 2x2 grid showing active agents, policy violations, + * checkpoints, and formatted uptime. + */ + +import React from 'react'; +import type { KernelSummaryData } from '../types'; +import { formatUptime } from '../timeUtils'; +import { Tooltip } from '../../shared/Tooltip'; +import { HELP } from '../../shared/helpContent'; + +function MetricCell(props: { + label: string; value: string; colorClass: string; tooltip: string; +}): React.ReactElement { + return ( +
+ {props.label} + {props.value} +
+ ); +} + +function buildMetrics(data: KernelSummaryData): Array<{ + label: string; value: string; colorClass: string; tooltip: string; +}> { + return [ + { label: 'Agents', value: data.activeAgents.toString(), colorClass: 'text-ml-text', + tooltip: HELP.kernel.activeAgents }, + { label: 'Violations', value: data.policyViolations.toString(), + colorClass: data.policyViolations > 0 ? 'text-ml-error' : 'text-ml-text', + tooltip: HELP.kernel.violations }, + { label: 'Checkpoints', value: data.totalCheckpoints.toString(), colorClass: 'text-ml-text', + tooltip: HELP.kernel.checkpoints }, + { label: 'Uptime', value: formatUptime(data.uptimeSeconds), colorClass: 'text-ml-text', + tooltip: HELP.kernel.uptime }, + ]; +} + +export function KernelSummary( + { data }: { data: KernelSummaryData | null } +): React.ReactElement { + if (!data) { + return ( +
+ Awaiting kernel data... +
+ ); + } + + const metrics = buildMetrics(data); + + return ( +
+
+ {metrics.map((m) => ( + + ))} +
+
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/panels/MemorySummary.tsx b/packages/agent-os-vscode/src/webviews/sidebar/panels/MemorySummary.tsx new file mode 100644 index 00000000..cfb62025 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/panels/MemorySummary.tsx @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Memory Summary Panel + * + * Compact display of VFS directory/file counts + * with up to 3 root paths shown in monospace. + */ + +import React from 'react'; +import type { MemorySummaryData } from '../types'; +import { Tooltip } from '../../shared/Tooltip'; +import { HELP } from '../../shared/helpContent'; + +const MAX_VISIBLE_PATHS = 3; + +function PathList({ paths }: { paths: string[] }): React.ReactElement { + const visible = paths.slice(0, MAX_VISIBLE_PATHS); + const remaining = paths.length - MAX_VISIBLE_PATHS; + + return ( +
+ {visible.map((p) => ( +
{p}
+ ))} + {remaining > 0 && ( +
+{remaining} more
+ )} +
+ ); +} + +export function MemorySummary( + { data }: { data: MemorySummaryData | null } +): React.ReactElement { + if (!data) { + return ( +
+ Awaiting VFS data... +
+ ); + } + + return ( +
+
+ {data.directoryCount} dirs + {data.fileCount} files +
+ {data.rootPaths.length > 0 && } +
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/panels/PolicySummary.tsx b/packages/agent-os-vscode/src/webviews/sidebar/panels/PolicySummary.tsx new file mode 100644 index 00000000..b7d84d38 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/panels/PolicySummary.tsx @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Policy Summary Panel + * + * Shows enabled/total rule count headline with a 2x2 + * metric grid for deny, block, evaluations, and violations. + */ + +import React from 'react'; +import type { PolicySummaryData } from '../types'; +import { Tooltip } from '../../shared/Tooltip'; +import { HELP } from '../../shared/helpContent'; + +function MetricCell(props: { + label: string; value: number; colorClass: string; tooltip: string; +}): React.ReactElement { + return ( +
+ {props.label} + {props.value} +
+ ); +} + +function buildMetrics(data: PolicySummaryData): Array<{ + label: string; value: number; colorClass: string; tooltip: string; +}> { + return [ + { label: 'DENY', value: data.denyRules, colorClass: data.denyRules > 0 ? 'text-ml-error' : 'text-ml-text', + tooltip: HELP.policy.deny }, + { label: 'BLOCK', value: data.blockRules, colorClass: data.blockRules > 0 ? 'text-ml-error' : 'text-ml-text', + tooltip: HELP.policy.block }, + { label: 'Evals Today', value: data.evaluationsToday, colorClass: 'text-ml-text', + tooltip: HELP.policy.evaluations }, + { label: 'Violations', value: data.violationsToday, colorClass: data.violationsToday > 0 ? 'text-ml-error' : 'text-ml-text', + tooltip: HELP.policy.violations }, + ]; +} + +export function PolicySummary( + { data }: { data: PolicySummaryData | null } +): React.ReactElement { + if (!data) { + return ( +
+ Awaiting policy data... +
+ ); + } + + const metrics = buildMetrics(data); + + return ( +
+
+ {data.enabledRules}/{data.totalRules} + rules active +
+
+ {metrics.map((m) => ( + + ))} +
+
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/panels/SLOSummary.tsx b/packages/agent-os-vscode/src/webviews/sidebar/panels/SLOSummary.tsx new file mode 100644 index 00000000..95c3c15f --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/panels/SLOSummary.tsx @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * SLO Summary Panel + * + * Compact 2x2 metric grid showing availability, latency, + * compliance, and trust scores with health-color coding. + */ + +import React from 'react'; +import type { SLOSummaryData } from '../types'; +import { percentColor, latencyColor, trustColor } from '../healthColors'; +import { Tooltip } from '../../shared/Tooltip'; +import { HELP } from '../../shared/helpContent'; + +/** A single compact metric cell with label, value, and suffix. */ +function MiniMetric(props: { + label: string; + value: string; + suffix: string; + colorClass: string; + tooltip: string; +}): React.ReactElement { + return ( +
+ {props.label} + + {props.value} + + {props.suffix} + + +
+ ); +} + +/** Builds the array of metric props from SLO data. */ +function buildMetrics(data: SLOSummaryData): Array<{ + label: string; value: string; suffix: string; colorClass: string; tooltip: string; +}> { + return [ + { label: 'Avail', value: data.availability.toFixed(1), suffix: '%', + colorClass: percentColor(data.availability, data.availabilityTarget), + tooltip: HELP.slo.availability }, + { label: 'P99', value: Math.round(data.latencyP99).toString(), suffix: 'ms', + colorClass: latencyColor(data.latencyP99, data.latencyTarget), + tooltip: HELP.slo.latencyP99 }, + { label: 'Comply', value: data.compliancePercent.toFixed(1), suffix: '%', + colorClass: percentColor(data.compliancePercent, 100), + tooltip: HELP.slo.compliance }, + { label: 'Trust', value: Math.round(data.trustMean).toString(), suffix: '', + colorClass: trustColor(data.trustMean), + tooltip: HELP.slo.trust }, + ]; +} + +export function SLOSummary( + { data }: { data: SLOSummaryData | null } +): React.ReactElement { + if (!data) { + return ( +
+ Awaiting SLO data... +
+ ); + } + + const metrics = buildMetrics(data); + const hasViolations = data.violationsToday > 0; + const violationLabel = `${data.violationsToday} violation${data.violationsToday !== 1 ? 's' : ''} today`; + + return ( +
+
+ {metrics.map((m) => ( + + ))} +
+ {hasViolations && ( +
+ {violationLabel} +
+ )} +
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/panels/StatsSummary.tsx b/packages/agent-os-vscode/src/webviews/sidebar/panels/StatsSummary.tsx new file mode 100644 index 00000000..93589e6d --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/panels/StatsSummary.tsx @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Stats Summary Panel + * + * Vertical list of 5 metric rows with conditional + * health-color coding for blocked and warning counts. + */ + +import React from 'react'; +import type { StatsSummaryData } from '../types'; +import { Tooltip } from '../../shared/Tooltip'; +import { HELP } from '../../shared/helpContent'; + +function MetricRow(props: { + label: string; value: number; colorClass: string; tooltip: string; +}): React.ReactElement { + return ( +
+ {props.label} + {props.value} +
+ ); +} + +function buildRows(data: StatsSummaryData): Array<{ + label: string; value: number; colorClass: string; tooltip: string; +}> { + return [ + { label: 'Blocked Today', value: data.blockedToday, + colorClass: data.blockedToday > 0 ? 'text-ml-error' : 'text-ml-text', + tooltip: HELP.stats.blockedToday }, + { label: 'Blocked This Week', value: data.blockedThisWeek, colorClass: 'text-ml-text', + tooltip: HELP.stats.blockedThisWeek }, + { label: 'Warnings Today', value: data.warningsToday, + colorClass: data.warningsToday > 0 ? 'text-ml-warning' : 'text-ml-text', + tooltip: HELP.stats.warnings }, + { label: 'CMVK Reviews', value: data.cmvkReviews, colorClass: 'text-ml-text', + tooltip: HELP.stats.cmvkReviews }, + { label: 'Total Logs', value: data.totalLogs, colorClass: 'text-ml-text', + tooltip: HELP.stats.totalLogs }, + ]; +} + +export function StatsSummary( + { data }: { data: StatsSummaryData | null } +): React.ReactElement { + if (!data) { + return ( +
+ Awaiting stats data... +
+ ); + } + + const rows = buildRows(data); + + return ( +
+ {rows.map((r) => ( + + ))} +
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/panels/TopologySummary.tsx b/packages/agent-os-vscode/src/webviews/sidebar/panels/TopologySummary.tsx new file mode 100644 index 00000000..ad9f500d --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/panels/TopologySummary.tsx @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Topology Summary Panel + * + * Compact 2x2 grid showing agent count, bridge count, + * mean trust score, and delegation chain count. + */ + +import React from 'react'; +import type { TopologySummaryData } from '../types'; +import { trustColor } from '../healthColors'; +import { Tooltip } from '../../shared/Tooltip'; +import { HELP } from '../../shared/helpContent'; + +/** A single compact metric cell with label and value. */ +function MetricCell(props: { + label: string; + value: string; + colorClass?: string; + tooltip: string; +}): React.ReactElement { + return ( +
+ {props.label} + + {props.value} + +
+ ); +} + +export function TopologySummary( + { data }: { data: TopologySummaryData | null } +): React.ReactElement { + if (!data) { + return ( +
+ Awaiting topology data... +
+ ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/pickerUtils.ts b/packages/agent-os-vscode/src/webviews/sidebar/pickerUtils.ts new file mode 100644 index 00000000..3c5b0141 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/pickerUtils.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Panel Picker Utilities + * + * Pure functions for slot assignment logic. + * Extracted for testability without JSX dependency. + */ + +import type { PanelId, SlotConfig } from './types'; + +export type SlotKey = 'slotA' | 'slotB' | 'slotC'; + +export const SLOT_KEYS: SlotKey[] = ['slotA', 'slotB', 'slotC']; + +/** Returns the slot key a panel is assigned to, or null. */ +export function findSlotForPanel(draft: SlotConfig, panelId: PanelId): SlotKey | null { + for (const key of SLOT_KEYS) { + if (draft[key] === panelId) { + return key; + } + } + return null; +} + +/** Badge letter for a slot key. */ +export function slotBadgeLetter(key: SlotKey): string { + return key.replace('slot', ''); +} + +/** Check if draft differs from current config. */ +export function hasChanges(current: SlotConfig, draft: SlotConfig): boolean { + return ( + current.slotA !== draft.slotA || + current.slotB !== draft.slotB || + current.slotC !== draft.slotC + ); +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/priorityEngine.ts b/packages/agent-os-vscode/src/webviews/sidebar/priorityEngine.ts new file mode 100644 index 00000000..260ff883 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/priorityEngine.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Priority Engine + * + * Pure functions that rank panels by health urgency. + * No React, no vscode, no side effects. + */ + +import type { PanelId, SidebarState, SlotConfig, UrgencyLevel } from './types'; + +const ALL_PANELS: PanelId[] = [ + 'governance-hub', 'slo-dashboard', 'audit-log', 'agent-topology', + 'active-policies', 'safety-stats', 'kernel-debugger', 'memory-browser', +]; + +const URGENCY_RANK: Record = { + critical: 0, warning: 1, healthy: 2, unknown: 3, +}; + +/** Threshold-based health check: returns urgency from data or 'unknown' if null. */ +function thresholdHealth( + data: unknown, criticalIf: (d: any) => boolean, warningIf: (d: any) => boolean, +): UrgencyLevel { + if (!data) { return 'unknown'; } + if (criticalIf(data)) { return 'critical'; } + if (warningIf(data)) { return 'warning'; } + return 'healthy'; +} + +/** Per-panel health extractors. Each returns an UrgencyLevel from SidebarState. */ +const HEALTH_EXTRACTORS: Record UrgencyLevel> = { + 'slo-dashboard': (s) => thresholdHealth(s.slo, + d => d.availability < d.availabilityTarget || d.compliancePercent < 90, + d => d.compliancePercent < 95 || d.violationsToday > 0), + 'audit-log': (s) => thresholdHealth(s.audit, + d => d.violationsToday > 10, d => d.violationsToday > 0), + 'agent-topology': (s) => thresholdHealth(s.topology, + d => d.meanTrust < 400, d => d.meanTrust < 600), + 'governance-hub': (s) => s.hub ? s.hub.overallHealth : 'unknown', + 'active-policies': (s) => thresholdHealth(s.policy, + d => d.violationsToday > 5, d => d.violationsToday > 0), + 'safety-stats': (s) => thresholdHealth(s.stats, + d => d.blockedToday > 10, d => d.blockedToday > 0), + 'kernel-debugger': (s) => thresholdHealth(s.kernel, + d => d.policyViolations > 5, d => d.policyViolations > 0), + 'memory-browser': (s) => s.memory ? 'healthy' : 'unknown', +}; + +/** Extract the health urgency of a single panel from sidebar state. */ +export function extractPanelHealth(panelId: PanelId, state: SidebarState): UrgencyLevel { + return HEALTH_EXTRACTORS[panelId](state); +} + +/** Rank all panels by urgency, return top 3 as SlotConfig. Tiebreaker: user config order. */ +export function rankPanelsByUrgency(state: SidebarState, userSlots: SlotConfig): SlotConfig { + const userOrder = [userSlots.slotA, userSlots.slotB, userSlots.slotC]; + + const ranked = ALL_PANELS + .map(id => ({ id, urgency: extractPanelHealth(id, state) })) + .sort((a, b) => { + const diff = URGENCY_RANK[a.urgency] - URGENCY_RANK[b.urgency]; + if (diff !== 0) { return diff; } + const aIdx = userOrder.indexOf(a.id); + const bIdx = userOrder.indexOf(b.id); + return (aIdx >= 0 ? aIdx : ALL_PANELS.length) - (bIdx >= 0 ? bIdx : ALL_PANELS.length); + }); + + return { slotA: ranked[0].id, slotB: ranked[1].id, slotC: ranked[2].id }; +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/scanController.ts b/packages/agent-os-vscode/src/webviews/sidebar/scanController.ts new file mode 100644 index 00000000..253a04a3 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/scanController.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Scan Controller + * + * Pure functions for sidebar scan rotation logic. + * No React, no timers, no side effects. + */ + +import type { AttentionMode, SlotKey } from './types'; + +const SLOT_ORDER: SlotKey[] = ['slotA', 'slotB', 'slotC']; + +/** Scan interval in milliseconds (4 seconds per slot). */ +export const SCAN_INTERVAL_MS = 4000; + +/** Idle delay before resuming scan after hover/focus (2 seconds). */ +export const IDLE_RESUME_MS = 2000; + +/** Rotate to the next slot in A → B → C → A order. */ +export function nextSlot(current: SlotKey): SlotKey { + const idx = SLOT_ORDER.indexOf(current); + return SLOT_ORDER[(idx + 1) % SLOT_ORDER.length]; +} + +/** Should scanning be active? Only in auto mode without reduced motion. */ +export function shouldScan(mode: AttentionMode, reducedMotion: boolean): boolean { + return mode === 'auto' && !reducedMotion; +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/storeEventWiring.ts b/packages/agent-os-vscode/src/webviews/sidebar/storeEventWiring.ts new file mode 100644 index 00000000..0f1901a0 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/storeEventWiring.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Store Event Wiring + * + * Subscribes GovernanceStore fetch groups to external change events. + * Extracted to keep GovernanceStore within the 250-line Section 4 limit. + */ + +import type * as vscode from 'vscode'; + +/** Event source with a vscode.Event change signal. */ +export interface ChangeSource { + onDidChange: vscode.Event; +} + +/** + * Wire change event sources to store fetch callbacks. + * Returns disposables for cleanup. + */ +export function wireStoreEvents( + liveClient: ChangeSource | undefined, + auditLogger: ChangeSource | undefined, + onLiveChange: () => void, + onLocalChange: () => void, +): vscode.Disposable[] { + const subs: vscode.Disposable[] = []; + if (liveClient) { + subs.push(liveClient.onDidChange(onLiveChange)); + } + if (auditLogger) { + subs.push(auditLogger.onDidChange(onLocalChange)); + } + return subs; +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/timeUtils.ts b/packages/agent-os-vscode/src/webviews/sidebar/timeUtils.ts new file mode 100644 index 00000000..2524ae23 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/timeUtils.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Time Utilities + * + * Pure functions for time formatting. + * Extracted for testability without JSX dependency. + */ + +/** Converts an ISO timestamp to a relative time string. */ +export function timeAgo(isoString: string): string { + const now = Date.now(); + const then = new Date(isoString).getTime(); + const diffMs = now - then; + + if (diffMs < 0) { return 'just now'; } + + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) { return 'just now'; } + if (minutes < 60) { return `${minutes}m ago`; } + + const hours = Math.floor(minutes / 60); + if (hours < 24) { return `${hours}h ago`; } + + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +/** Formats seconds into a compact uptime string (e.g. "3h 12m" or "45m"). */ +export function formatUptime(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) { return `${h}h ${m}m`; } + return `${m}m`; +} diff --git a/packages/agent-os-vscode/src/webviews/sidebar/types.ts b/packages/agent-os-vscode/src/webviews/sidebar/types.ts new file mode 100644 index 00000000..bf6238e9 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sidebar/types.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Sidebar Types + * + * Shared type definitions for the 3-slot sidebar system. + * Used by both the extension host (SidebarProvider) and the React webview. + */ + +/** All available panel identifiers. */ +export type PanelId = + | 'governance-hub' + | 'slo-dashboard' + | 'audit-log' + | 'agent-topology' + | 'active-policies' + | 'safety-stats' + | 'kernel-debugger' + | 'memory-browser'; + +/** Human-readable labels for each panel. */ +export const PANEL_LABELS: Record = { + 'governance-hub': 'Governance Hub', + 'slo-dashboard': 'SLO Dashboard', + 'audit-log': 'Audit Log', + 'agent-topology': 'Agent Topology', + 'active-policies': 'Active Policies', + 'safety-stats': 'Safety Stats', + 'kernel-debugger': 'Kernel Debugger', + 'memory-browser': 'Memory Browser', +}; + +/** Codicon identifiers for each panel. */ +export const PANEL_ICONS: Record = { + 'governance-hub': 'shield', + 'slo-dashboard': 'graph-line', + 'audit-log': 'list-unordered', + 'agent-topology': 'type-hierarchy', + 'active-policies': 'law', + 'safety-stats': 'pie-chart', + 'kernel-debugger': 'debug-alt', + 'memory-browser': 'database', +}; + +/** Which panel is assigned to each slot. */ +export interface SlotConfig { + slotA: PanelId; + slotB: PanelId; + slotC: PanelId; +} + +/** Default slot assignments. */ +export const DEFAULT_SLOTS: SlotConfig = { + slotA: 'slo-dashboard', + slotB: 'audit-log', + slotC: 'agent-topology', +}; + +/** Compact SLO data for the sidebar summary. */ +export interface SLOSummaryData { + availability: number; + availabilityTarget: number; + latencyP99: number; + latencyTarget: number; + compliancePercent: number; + violationsToday: number; + trustMean: number; + agentsBelowThreshold: number; +} + +/** Compact audit data for the sidebar summary. */ +export interface AuditSummaryData { + totalToday: number; + violationsToday: number; + lastEventTime: string | null; + lastEventAction: string | null; +} + +/** Compact topology data for the sidebar summary. */ +export interface TopologySummaryData { + agentCount: number; + bridgeCount: number; + meanTrust: number; + delegationCount: number; +} + +/** Compact policy data for the sidebar summary. */ +export interface PolicySummaryData { + totalRules: number; + enabledRules: number; + denyRules: number; + blockRules: number; + evaluationsToday: number; + violationsToday: number; +} + +/** Compact safety stats for the sidebar summary. */ +export interface StatsSummaryData { + blockedToday: number; + blockedThisWeek: number; + warningsToday: number; + cmvkReviews: number; + totalLogs: number; +} + +/** Compact kernel state for the sidebar summary. */ +export interface KernelSummaryData { + activeAgents: number; + policyViolations: number; + totalCheckpoints: number; + uptimeSeconds: number; +} + +/** Compact VFS state for the sidebar summary. */ +export interface MemorySummaryData { + directoryCount: number; + fileCount: number; + rootPaths: string[]; +} + +/** Governance Hub composite summary. */ +export interface GovernanceHubData { + overallHealth: 'healthy' | 'warning' | 'critical'; + activeAlerts: number; + policyCompliance: number; + agentCount: number; +} + +/** Full sidebar state pushed from extension host to webview. */ +export interface SidebarState { + slots: SlotConfig; + slo: SLOSummaryData | null; + audit: AuditSummaryData | null; + topology: TopologySummaryData | null; + policy: PolicySummaryData | null; + stats: StatsSummaryData | null; + kernel: KernelSummaryData | null; + memory: MemorySummaryData | null; + hub: GovernanceHubData | null; + stalePanels: PanelId[]; + attentionMode: AttentionMode; + userSlots: SlotConfig; +} + +/** Attention mode: manual locks to user config, auto enables scanning + priority. */ +export type AttentionMode = 'manual' | 'auto'; + +/** Slot position keys for scan rotation. */ +export type SlotKey = 'slotA' | 'slotB' | 'slotC'; + +/** Health urgency level for priority ranking. */ +export type UrgencyLevel = 'critical' | 'warning' | 'healthy' | 'unknown'; + +/** Typed events for host-side coordination via GovernanceEventBus. */ +export type GovernanceEvent = + | { type: 'stateChanged'; state: SidebarState } + | { type: 'slotConfigChanged'; slots: SlotConfig } + | { type: 'refreshRequested' } + | { type: 'visibilityChanged'; visible: boolean } + | { type: 'panelIsolated'; panelId: PanelId } + | { type: 'panelRejoined'; panelId: PanelId }; + +/** Messages from extension host to webview. */ +export type HostMessage = + | { type: 'stateUpdate'; state: SidebarState }; + +/** Messages from webview to extension host. */ +export type WebviewMessage = + | { type: 'ready' } + | { type: 'setSlots'; slots: SlotConfig } + | { type: 'promotePanelToWebview'; panelId: PanelId } + | { type: 'refresh' } + | { type: 'setAttentionMode'; mode: AttentionMode }; diff --git a/packages/agent-os-vscode/src/webviews/sloDetail/SLOBudgetBar.tsx b/packages/agent-os-vscode/src/webviews/sloDetail/SLOBudgetBar.tsx new file mode 100644 index 00000000..5585426d --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sloDetail/SLOBudgetBar.tsx @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * SLO Budget Bar + * + * Horizontal bar visualizing error budget consumption. + * Color shifts from green to yellow to red as budget is consumed. + */ + +import React from 'react'; + +interface SLOBudgetBarProps { + /** Amount of budget consumed (same unit as total). */ + consumed: number; + /** Total error budget available. */ + total: number; + /** Label displayed to the left of the bar. */ + label: string; +} + +/** Map remaining-percentage to a CSS color variable. */ +function barColor(remainingPercent: number): string { + if (remainingPercent > 50) { return 'var(--vscode-testing-iconPassed)'; } + if (remainingPercent > 20) { return 'var(--vscode-list-warningForeground)'; } + return 'var(--vscode-errorForeground)'; +} + +/** + * Error budget consumption bar. + * + * Displays a horizontal bar showing how much error budget has been used. + * The fill color indicates urgency based on remaining budget. + */ +export function SLOBudgetBar({ consumed, total, label }: SLOBudgetBarProps): React.JSX.Element { + const safeTotal = total > 0 ? total : 1; + const fillPercent = Math.min((consumed / safeTotal) * 100, 100); + const remainingPercent = 100 - fillPercent; + const color = barColor(remainingPercent); + + return ( +
+
+ {label} + + {remainingPercent.toFixed(1)}% remaining + +
+
+
+
+
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sloDetail/SLODetail.tsx b/packages/agent-os-vscode/src/webviews/sloDetail/SLODetail.tsx new file mode 100644 index 00000000..2aa206f4 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sloDetail/SLODetail.tsx @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * SLO Detail Panel + * + * Root component for the full-panel SLO dashboard. Displays gauges, + * sparklines, budget bars, and trust distribution in a structured layout. + */ + +import React from 'react'; +import { DetailShell } from '../shared/DetailShell'; +import { useExtensionMessage } from '../shared/useExtensionMessage'; +import { getVSCodeAPI } from '../shared/vscode'; +import { trustColor } from '../sidebar/healthColors'; +import { Tooltip } from '../shared/Tooltip'; +import { HELP } from '../shared/helpContent'; +import type { SLODetailData } from '../shared/types'; +import { SLOGauge } from './SLOGauge'; +import { SLOSparkline } from './SLOSparkline'; +import { SLOBudgetBar } from './SLOBudgetBar'; + +interface SLODetailProps { + /** Optional data prop for testing. Falls back to extension messages. */ + data?: SLODetailData; + /** When true, render content only (no DetailShell wrapper). Used by Hub embedding. */ + embedded?: boolean; +} + +/** Trust distribution bucket labels. */ +const TRUST_BUCKETS = ['0-250', '251-500', '501-750', '751-1000'] as const; + +/** Trust bucket midpoint scores for color mapping. */ +const TRUST_MIDPOINTS = [125, 375, 625, 875] as const; + +/** Loading placeholder shown before data arrives. */ +function LoadingState(): React.JSX.Element { + return ( +
+ Waiting for SLO data... +
+ ); +} + +/** Top row: availability and compliance gauges side by side. */ +function GaugeRow({ data }: { data: SLODetailData }): React.JSX.Element { + return ( +
+ + +
+ ); +} + +/** Latency breakdown with stacked bars for P50, P95, P99. */ +function LatencySection({ data }: { data: SLODetailData }): React.JSX.Element { + return ( +
+

+ Latency +

+ + + +
+ ); +} + +/** Single latency bar showing value vs target. */ +function LatencyBar({ label, value, target }: { label: string; value: number; target: number }): React.JSX.Element { + const pct = Math.min((value / target) * 100, 150); + const isOver = value > target; + const color = isOver ? 'var(--vscode-errorForeground)' : 'var(--vscode-testing-iconPassed)'; + + return ( +
+ {label} +
+
+
+ + {value.toFixed(0)}ms + +
+ ); +} + +/** Burn rate sparkline with current value label. */ +function BurnRateSection({ data }: { data: SLODetailData }): React.JSX.Element { + return ( +
+
+

+ Burn Rate +

+ {data.burnRate.toFixed(2)}x +
+ +
+ ); +} + +/** Error budget bars for availability and latency. */ +function BudgetSection({ data }: { data: SLODetailData }): React.JSX.Element { + const availConsumed = 100 - data.availabilityBudgetRemaining; + const latencyConsumed = 100 - data.latencyBudgetRemaining; + + return ( +
+

+ Error Budgets +

+ + +
+ ); +} + +/** Trust distribution as horizontal segments with counts. */ +function TrustSection({ data }: { data: SLODetailData }): React.JSX.Element { + const total = data.trustDistribution.reduce((a, b) => a + b, 0) || 1; + + return ( +
+
+

+ Trust Distribution +

+ + mean {data.trustMean.toFixed(0)} / min {data.trustMin.toFixed(0)} + +
+
+ {data.trustDistribution.map((count, i) => ( + + ))} +
+
+ ); +} + +/** Single trust distribution bucket row. */ +function TrustBucket( + { label, count, total, midpoint }: { label: string; count: number; total: number; midpoint: number } +): React.JSX.Element { + const pct = (count / total) * 100; + const colorClass = trustColor(midpoint); + const colorMap: Record = { + 'text-ml-success': 'var(--vscode-testing-iconPassed)', + 'text-ml-warning': 'var(--vscode-list-warningForeground)', + 'text-ml-error': 'var(--vscode-errorForeground)', + }; + const color = colorMap[colorClass] ?? 'var(--vscode-foreground)'; + + return ( +
+ {label} +
+
+
+ {count} +
+ ); +} + +/** + * Root SLO detail panel component. + * + * Accepts optional data prop for testing. When not provided, + * listens for 'sloDetailUpdate' messages from the extension host. + */ +export function SLODetail({ data: propData, embedded }: SLODetailProps = {}): React.JSX.Element { + const messageData = useExtensionMessage('sloDetailUpdate'); + const data = propData ?? messageData; + const content = data ? : ; + + if (embedded) { return content; } + + return ( + getVSCodeAPI().postMessage({ type: 'refresh' })} + > + {content} + + ); +} + +/** Main content layout with all SLO sections. Exported for Hub embedding. */ +export function SLOContent({ data }: { data: SLODetailData }): React.JSX.Element { + return ( +
+ + + + + +
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sloDetail/SLODetailPanel.ts b/packages/agent-os-vscode/src/webviews/sloDetail/SLODetailPanel.ts new file mode 100644 index 00000000..c8a9df73 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sloDetail/SLODetailPanel.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * SLO Detail Panel -- thin config wrapper over shared panelHost. + */ + +import type { GovernanceStore } from '../sidebar/GovernanceStore'; +import { createDetailPanel } from '../shared/panelHost'; + +export function showSLODetail(extensionUri: import('vscode').Uri, store: GovernanceStore): void { + createDetailPanel( + { viewType: 'agent-os.sloDetail', title: 'SLO Detail', scriptFolder: 'sloDetail' }, + extensionUri, + { + onStoreData: (panel) => store.onDetailSubscribe('slo', (data) => { + panel.webview.postMessage({ type: 'sloDetailUpdate', data }); + }), + onRefresh: () => store.refreshNow(), + }, + ); +} diff --git a/packages/agent-os-vscode/src/webviews/sloDetail/SLOGauge.tsx b/packages/agent-os-vscode/src/webviews/sloDetail/SLOGauge.tsx new file mode 100644 index 00000000..2074ffa2 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sloDetail/SLOGauge.tsx @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * SLO Gauge + * + * Pure SVG radial gauge for displaying a percentage value against a target. + * Uses a 270-degree arc with color derived from health status. + */ + +import React from 'react'; +import { percentColor } from '../sidebar/healthColors'; + +interface SLOGaugeProps { + /** Current value (0-100). */ + value: number; + /** Target threshold (0-100). */ + target: number; + /** Label shown below the gauge. */ + label: string; +} + +/** Arc geometry constants. */ +const RADIUS = 48; +const CENTER = 60; +const STROKE_WIDTH = 8; +const ARC_DEGREES = 270; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; +const ARC_LENGTH = (ARC_DEGREES / 360) * CIRCUMFERENCE; + +/** Map Tailwind health class to a CSS variable for SVG stroke. */ +const COLOR_MAP: Record = { + 'text-ml-success': 'var(--vscode-testing-iconPassed)', + 'text-ml-warning': 'var(--vscode-list-warningForeground)', + 'text-ml-error': 'var(--vscode-errorForeground)', +}; + +/** Compute the stroke color CSS variable from a health class. */ +function strokeColor(healthClass: string): string { + return COLOR_MAP[healthClass] ?? 'var(--vscode-foreground)'; +} + +/** Compute stroke-dashoffset for the filled portion of the arc. */ +function arcOffset(value: number): number { + const clamped = Math.max(0, Math.min(100, value)); + const filled = (clamped / 100) * ARC_LENGTH; + return ARC_LENGTH - filled; +} + +/** + * Radial gauge with 270-degree arc. + * + * Displays a percentage metric with color coding based on + * proximity to target. Animates via CSS transition. + */ +export function SLOGauge({ value, target, label }: SLOGaugeProps): React.JSX.Element { + const healthClass = percentColor(value, target); + const color = strokeColor(healthClass); + const offset = arcOffset(value); + + return ( +
+ + + + + {value.toFixed(1)}% + + + target {target}% + + + {label} +
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/sloDetail/SLOSparkline.tsx b/packages/agent-os-vscode/src/webviews/sloDetail/SLOSparkline.tsx new file mode 100644 index 00000000..6af901a1 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sloDetail/SLOSparkline.tsx @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * SLO Sparkline + * + * Pure SVG sparkline for displaying a time series as a small line chart + * with a gradient fill below. Handles empty data gracefully. + */ + +import React, { useId } from 'react'; + +interface SLOSparklineProps { + /** Array of numeric data points. */ + points: number[]; + /** SVG height in pixels. */ + height?: number; + /** SVG width as a CSS value. */ + width?: string; + /** Stroke color as a CSS variable or color string. */ + color?: string; +} + +/** Convert data points to an SVG polyline points string. */ +function toPolylinePoints(data: number[], w: number, h: number, padding: number): string { + if (data.length < 2) { return ''; } + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min || 1; + const usableH = h - padding * 2; + const step = w / (data.length - 1); + + return data + .map((v, i) => { + const x = i * step; + const y = padding + usableH - ((v - min) / range) * usableH; + return `${x},${y}`; + }) + .join(' '); +} + +/** Build the fill polygon points (line + bottom edge). */ +function toFillPoints(data: number[], w: number, h: number, padding: number): string { + const linePoints = toPolylinePoints(data, w, h, padding); + if (!linePoints) { return ''; } + const step = w / (data.length - 1); + const lastX = step * (data.length - 1); + return `0,${h} ${linePoints} ${lastX},${h}`; +} + +/** + * Compact sparkline chart. + * + * Renders a polyline with gradient fill. Returns an empty placeholder + * when fewer than 2 data points are provided. + */ +export function SLOSparkline({ + points, + height = 60, + width = '100%', + color = 'var(--ml-accent)', +}: SLOSparklineProps): React.JSX.Element { + const gradientId = useId(); + const svgWidth = 200; + const padding = 4; + + if (points.length < 2) { + return ( +
+ No data +
+ ); + } + + const lineStr = toPolylinePoints(points, svgWidth, height, padding); + const fillStr = toFillPoints(points, svgWidth, height, padding); + + return ( + + + + + + + + + + + ); +} diff --git a/packages/agent-os-vscode/src/webviews/sloDetail/main.tsx b/packages/agent-os-vscode/src/webviews/sloDetail/main.tsx new file mode 100644 index 00000000..fd212dae --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/sloDetail/main.tsx @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * SLO Detail Entry Point + * + * Mounts the SLO detail React app into the webview panel. + */ + +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { SLODetail } from './SLODetail'; + +const container = document.getElementById('root'); +if (container) { + createRoot(container).render(); +} diff --git a/packages/agent-os-vscode/src/webviews/topologyDetail/ForceGraph.tsx b/packages/agent-os-vscode/src/webviews/topologyDetail/ForceGraph.tsx new file mode 100644 index 00000000..7fcf0b03 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/topologyDetail/ForceGraph.tsx @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Force Graph + * + * Imperative SVG renderer for the force-directed topology graph. + * React manages lifecycle; the simulation mutates SVG directly via refs. + * Nodes are colored by trust tier and sized by the simulation engine. + * + * DOM elements are created once per data change, then updated in-place + * each animation frame (setAttribute only -- zero createElement per tick). + */ + +import React, { useRef, useEffect, useCallback } from 'react'; +import type { TopologyNode, TopologyEdge } from '../shared/types'; +import { createSimulation, toSimNode } from '../shared/forceSimulation'; +import type { SimNode, SimEdge, Simulation } from '../shared/forceSimulation'; + +interface ForceGraphProps { + nodes: TopologyNode[]; + edges: TopologyEdge[]; + width: number; + height: number; + zoom: number; + onSelectNode?: (id: string) => void; +} + +// --------------------------------------------------------------------------- +// Trust tier color mapping using VS Code theme tokens +// --------------------------------------------------------------------------- + +/** Return a CSS color variable based on the trust score tier. */ +function trustFill(trust: number): string { + if (trust >= 750) { return 'var(--vscode-testing-iconPassed)'; } + if (trust >= 400) { return 'var(--vscode-list-warningForeground)'; } + return 'var(--vscode-errorForeground)'; +} + +/** Truncate a label to at most 12 characters with ellipsis. */ +function truncateLabel(label: string): string { + if (label.length <= 12) { return label; } + return label.slice(0, 11) + '\u2026'; +} + +// --------------------------------------------------------------------------- +// SVG element helpers +// --------------------------------------------------------------------------- + +/** Clear all children from an SVG group element. */ +function clearGroup(g: SVGGElement): void { + while (g.firstChild) { g.removeChild(g.firstChild); } +} + +/** Create an SVG element in the SVG namespace. */ +function svgEl( + tag: K, + attrs: Record, +): SVGElementTagNameMap[K] { + const el = document.createElementNS('http://www.w3.org/2000/svg', tag); + for (const [k, v] of Object.entries(attrs)) { el.setAttribute(k, v); } + return el; +} + +// --------------------------------------------------------------------------- +// Persistent element builders (run once per data change) +// --------------------------------------------------------------------------- + +/** Build edge elements, append to group, return array. */ +function buildEdgeElements(g: SVGGElement, count: number): SVGLineElement[] { + const els: SVGLineElement[] = []; + for (let i = 0; i < count; i++) { + const line = svgEl('line', { + stroke: 'var(--vscode-editorWidget-border)', + 'stroke-width': '1', + 'stroke-opacity': '0.5', + }); + g.appendChild(line); + els.push(line); + } + return els; +} + +/** Build node elements, append to group, return array. */ +function buildNodeElements( + g: SVGGElement, + simNodes: readonly SimNode[], + onClick: ((id: string) => void) | undefined, +): SVGCircleElement[] { + const els: SVGCircleElement[] = []; + for (const node of simNodes) { + const circle = svgEl('circle', { + r: String(node.radius), + fill: trustFill(node.trust), + cursor: 'pointer', + }); + if (onClick) { circle.addEventListener('click', () => onClick(node.id)); } + g.appendChild(circle); + els.push(circle); + } + return els; +} + +/** Build label elements, append to group, return array. */ +function buildLabelElements( + g: SVGGElement, + simNodes: readonly SimNode[], + labels: Map, +): SVGTextElement[] { + const els: SVGTextElement[] = []; + for (const node of simNodes) { + const text = svgEl('text', { + 'text-anchor': 'middle', + fill: 'var(--ml-text-muted)', + 'font-size': '10', + 'font-family': 'var(--ml-font)', + }); + text.textContent = truncateLabel(labels.get(node.id) ?? node.id); + g.appendChild(text); + els.push(text); + } + return els; +} + +// --------------------------------------------------------------------------- +// Per-frame position update (zero DOM creation) +// --------------------------------------------------------------------------- + +/** Update edge line positions from the node map. */ +function updateEdgePositions( + els: SVGLineElement[], + simEdges: readonly SimEdge[], + nodeMap: Map, +): void { + for (let i = 0; i < els.length; i++) { + const edge = simEdges[i]; + const src = nodeMap.get(edge.source); + const tgt = nodeMap.get(edge.target); + if (!src || !tgt) { continue; } + const el = els[i]; + el.setAttribute('x1', String(src.x)); + el.setAttribute('y1', String(src.y)); + el.setAttribute('x2', String(tgt.x)); + el.setAttribute('y2', String(tgt.y)); + } +} + +/** Update node circle and label positions. */ +function updateNodePositions( + circleEls: SVGCircleElement[], + labelEls: SVGTextElement[], + simNodes: readonly SimNode[], +): void { + for (let i = 0; i < circleEls.length; i++) { + const node = simNodes[i]; + circleEls[i].setAttribute('cx', String(node.x)); + circleEls[i].setAttribute('cy', String(node.y)); + labelEls[i].setAttribute('x', String(node.x)); + labelEls[i].setAttribute('y', String(node.y + node.radius + 12)); + } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Force-directed graph SVG component. + * + * Runs a physics simulation via requestAnimationFrame and renders + * edges and nodes imperatively for performance. + */ +export function ForceGraph(props: ForceGraphProps): React.JSX.Element { + const { nodes, edges, width, height, zoom, onSelectNode } = props; + const svgRef = useRef(null); + const groupRef = useRef(null); + const simRef = useRef(null); + const rafRef = useRef(0); + + const onSelectRef = useRef(onSelectNode); + onSelectRef.current = onSelectNode; + + const labelMap = useRef(new Map()); + const edgeEls = useRef([]); + const nodeEls = useRef([]); + const labelEls = useRef([]); + const nodeMapRef = useRef>(new Map()); + + const animate = useCallback(() => { + const sim = simRef.current; + if (!sim) { return; } + + const settled = sim.tick(); + const simNodes = sim.nodes(); + nodeMapRef.current = new Map(simNodes.map((n) => [n.id, n])); + updateEdgePositions(edgeEls.current, sim.edges(), nodeMapRef.current); + updateNodePositions(nodeEls.current, labelEls.current, simNodes); + + if (!settled) { rafRef.current = requestAnimationFrame(animate); } + }, []); + + useEffect(() => { + const g = groupRef.current; + if (!g) { return; } + + const simNodes = nodes.map((n) => toSimNode(n.id, n.trust)); + const simEdges: SimEdge[] = edges.map((e) => ({ + source: e.source, target: e.target, weight: 1.0, + })); + + labelMap.current = new Map(nodes.map((n) => [n.id, n.label])); + simRef.current = createSimulation(simNodes, simEdges, { width, height }); + + clearGroup(g); + edgeEls.current = buildEdgeElements(g, simEdges.length); + nodeEls.current = buildNodeElements(g, simNodes, onSelectRef.current); + labelEls.current = buildLabelElements(g, simNodes, labelMap.current); + nodeMapRef.current = new Map(simNodes.map((n) => [n.id, n])); + + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(animate); + + return () => { cancelAnimationFrame(rafRef.current); }; + }, [nodes, edges, width, height, animate]); + + const tx = width / 2; + const ty = height / 2; + const transform = `translate(${tx},${ty}) scale(${zoom}) translate(${-tx},${-ty})`; + + return ( + + + + ); +} diff --git a/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyControls.tsx b/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyControls.tsx new file mode 100644 index 00000000..40ae87d7 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyControls.tsx @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Topology Controls + * + * Zoom control overlay with +, -, and Reset buttons. + * Positioned absolute top-right over the force graph. + */ + +import React from 'react'; + +interface TopologyControlsProps { + zoom: number; + onZoomIn: () => void; + onZoomOut: () => void; + onReset: () => void; +} + +// --------------------------------------------------------------------------- +// Shared button style +// --------------------------------------------------------------------------- + +interface ControlButtonProps { + onClick: () => void; + label: string; + icon: string; +} + +/** Single square control button with codicon icon. */ +function ControlButton({ onClick, label, icon }: ControlButtonProps): React.JSX.Element { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Zoom control panel overlay. + * + * Displays current zoom percentage and three action buttons. + * Does not enforce min/max; that is the parent's responsibility. + */ +export function TopologyControls(props: TopologyControlsProps): React.JSX.Element { + const { zoom, onZoomIn, onZoomOut, onReset } = props; + const pct = Math.round(zoom * 100); + + return ( +
+ {pct}% + + + +
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyDetail.tsx b/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyDetail.tsx new file mode 100644 index 00000000..c9ee33e5 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyDetail.tsx @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Topology Detail + * + * Root component for the full-panel agent topology visualization. + * Composes ForceGraph, legend, zoom controls, and a stats bar. + * Accepts data via prop or extension message subscription. + */ + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import type { TopologyDetailData } from '../shared/types'; +import { useExtensionMessage } from '../shared/useExtensionMessage'; +import { getVSCodeAPI } from '../shared/vscode'; +import { DetailShell } from '../shared/DetailShell'; +import { Tooltip } from '../shared/Tooltip'; +import { HELP } from '../shared/helpContent'; +import { ForceGraph } from './ForceGraph'; +import { TopologyLegend } from './TopologyLegend'; +import { TopologyControls } from './TopologyControls'; + +// --------------------------------------------------------------------------- +// Zoom constants +// --------------------------------------------------------------------------- + +const ZOOM_MIN = 0.3; +const ZOOM_MAX = 3.0; +const ZOOM_STEP = 0.2; +const DEFAULT_WIDTH = 800; +const DEFAULT_HEIGHT = 600; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface TopologyDetailProps { + /** If provided, skip extension message subscription. */ + data?: TopologyDetailData; + /** When true, render content only (no DetailShell wrapper). Used by Hub embedding. */ + embedded?: boolean; +} + +// --------------------------------------------------------------------------- +// Zoom helpers +// --------------------------------------------------------------------------- + +/** Clamp a zoom level to the allowed range. */ +function clampZoom(z: number): number { + return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); +} + +// --------------------------------------------------------------------------- +// Stats bar sub-components +// --------------------------------------------------------------------------- + +/** Compute average trust across all nodes. */ +function meanTrust(nodes: TopologyDetailData['nodes']): number { + if (nodes.length === 0) { return 0; } + const sum = nodes.reduce((acc, n) => acc + n.trust, 0); + return Math.round(sum / nodes.length); +} + +/** Bridge status dot: green when connected, gray when not. */ +function BridgeDot({ connected }: { connected: boolean }): React.JSX.Element { + const color = connected + ? 'var(--vscode-testing-iconPassed)' + : 'var(--ml-text-muted)'; + return ( + + ); +} + +/** Stats bar showing agent count, bridge count, and average trust. */ +function StatsBar({ data }: { data: TopologyDetailData }): React.JSX.Element { + const avg = meanTrust(data.nodes); + return ( +
+ + {data.nodes.length} agents + + · + + {data.bridges.length} bridges + + · + + Trust: {avg} + + · +
+ {data.bridges.map((b) => ( +
+ + {b.protocol} +
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Loading state +// --------------------------------------------------------------------------- + +/** Placeholder shown while waiting for initial data. */ +function LoadingState(): React.JSX.Element { + return ( +
+ + Waiting for topology data... +
+ ); +} + +// --------------------------------------------------------------------------- +// Container size hook +// --------------------------------------------------------------------------- + +/** Track the size of a container element via ResizeObserver. */ +function useContainerSize(): { + ref: React.RefObject; + width: number; + height: number; +} { + const ref = useRef(null); + const [size, setSize] = useState({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }); + + useEffect(() => { + const el = ref.current; + if (!el) { return; } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { return; } + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + setSize({ width: Math.round(width), height: Math.round(height) }); + } + }); + observer.observe(el); + return () => { observer.disconnect(); }; + }, []); + + return { ref, width: size.width, height: size.height }; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +/** + * Root topology detail panel. + * + * Subscribes to extension messages for topology data, manages zoom state, + * and composes the force graph with overlay controls and a stats bar. + */ +export function TopologyDetail({ data: propData, embedded }: TopologyDetailProps): React.JSX.Element { + const msgData = useExtensionMessage('topologyDetailUpdate'); + const data = propData ?? msgData; + + const [zoom, setZoom] = useState(1.0); + const { ref, width, height } = useContainerSize(); + + const handleRefresh = useCallback(() => { + getVSCodeAPI().postMessage({ type: 'refresh' }); + }, []); + const handleZoomIn = useCallback(() => setZoom((z) => clampZoom(z + ZOOM_STEP)), []); + const handleZoomOut = useCallback(() => setZoom((z) => clampZoom(z - ZOOM_STEP)), []); + const handleZoomReset = useCallback(() => setZoom(1.0), []); + const handleSelectNode = useCallback((id: string) => { + getVSCodeAPI().postMessage({ type: 'selectAgent', did: id }); + }, []); + + const content = data ? ( +
+
+ + + +
+ +
+ ) : ; + + if (embedded) { return content; } + + return ( + + {content} + + ); +} diff --git a/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyDetailPanel.ts b/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyDetailPanel.ts new file mode 100644 index 00000000..cadc78ec --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyDetailPanel.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Topology Detail Panel -- thin config wrapper over shared panelHost. + */ + +import * as vscode from 'vscode'; +import type { GovernanceStore } from '../sidebar/GovernanceStore'; +import { createDetailPanel } from '../shared/panelHost'; + +export function showTopologyDetail(extensionUri: vscode.Uri, store: GovernanceStore): void { + createDetailPanel( + { + viewType: 'agent-os.topologyDetail', + title: 'Agent Topology', + scriptFolder: 'topologyDetail', + // SECURITY: retainContextWhenHidden preserves force simulation state (120 frames). + // Increases memory ~2MB when backgrounded. Acceptable for UX. + retainContextWhenHidden: true, + }, + extensionUri, + { + onStoreData: (panel) => store.onDetailSubscribe('topology', (data) => { + panel.webview.postMessage({ type: 'topologyDetailUpdate', data }); + }), + onRefresh: () => store.refreshNow(), + onMessage: (msg) => { + if (msg.type === 'selectAgent' && typeof msg.did === 'string') { + vscode.commands.executeCommand('agent-os.showAgentDetails', msg.did); + } + }, + }, + ); +} diff --git a/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyLegend.tsx b/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyLegend.tsx new file mode 100644 index 00000000..ca5b4302 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/topologyDetail/TopologyLegend.tsx @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Topology Legend + * + * Static overlay displaying trust tier color meanings. + * Positioned absolute bottom-left over the force graph. + */ + +import React from 'react'; + +// --------------------------------------------------------------------------- +// Legend data +// --------------------------------------------------------------------------- + +interface LegendRow { + label: string; + color: string; +} + +const ROWS: readonly LegendRow[] = [ + { label: 'High Trust (\u2265750)', color: 'var(--vscode-testing-iconPassed)' }, + { label: 'Medium (\u2265400)', color: 'var(--vscode-list-warningForeground)' }, + { label: 'Low (<400)', color: 'var(--vscode-errorForeground)' }, +]; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** Single legend row with colored dot and label. */ +function LegendItem({ row }: { row: LegendRow }): React.JSX.Element { + return ( +
+ + + {row.label} + +
+ ); +} + +/** + * Trust tier legend overlay. + * + * Renders a compact, semi-transparent panel with three trust level + * indicators. Designed to sit at the bottom-left of the graph area. + */ +export function TopologyLegend(): React.JSX.Element { + return ( +
+ {ROWS.map((row) => ( + + ))} +
+ ); +} diff --git a/packages/agent-os-vscode/src/webviews/topologyDetail/main.tsx b/packages/agent-os-vscode/src/webviews/topologyDetail/main.tsx new file mode 100644 index 00000000..c4637c26 --- /dev/null +++ b/packages/agent-os-vscode/src/webviews/topologyDetail/main.tsx @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Topology Detail Entry Point + * + * Mounts the topology detail React app into the webview. + */ + +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { TopologyDetail } from './TopologyDetail'; + +const container = document.getElementById('root'); +if (container) { + createRoot(container).render(); +} diff --git a/packages/agent-os/extensions/vscode/src/webviews/workflowDesigner/WorkflowDesignerPanel.ts b/packages/agent-os-vscode/src/webviews/workflowDesigner/WorkflowDesignerPanel.ts similarity index 97% rename from packages/agent-os/extensions/vscode/src/webviews/workflowDesigner/WorkflowDesignerPanel.ts rename to packages/agent-os-vscode/src/webviews/workflowDesigner/WorkflowDesignerPanel.ts index 62753a23..19b453ce 100644 --- a/packages/agent-os/extensions/vscode/src/webviews/workflowDesigner/WorkflowDesignerPanel.ts +++ b/packages/agent-os-vscode/src/webviews/workflowDesigner/WorkflowDesignerPanel.ts @@ -9,6 +9,8 @@ import * as vscode from 'vscode'; import * as crypto from 'crypto'; +import { escapeHtml } from '../../utils/escapeHtml'; + interface WorkflowNode { id: string; type: 'start' | 'end' | 'action' | 'condition' | 'loop' | 'parallel'; @@ -614,7 +616,9 @@ ${functionDefs} private _getHtmlForWebview() { const nonce = crypto.randomBytes(16).toString('base64'); - const cspSource = this._panel.webview.cspSource; + const webview = this._panel.webview; + const cspSource = webview.cspSource; + const nodeTypesJson = JSON.stringify(WorkflowDesignerPanel.nodeTypes); const workflowJson = JSON.stringify(this._workflow); @@ -917,6 +921,15 @@ ${functionDefs} let draggingNode = null; let connectingFrom = null; + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + // Render palette const palette = document.getElementById('palette'); nodeTypes.forEach(nt => { @@ -925,10 +938,10 @@ ${functionDefs} item.draggable = true; item.dataset.type = nt.type; item.innerHTML = \` - \${nt.icon} + \${escapeHtml(nt.icon)}
-

\${nt.label}

-

\${nt.description}

+

\${escapeHtml(nt.label)}

+

\${escapeHtml(nt.description)}

\`; item.addEventListener('dragstart', e => { @@ -955,10 +968,10 @@ ${functionDefs} div.innerHTML = \`
- \${icon} - \${node.label} + \${escapeHtml(icon)} + \${escapeHtml(node.label)}
- \${node.policy ? \`
🛡️ \${node.policy}
\` : ''} + \${node.policy ? \`
🛡️ \${escapeHtml(node.policy)}
\` : ''}
\${node.type !== 'start' ? '
' : ''} \${node.type !== 'end' ? '
' : ''} @@ -1040,22 +1053,22 @@ ${functionDefs} const nodeType = nodeTypes.find(t => t.type === node.type); panel.innerHTML = \` -

\${node.label} Properties

+

\${escapeHtml(node.label)} Properties

- +
\${node.type === 'action' && nodeType?.actions ? \`
\` : ''}
- +
@@ -1068,7 +1081,7 @@ ${functionDefs}
\${node.type !== 'start' && node.type !== 'end' ? \`
- +
\` : ''} \`; diff --git a/packages/agent-os-vscode/tailwind.config.js b/packages/agent-os-vscode/tailwind.config.js new file mode 100644 index 00000000..75853839 --- /dev/null +++ b/packages/agent-os-vscode/tailwind.config.js @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/webviews/**/*.{tsx,ts,jsx,js}'], + theme: { + extend: { + colors: { + ml: { + bg: 'var(--ml-bg)', + surface: 'var(--ml-surface)', + 'surface-hover': 'var(--ml-surface-hover)', + border: 'var(--ml-border)', + accent: 'var(--ml-accent)', + 'accent-muted': 'var(--ml-accent-muted)', + success: 'var(--ml-success)', + warning: 'var(--ml-warning)', + error: 'var(--ml-error)', + info: 'var(--ml-info)', + text: 'var(--ml-text)', + 'text-muted': 'var(--ml-text-muted)', + 'text-bright': 'var(--ml-text-bright)', + }, + }, + fontFamily: { + sans: ['var(--ml-font)'], + mono: ['var(--ml-font-mono)'], + }, + borderRadius: { + ml: 'var(--ml-radius)', + 'ml-lg': 'var(--ml-radius-lg)', + }, + spacing: { + 'ml-xs': 'var(--ml-space-xs)', + 'ml-sm': 'var(--ml-space-sm)', + 'ml-md': 'var(--ml-space-md)', + 'ml-lg': 'var(--ml-space-lg)', + 'ml-xl': 'var(--ml-space-xl)', + }, + }, + }, + plugins: [], +}; diff --git a/packages/agent-os/extensions/vscode/tsconfig.json b/packages/agent-os-vscode/tsconfig.json similarity index 76% rename from packages/agent-os/extensions/vscode/tsconfig.json rename to packages/agent-os-vscode/tsconfig.json index 51b9691c..ddbd1ab2 100644 --- a/packages/agent-os/extensions/vscode/tsconfig.json +++ b/packages/agent-os-vscode/tsconfig.json @@ -12,5 +12,5 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, - "exclude": ["node_modules", ".vscode-test"] + "exclude": ["node_modules", ".vscode-test", "src/webviews/**/*.tsx", "src/webviews/shared/**"] } diff --git a/packages/agent-os-vscode/tsconfig.webview.json b/packages/agent-os-vscode/tsconfig.webview.json new file mode 100644 index 00000000..73cecee3 --- /dev/null +++ b/packages/agent-os-vscode/tsconfig.webview.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2020", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@shared/*": ["src/webviews/shared/*"] + } + }, + "include": ["src/webviews/**/*"] +} diff --git a/packages/agent-os/extensions/vscode/CHANGELOG.md b/packages/agent-os/extensions/vscode/CHANGELOG.md deleted file mode 100644 index 69a6a7ea..00000000 --- a/packages/agent-os/extensions/vscode/CHANGELOG.md +++ /dev/null @@ -1,79 +0,0 @@ -# Changelog - -All notable changes to the Agent OS VS Code extension will be documented in this file. - -## [1.0.1] - 2026-01-29 - -### Fixed -- Workflow Designer: Delete button now works correctly on nodes -- Workflow Designer: Code generation handles empty workflows gracefully -- Workflow Designer: TypeScript and Go exports have proper type annotations - -## [1.0.0] - 2026-01-28 - -### Added - GA Release 🎉 -- **Policy Management Studio**: Visual policy editor with templates - - 5 built-in templates (Strict Security, SOC 2, GDPR, Development, Rate Limiting) - - Real-time validation - - Import/Export in YAML format - -- **Workflow Designer**: Drag-and-drop agent workflow builder - - 4 node types (Action, Condition, Loop, Parallel) - - 8 action types (file_read, http_request, llm_call, etc.) - - Code export to Python, TypeScript, Go - - Policy attachment at node level - -- **Metrics Dashboard**: Real-time monitoring - - Policy check statistics - - Activity feed with timestamps - - Export to CSV/JSON - -- **IntelliSense & Snippets** - - 14 code snippets for Python, TypeScript, YAML - - Context-aware completions for AgentOS APIs - - Hover documentation - -- **Security Diagnostics** - - Real-time vulnerability detection - - 13 security rules (os.system, eval, exec, etc.) - - Quick fixes available - -- **Enterprise Features** - - SSO integration (Azure AD, Okta, Google, GitHub) - - Role-based access control (5 roles) - - CI/CD integration (GitHub Actions, GitLab CI, Jenkins, Azure Pipelines, CircleCI) - - Compliance frameworks (SOC 2, GDPR, HIPAA, PCI DSS) - -- **Onboarding Experience** - - Interactive getting started guide - - Progress tracking - - First agent tutorial - -### Changed -- Upgraded extension architecture for GA stability -- Improved WebView performance - -## [0.1.0] - 2026-01-27 - -### Added -- Initial release -- Real-time code safety analysis -- Policy engine with 5 policy categories: - - Destructive SQL (DROP, DELETE, TRUNCATE) - - File deletes (rm -rf, unlink, rmtree) - - Secret exposure (API keys, passwords, tokens) - - Privilege escalation (sudo, chmod 777) - - Unsafe network calls (HTTP instead of HTTPS) -- CMVK multi-model code review (mock implementation for demo) -- Audit log sidebar with recent activity -- Policies view showing active policies -- Statistics view with daily/weekly counts -- Status bar with real-time protection indicator -- Team policy sharing via `.vscode/agent-os.json` -- Export audit log to JSON -- Custom rule support - -### Known Limitations -- CMVK uses mock responses (real API coming in v0.2.0) -- Inline completion interception is read-only (doesn't block) -- Limited to text change detection for now