From e3a8ce31e0d39551efafbc10fd5e23a7322ecbf0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 00:45:46 +0000 Subject: [PATCH 1/4] Scaffold Phase A governance MCP and module-skill bridge. Treat openXYOS as the control plane and FreeOS/Octop as the data plane. Rewrite the integration architecture around the seven directions with Phase A/B/C, then add xyos-governance-mcp (default-deny, durable HITL pause/resume, local audit) and a catalog-to-SKILL.md generator with a tenant-toggle publish path. Phase C stays documentation only. Co-authored-by: XYAI Labs --- NOTICE | 5 +- README.md | 32 +- README_CN.md | 2 +- docs/architecture-integration.md | 375 ++++++++++++------ pyproject.toml | 1 + src/octop/api/routers/org_module.py | 117 +++++- src/octop/cli/commands/org.py | 125 +++++- src/octop/cli/registry.py | 6 +- .../agents/plugins/bundled/org-os/main.py | 33 ++ src/octop/infra/utils/paths.py | 20 + src/octop/modules/org_os/__init__.py | 2 +- src/octop/modules/org_os/governance/README.md | 79 ++++ .../modules/org_os/governance/__init__.py | 32 ++ src/octop/modules/org_os/governance/engine.py | 276 +++++++++++++ .../modules/org_os/governance/interceptor.py | 85 ++++ .../modules/org_os/governance/mcp_server.py | 180 +++++++++ src/octop/modules/org_os/governance/policy.py | 90 +++++ src/octop/modules/org_os/governance/store.py | 153 +++++++ src/octop/modules/org_os/governance/types.py | 43 ++ src/octop/modules/org_os/proxy.py | 28 +- src/octop/modules/org_os/service.py | 45 ++- .../modules/org_os/skill_bridge/README.md | 60 +++ .../modules/org_os/skill_bridge/__init__.py | 11 + .../modules/org_os/skill_bridge/endpoints.py | 204 ++++++++++ .../modules/org_os/skill_bridge/generate.py | 266 +++++++++++++ .../modules/org_os/skill_bridge/publish.py | 157 ++++++++ tests/unit/test_governance_mcp.py | 175 ++++++++ tests/unit/test_org_module.py | 20 + tests/unit/test_paths.py | 8 + tests/unit/test_skill_bridge.py | 60 +++ 30 files changed, 2543 insertions(+), 147 deletions(-) create mode 100644 src/octop/modules/org_os/governance/README.md create mode 100644 src/octop/modules/org_os/governance/__init__.py create mode 100644 src/octop/modules/org_os/governance/engine.py create mode 100644 src/octop/modules/org_os/governance/interceptor.py create mode 100644 src/octop/modules/org_os/governance/mcp_server.py create mode 100644 src/octop/modules/org_os/governance/policy.py create mode 100644 src/octop/modules/org_os/governance/store.py create mode 100644 src/octop/modules/org_os/governance/types.py create mode 100644 src/octop/modules/org_os/skill_bridge/README.md create mode 100644 src/octop/modules/org_os/skill_bridge/__init__.py create mode 100644 src/octop/modules/org_os/skill_bridge/endpoints.py create mode 100644 src/octop/modules/org_os/skill_bridge/generate.py create mode 100644 src/octop/modules/org_os/skill_bridge/publish.py create mode 100644 tests/unit/test_governance_mcp.py create mode 100644 tests/unit/test_skill_bridge.py diff --git a/NOTICE b/NOTICE index 461c07b9..dc33e2c1 100644 --- a/NOTICE +++ b/NOTICE @@ -44,7 +44,8 @@ trademarks beyond this accurate attribution. See License split ------------------------------------------------------------------------ - MIT applies to the FreeOS/Octop host platform and FreeOS-authored - bridge code under ``src/octop/modules/org_os/``, dashboard Organization - pages, and FreeOS branding assets. + bridge code under ``src/octop/modules/org_os/`` (including + ``governance/`` / ``xyos-governance-mcp`` and ``skill_bridge/``), + dashboard Organization pages, and FreeOS branding assets. - Apache-2.0 applies to ``modules/openxyos/`` and unmodified copies of that tree. diff --git a/README.md b/README.md index 52f75ad7..0157d204 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ The Vite app in `dashboard/` is what you edit; packaged builds land in `src/octo ### Tests ```bash -uv run pytest tests/unit/test_org_module.py tests/unit/test_paths.py tests/unit/test_bundled_plugins_layout.py +uv run pytest tests/unit/test_org_module.py tests/unit/test_paths.py tests/unit/test_bundled_plugins_layout.py tests/unit/test_governance_mcp.py tests/unit/test_skill_bridge.py ``` Full Octop-derived suite: `uv run pytest` / `make test-fast` (needs the usual extra services for marked tests). @@ -90,7 +90,33 @@ Default origin: `http://127.0.0.1:3780` (`FREEOS_ORG_SIDECAR_URL` / `FREEOS_ORG_ Sidecar env is generated at `modules/openxyos/.env` on first launch (do not commit secrets). See `modules/openxyos/.env.example` and [docs/architecture-integration.md](docs/architecture-integration.md). -**Auth (MVP):** FreeOS JWT users and openXYOS tenants are separate. The proxy forwards `X-FreeOS-User*` headers only; sign in to the sidecar for mutating org APIs. +**Auth (MVP):** FreeOS JWT users and openXYOS tenants are separate. The proxy forwards `X-FreeOS-User*` and `X-FreeOS-Tenant-Id`; sign in to the sidecar for mutating org APIs. One tenant = one workspace/sandbox — not prompt isolation. + +## Phase A — governance MCP + module skills + +openXYOS is the **control plane**. FreeOS/Octop stays the **data plane** (do not replace Octop chat). Phase A inserts governance before high-risk tools and generates skills from the module catalog. + +### Direction 2 — `xyos-governance-mcp` + +```bash +uv run freeos org governance enable +# writes {FREEOS_HOME}/governance/xyos-governance-mcp.json — add that stdio server to the agent +uv run xyos-governance-mcp # optional: run by hand +uv run freeos org governance check --tool delete_employee --category delete +``` + +High-risk classes (`outbound`, `delete`, `pay`, `prod`) **default-deny**. Human approval is a durable pause under `~/.freeos/governance/pauses/`. `execute` stays false until `freeos org governance approve ` (or IM `/approve` once wired) and the agent **re-checks** with the same args. How-to: [src/octop/modules/org_os/governance/README.md](src/octop/modules/org_os/governance/README.md). + +### Direction 3 — module ↔ skill bridge + +```bash +uv run freeos org skills generate +uv run freeos org skills publish ~/.freeos/org-skills/org-employees +``` + +Each generated `SKILL.md` calls real `/api/…` routes through `/api/org-module/sidecar` with tenant headers. Publish writes a **disabled** tenant-toggleable plugin draft — it does not auto-enable. How-to: [src/octop/modules/org_os/skill_bridge/README.md](src/octop/modules/org_os/skill_bridge/README.md). + +Phase B (blueprint compiler, digital-colleague lifecycle) and Phase C (chat routing, reflections, org-as-code) are specified in [docs/architecture-integration.md](docs/architecture-integration.md) only. ## Host platform features (from Octop) @@ -116,4 +142,4 @@ Intentional for MVP compatibility (not a rebrand miss): - SQLite file `octop.db` inside the home directory - Many internal tests and IM strings -See [docs/architecture-integration.md](docs/architecture-integration.md) for the capability map, rejected merge options, and roadmap. +See [docs/architecture-integration.md](docs/architecture-integration.md) for the seven integration directions, Phase A/B/C, and the chosen MVP topology. diff --git a/README_CN.md b/README_CN.md index ab717516..3a07514b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -6,7 +6,7 @@ FreeOS — 自托管多用户、多 Agent 助手,可选组织 OS 模块。

-

运行与组织模块说明见 README.md;架构见 docs/architecture-integration.md。

+

运行、组织模块、治理 MCP 与模块技能桥见 README.md;七条集成方向与 Phase A/B/C 见 docs/architecture-integration.md。

Python 3.12+ diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index 3b805f57..e3b15c5b 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -1,163 +1,292 @@ -# FreeOS architecture: Octop host + openXYOS organization module +# FreeOS × openXYOS integration architecture -FreeOS is **Octop** (MIT, Python/FastAPI + React dashboard) with **openXYOS** -(Apache-2.0, Express + React) mounted as an **enable/disable organization -module**. This document is the capability map, the chosen topology, rejected -alternatives, auth/tenant mapping, data ownership, and the phased roadmap. +FreeOS is the **Octop-derived data plane** (MIT: skills, workspace FS, memory, +cron, multi-agent, MCP, sandboxes, real IM channels) plus a **control-plane +contract** from openXYOS (Apache-2.0: org hierarchy, multi-tenant modules, +`openxyos.agent-blueprint.v1`, governance, module catalog). + +They are complementary, not competitors. FreeOS does **not** replace Octop’s +agent runtime with openXYOS chat. openXYOS is the organization control plane; +FreeOS/Octop remains the execution runtime. FreeOS is an independent project. It is not affiliated with Tencent Cloud, Octop, XYAIStudio, or XYOS trademarks beyond accurate license attribution. -See [NOTICE](../NOTICE). +See [NOTICE](../NOTICE) and `modules/openxyos/TRADEMARKS.md`. -## 1. Capability map +## Framing -| Capability | Owner in MVP | Path / API | Notes | -|---|---|---|---| -| Multi-user JWT, agents, chat, plugins, cron, connectors | FreeOS host (Octop) | `src/octop/`, `/api/*`, dashboard `/chat` | Unchanged control plane | -| Plugin seed + enable | `PluginManager` | `src/octop/infra/agents/plugins/`; bundled under `src/octop/infra/agents/plugins/bundled/`; runtime `~/.freeos/plugins/*` (or `~/.octop/plugins/*`) | Seeds bundled plugins disabled, then loads user plugins | -| Organization OS catalog | Bridge (Python) + upstream TS | `src/octop/modules/org_os/catalog.py` mirrors `modules/openxyos/backend/open-module-catalog.ts` and `frontend/src/open-modules.ts` | Keys: workspace, announcements, organization, employees, skills, chat, agents, tasks, knowledge, reflections, governance, settings | -| Enable / disable org module | FreeOS BFF + plugin `org-os` | `PATCH /api/org-module` · `freeos org enable` · dashboard `/organization` · Admin → Plugins | Writes `config.json` `modules.org_os.enabled` and `plugins.org-os.enabled` | -| Org APIs (employees, org tree, tenants, governance, …) | openXYOS sidecar | `modules/openxyos/backend/routes/*.ts` mounted at `/api/org`, `/api/employees`, `/api/tenants`, `/api/governance`, `/api/module-settings`, … | Reachable via sidecar origin or ` /api/org-module/sidecar/{path}` when enabled | -| Org UI | openXYOS Vite/Express app | sidecar `:3780` (default); iframe on `/organization` when healthy | Host shell is FreeOS; org console is the sidecar | -| Branding | FreeOS | `dashboard/public/logo*.png`, `favico.svg`, `pwa-*.png`, `docs/assets/readme-banner.png` | Circular mark: gray ring, yellow/green/red teardrops, blue center | +| Plane | System | Owns | +|---|---|---| +| **Control plane** | openXYOS sidecar (`modules/openxyos`) | Org tree, tenants, module catalog, blueprints `openxyos.agent-blueprint.v1`, governance/audit, talent market, module contract | +| **Data plane** | FreeOS host (Octop) | Agent runtime, SKILL.md, workspace FS, harness-memory, cron, multi-agent, MCP connectors, sandboxes, Feishu/DingTalk/Discord/… | -### Upstream verification (live trees, not hypotheses) +``` + Control plane (openXYOS) Data plane (FreeOS / Octop) + ──────────────────────── ─────────────────────────── + tenants / org tree agent workspace + sandbox + module catalog + toggles SKILL.md + plugins + agent-blueprint.v1 SOUL.md / MEMORY / cron + GovernanceEngine + audit MCP + HITL IM + tool_guard + talent market running agent instances +``` -Octop `PluginManager` (`src/octop/infra/agents/plugins/manager.py`): +## Chosen MVP topology (Phase A — Directions 2 + 3) -1. `seed_bundled()` copies `src/octop/infra/agents/plugins/bundled//` into the install `plugins/` directory with `enabled: false`. -2. `load_installed()` loads `~/.freeos/plugins/*` (legacy `~/.octop/plugins/*`). -3. Dashboard admin patches `PATCH /api/plugins/{id}` `{ "enabled": true }`. +Phase A does **not** compile blueprints or merge chat UIs. It inserts +governance in front of high-risk data-plane tools, and generates real +module skills that call sidecar `/api/*` with tenant headers. -openXYOS module keys are defined twice and must stay aligned: +``` + ┌─────────────────────────────────────────────┐ + Browser / IM │ FreeOS dashboard + Octop IM channels │ + │ /organization · /approve · /pending │ + └───────────────┬─────────────────────────────┘ + │ JWT / IM session + ┌───────────────▼─────────────────────────────┐ + Host │ FastAPI src/octop/api/app.py │ + (data plane) │ /api/* Octop routers │ + │ /api/org-module/status|catalog|PATCH │ + │ /api/org-module/governance/* │ + │ /api/org-module/skills/* │ + │ /api/org-module/sidecar/* (BFF proxy) │ + │ PluginManager + bundled org-os │ + │ xyos-governance-mcp (stdio MCP) │ + └───────┬───────────────────┬─────────────────┘ + │ HTTP │ durable pause + │ X-FreeOS-User* │ ~/.freeos/governance/ + │ X-FreeOS-Tenant-Id│ + ┌───────▼─────────┐ ┌─────▼─────────────────┐ + Sidecar │ openXYOS :3780 │ │ Pause + audit JSONL │ + (control plane)│ /api/governance │ │ Human must approve │ + │ /api/module-… │ │ before execute=true │ + │ own SQL.js/PG │ └───────────────────────┘ + └─────────────────┘ +``` -- Backend catalog: `modules/openxyos/backend/open-module-catalog.ts` (`OPENXYOS_MODULES`) -- Frontend store: `modules/openxyos/frontend/src/open-modules.ts` (`OPEN_MODULE_KEYS`) -- Runtime toggle API: `GET/PUT /api/module-settings` (`open-module-settings` routes) +**Why this topology for 2 + 3** -Dashboard source lives in `dashboard/` and is built into `src/octop/dashboard/` for the packaged server. +- Governance as MCP matches Octop’s connector model (`overlay_stdio_spec_env`) + without rewriting `GovernanceEngine` into FastAPI. +- Default-deny lives in-process so a down sidecar cannot fail *open*. +- Durable pauses live on the FreeOS home disk so a process restart cannot + silently resume a high-risk tool. +- Generated skills call the existing BFF (`/api/org-module/sidecar/…`) so + tenant headers and enablement checks stay in one place. +- Reverse publish writes a plugin draft + tenant-toggle payload; it does + not auto-install into production tenants. -## 2. Chosen topology (MVP) +### Sequence: high-risk tool (Direction 2) ``` - ┌─────────────────────────────────────────┐ - Browser │ FreeOS dashboard (React, dashboard/) │ - │ shell, chat, plugins, /organization │ - └───────────────┬─────────────────────────┘ - │ JWT cookie / Bearer - ┌───────────────▼─────────────────────────┐ - Host process │ FastAPI src/octop/api/app.py │ - │ /api/* Octop routers │ - │ /api/org-module/status|catalog|PATCH │ - │ /api/org-module/sidecar/* (BFF proxy) │ - │ PluginManager + bundled org-os │ - └───────────────┬─────────────────────────┘ - │ HTTP, X-FreeOS-User* - ┌───────────────▼─────────────────────────┐ - Sidecar │ openXYOS modules/openxyos │ - (optional) │ Express :3780 + React org console │ - │ own SQLite/Postgres + tenants │ - └─────────────────────────────────────────┘ +Agent → tool (outbound / delete / pay / prod) + → GovernanceInterceptor.enforce() / MCP check_policy + → classify → high-risk + → optional POST {sidecar}/api/governance/validate + → no matching allow rule → DEFAULT DENY + → write audit.jsonl (and sidecar log when reachable) + → create durable pause ~/.freeos/governance/pauses/.json + → return execute=false (caller MUST stop) +Human → FreeOS/Octop IM /approve or `freeos org governance approve ` + → pause status=approved +Agent → check_policy(approval_id=…) + → execute=true only for the same tool + args digest + → tool runs ``` -**Why this topology** +If the interceptor/MCP result is `deny` or `pending`, execution is a hard +stop. Prompt-only “please get approval” is not governance. -- Octop is Python; openXYOS is TypeScript. A full language merge would rewrite - org routes, PEP/PDP, and the module catalog into FastAPI — high risk, no MVP. -- Octop already has a plugin enable/disable surface. Treating org OS as a - bundled plugin plus a first-class dashboard page matches that model. -- A BFF proxy keeps one browser origin for API calls later (SSO, CSRF) without - forcing openXYOS to import Python packages. - -### Sequence: enable the module +### Sequence: module → skill (Direction 3) ``` -User → GET /organization - → GET /api/org-module/status -Host → read ~/.freeos/config.json modules.org_os - → GET {sidecar}/api/health/livez -User → Switch on - → PATCH /api/org-module { "enabled": true } -Host → write modules.org_os.enabled + plugins.org-os.enabled - → optional PluginManager.set_enabled("org-os", true) -User → bash scripts/run-org-sidecar.sh -Host → iframe embed_url when livez succeeds -Agent → tool org_os_status (bundled plugin) +Operator → freeos org skills generate [--module employees] +Host → OPENXYOS_MODULES + endpoint map + → write skills/org-/SKILL.md + → write skills/org-/scripts/call_module.py +Skill → curl/python /api/org-module/sidecar/api/ + → headers X-FreeOS-Tenant-Id / X-FreeOS-User* + → high-risk verbs go through governance first +Polished → freeos org skills publish

+ → plugin.yaml draft + sidecar tenant-toggle payload + → operator enables per tenant; not auto-on ``` -### Sequence: call an org API through the BFF +## Seven directions -``` -User (JWT) → GET /api/org-module/sidecar/api/org -Host → 409 if disabled - → 503 if livez fails - → strip Authorization, add X-FreeOS-User / X-FreeOS-User-Id / X-FreeOS-Role - → GET http://127.0.0.1:3780/api/org -Sidecar → own cookie/JWT still required for mutating org routes -``` +### Phase A — implement now (highest value / verifiable) -## 3. Rejected alternatives +#### Direction 2 — `xyos-governance-mcp` -| Alternative | Why rejected for MVP | +Wrap openXYOS governance/audit as an MCP server and insert it **before** +high-risk tool calls. + +| Piece | Path | |---|---| -| Rewrite openXYOS routes into FastAPI / SQLAlchemy | Two full backends, duplicate PEP/PDP, months of work, easy to break tenants | -| iframe-only with no BFF or plugin | No enable/disable in the host; agents cannot see org capabilities | -| npm-import openXYOS React into the Octop dashboard | Conflicting routers, Vite configs, auth stores; would still need the Express API | -| Git submodule only, no vendored tree | Clone would not be self-contained; CI/offline installs break | -| Rename every `octop` package/import to `freeos` | Touches hundreds of files and breaks `orcakit-harness-agent` assumptions; documented as leftover identifiers instead | +| Engine (default deny, classify, sidecar validate) | `src/octop/modules/org_os/governance/` | +| Durable pause / resume + JSONL audit | `{FREEOS_HOME}/governance/` | +| stdio MCP | `xyos-governance-mcp` → `mcp_server.py` | +| HTTP BFF | `POST /api/org-module/governance/check` | +| Enable | `freeos org governance enable` | +| How-to | `src/octop/modules/org_os/governance/README.md` | -## 4. Auth and tenant mapping +High-risk classes: **outbound**, **delete**, **pay**, **prod**. Unmatched +rules **deny**. Human approval is a durable pause; `execute` stays false +until the pause is approved for the same action digest. -| Layer | Identity | Store | -|---|---|---| -| FreeOS host | Octop `User` (id, username, role, permissions) | `~/.freeos/octop.db` (legacy filename) + JWT | -| openXYOS sidecar | Sidecar users + `tenant_id` | `modules/openxyos` SQLite/Postgres (`DB_DIALECT`) | +#### Direction 3 — Module ↔ Skill bidirectional bridge + +| Direction | Behavior | +|---|---| +| Module → skill | Generate FreeOS/Octop `SKILL.md` from the openXYOS catalog; each skill calls real `/api/…` routes via the BFF with tenant headers | +| Skill → module | Publish a polished skill as a tenant-toggleable plugin draft (`plugin.yaml` + sidecar payload). Never auto-enable | + +| Piece | Path | +|---|---| +| Generator / publisher | `src/octop/modules/org_os/skill_bridge/` | +| Catalog source | `src/octop/modules/org_os/catalog.py` (mirrors `modules/openxyos/backend/open-module-catalog.ts`) | +| CLI | `freeos org skills generate` · `freeos org skills publish` | +| How-to | `src/octop/modules/org_os/skill_bridge/README.md` | + +### Phase B — next (scaffold later; specified here) + +#### Direction 1 — `xyos2octop` / blueprint compiler + +Compile `openxyos.agent-blueprint.v1` (see +`modules/openxyos/backend/routes/agent-studio.ts`) into a runnable FreeOS +agent workspace: + +- `SOUL.md`, `skills/`, `knowledge/`, MEMORY seed, `.env` +- cron entries from job duties +- reverse telemetry into openXYOS HR / capability profile + +Do **not** start a second chat runtime. The compiler emits files the +existing Octop agent already knows how to run. + +#### Direction 4 — Digital-colleague lifecycle -**MVP contract** +Wire the openXYOS talent market to FreeOS agent instances: -- FreeOS JWT is **not** accepted as an openXYOS session. The proxy strips - `Authorization` so a host token cannot be confused with a sidecar token. -- The proxy **does** forward `X-FreeOS-User`, `X-FreeOS-User-Id`, - `X-FreeOS-Role` for later SSO / JIT provisioning. -- Operators sign in to the sidecar separately (openXYOS `/api/auth`). -- Phase 2 should map `FreeOS user.id → openXYOS tenant member` with a signed - handshake (one-time code or shared OIDC). +`recruit → onboard → shadow/probation → promote → offboard` -Default sidecar origin: `http://127.0.0.1:3780` -Override: `FREEOS_ORG_SIDECAR_URL` or `config.json` `modules.org_os.sidecar_url`. +Each stage is a control-plane record plus a data-plane instance +(workspace + sandbox). Offboard must revoke tools and destroy or archive +the sandbox — not only flip a flag. -## 5. Data ownership +### Phase C — architecture only (do not implement in this pass) -| Data | Location | License / notes | +#### Direction 5 — Chat / session routing Web ↔ IM + +One conversation identity across FreeOS dashboard chat and Octop IM +channels. Route; do not duplicate openXYOS chat as the agent loop. + +#### Direction 6 — Reflections ↔ harness-memory + +openXYOS reflections become an org learning loop that writes into +harness-memory (and back). Keep tenant isolation at the workspace +boundary, not in the prompt. + +#### Direction 7 — Org-as-code GitOps (end-state, not MVP start) + +Reconcile control-plane YAML (modules, blueprints, policy) with running +agents. Desired end-state: git is the source of org intent; FreeOS +instances converge. Not the starting point — the contract and runtime +must exist first (Phases A/B). + +## Risks (encoded in design) + +1. **openXYOS is a community-candidate; SQL.js is the default.** Use it as + the control-plane / contract layer, not the production multi-tenant + database of record. Durable FreeOS state (pauses, audit, workspaces) + lives under `FREEOS_HOME`. Sidecar data is optional and separately + backed up. +2. **Octop is not natively multi-tenant.** Isolate as **one tenant = one + workspace / sandbox instance**. Never rely on prompt-only isolation. + Generated skills always send `X-FreeOS-Tenant-Id`; the BFF must not + collapse tenants into one agent home. +3. **Licenses and trademarks.** Preserve Apache-2.0 (vendored tree) and + MIT (host + bridge). Follow `modules/openxyos/TRADEMARKS.md`: FreeOS + uses distinct branding (circular mark already in the dashboard/README). + No XYOS / Tencent Cloud trademark claims. +4. **Human approval must truly block execution.** A `pending` or `deny` + decision sets `execute=false` and the interceptor raises + `GovernanceBlockedError`. Cosmetic “please confirm” text in a skill + is not sufficient. Restarts must not resume paused high-risk calls. + +## Host bootstrap (unchanged) + +The Phase A work sits on the existing FreeOS bootstrap: + +- Octop history as the host (`src/octop/`, `dashboard/`) +- openXYOS vendored at `modules/openxyos/` (subtree `@ 1bdba35f`) +- Bundled plugin `org-os`, BFF `/api/org-module`, CLI `freeos org` +- PathLayout: `FREEOS_HOME` > `OCTOP_HOME` > existing `~/.freeos` > + existing `~/.octop` > new `~/.freeos` +- Package name stays `octop`; CLI alias `freeos` + +### Capability map (host + sidecar) + +| Capability | Owner | Path / API | +|---|---|---| +| Multi-user JWT, agents, chat, plugins, cron, connectors | FreeOS host | `src/octop/`, `/api/*` | +| Plugin seed + enable | `PluginManager` | bundled `src/octop/infra/agents/plugins/bundled/`; runtime `{home}/plugins/*` | +| Organization OS catalog | Bridge + upstream TS | `catalog.py` ↔ `open-module-catalog.ts` / `open-modules.ts` | +| Enable / disable org module | BFF + plugin `org-os` | `PATCH /api/org-module` · `freeos org enable` · `/organization` | +| Governance MCP (Phase A) | Bridge | `xyos-governance-mcp` · `/api/org-module/governance/*` | +| Module ↔ skill bridge (Phase A) | Bridge | `freeos org skills generate|publish` | +| Org APIs | openXYOS sidecar | `/api/org`, `/api/employees`, `/api/governance`, `/api/module-settings`, … | +| Org UI | openXYOS Vite/Express | sidecar `:3780`; iframe on `/organization` when healthy | + +### Auth and tenant mapping + +| Layer | Identity | Store | |---|---|---| -| Host config, users, agents, chats, plugins | `FREEOS_HOME` else `OCTOP_HOME` else existing `~/.freeos` or `~/.octop`, else new `~/.freeos` | MIT host platform | -| Bundled plugin copies | `{home}/plugins/org-os/` | MIT bridge; seeded disabled | -| Org tenants, employees, governance, module settings | sidecar working dir / openXYOS DB | Apache-2.0 tree under `modules/openxyos/` | -| Brand assets | `dashboard/public/`, `docs/assets/freeos-banner.png` | FreeOS | +| FreeOS host | Octop `User` | `{home}/octop.db` + JWT | +| openXYOS sidecar | Sidecar users + `tenant_id` | sidecar SQL.js / Postgres (`DB_DIALECT`) | -The two databases are **not** merged in MVP. Backups of FreeOS (`octop backup` / -`freeos backup`) do not include sidecar data unless operators back up the -openXYOS directory themselves. +- FreeOS JWT is **not** an openXYOS session. The proxy strips + `Authorization` and forwards `X-FreeOS-User`, `X-FreeOS-User-Id`, + `X-FreeOS-Role`, `X-FreeOS-Tenant-Id`. +- Operators still sign in to the sidecar for mutating org routes. +- Tenant isolation for agents is **workspace/sandbox per tenant**, not + a shared workspace with a header in the prompt. -## 6. Remaining `octop` identifiers (intentional) +Default sidecar: `http://127.0.0.1:3780` +Override: `FREEOS_ORG_SIDECAR_URL` or `config.json` `modules.org_os.sidecar_url`. +Tenant hint: `FREEOS_ORG_TENANT_ID` or `modules.org_os.tenant_id`. -A full rename is larger than this MVP. These stay for compatibility: +### Data ownership -- Python package and imports: `octop.*` (`pyproject.toml` `name = "octop"`) -- CLI: `octop` still works; `freeos` is an alias to the same entry point -- Env: `OCTOP_HOME`, `OCTOP_USER`, `OCTOP_DATABASE_*` still honored; prefer `FREEOS_HOME` / `FREEOS_ORG_SIDECAR_URL` -- SQLite filename: `octop.db` inside the home directory -- Many tests, IM channels, and desktop strings still say “Octop” +| Data | Location | +|---|---| +| Host users, agents, chats, plugins | `FREEOS_HOME` / legacy `OCTOP_HOME` | +| Governance pauses + audit | `{home}/governance/pauses/`, `{home}/governance/audit.jsonl` | +| Generated org skills | `{home}/org-skills/` (or `--out`) | +| Org tenants, employees, matrix | sidecar DB — **not** the production SoT | +| Brand assets | `dashboard/public/`, `docs/assets/` | + +`freeos backup` does not include sidecar SQL.js files unless operators +copy the openXYOS working directory themselves. + +## Remaining `octop` identifiers (intentional) -## 7. Phased roadmap +- Python package / imports: `octop.*` (`pyproject.toml` `name = "octop"`) +- CLI: `octop` still works; `freeos` is the same entry point +- Env: `OCTOP_*` still honored; prefer `FREEOS_*` +- SQLite filename: `octop.db` +- Many tests and IM strings still say “Octop” -1. **MVP (this branch)** — import Octop history; vendor openXYOS; FreeOS branding in the UI shell and README; org module toggle + catalog + sidecar proxy/plugin. -2. **SSO** — exchange FreeOS JWT for an openXYOS session; map one host user to one tenant member. -3. **Unified nav** — surface individual openXYOS modules (organization, employees, governance) as FreeOS sidebar items gated by sidecar module-settings. -4. **Shared agents** — register sidecar AI employees as Octop experts/tools instead of a second agent runtime. -5. **Optional deep merge** — only after APIs stabilize; port selected org routes into FastAPI if a single process is required. +## Rejected alternatives + +| Alternative | Why rejected | +|---|---| +| Replace Octop chat with openXYOS chat | Violates the control/data-plane split; loses IM, cron, sandboxes | +| Rewrite openXYOS routes into FastAPI | Duplicate PEP/PDP; not MVP | +| Prompt-only multi-tenant isolation | Risk 2; one tenant = one workspace/sandbox | +| iframe-only, no BFF / MCP / skills | Agents cannot call org APIs or be governed | +| Start with org-as-code GitOps (Direction 7) | End-state; contract + runtime first | +| Fail-open when sidecar/SQL.js is down | Makes governance cosmetic | -## 8. Concrete file index +## Concrete file index | Role | Path | |---|---| @@ -165,9 +294,11 @@ A full rename is larger than this MVP. These stay for compatibility: | BFF router | `src/octop/api/routers/org_module.py` | | Enablement + health | `src/octop/modules/org_os/service.py` | | Catalog | `src/octop/modules/org_os/catalog.py` | -| Proxy | `src/octop/modules/org_os/proxy.py` | +| Proxy + tenant headers | `src/octop/modules/org_os/proxy.py` | +| Governance MCP / interceptor | `src/octop/modules/org_os/governance/` | +| Module ↔ skill bridge | `src/octop/modules/org_os/skill_bridge/` | | Bundled plugin | `src/octop/infra/agents/plugins/bundled/org-os/` | | Dashboard page | `dashboard/src/pages/Organization/index.tsx` | -| CLI | `src/octop/cli/commands/org.py` (`freeos org`) | +| CLI | `src/octop/cli/commands/org.py` | | Sidecar launcher | `scripts/run-org-sidecar.sh` | -| Vendored org OS | `modules/openxyos/` (subtree from XYAIStudio/openXYOS @ `1bdba35f`) | +| Vendored org OS | `modules/openxyos/` | diff --git a/pyproject.toml b/pyproject.toml index 925d001b..3d74878d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ dev = [ [project.scripts] octop = "octop.cli.main:cli" freeos = "octop.cli.main:cli" +xyos-governance-mcp = "octop.modules.org_os.governance.mcp_server:main" [tool.hatch.build.targets.wheel] packages = ["src/octop"] diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index 02326b71..cbafce64 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -90,7 +90,122 @@ async def org_module_proxy( request, base_url=service.sidecar_url(), path=path, - extra_headers=identity_headers(user), + extra_headers=identity_headers(user, tenant_id=service.tenant_id()), ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + + +class GovernanceCheckBody(BaseModel): + tool_name: str + category: str = "" + action: str = "" + actor_id: str = "" + tenant_id: str = "" + approval_id: str = "" + args: dict[str, Any] = Field(default_factory=dict) + auto_pause: bool = True + + +class GovernanceResolveBody(BaseModel): + pause_id: str + approve: bool = True + + +class SkillsGenerateBody(BaseModel): + modules: list[str] | None = None + out_dir: str | None = None + + +class SkillsPublishBody(BaseModel): + skill_dir: str + out_dir: str | None = None + + +@router.post("/governance/check", summary="Policy-check a tool (default-deny high-risk)") +async def governance_check( + body: GovernanceCheckBody, + server: OctopServer = Depends(get_server), + user: Any = Depends(current_user), +) -> dict[str, Any]: + from octop.modules.org_os.governance.engine import GovernanceEngine + from octop.modules.org_os.governance.types import PolicyRequest + + service = _service(server) + engine = GovernanceEngine.from_home(service.home, sidecar_url=service.sidecar_url()) + actor = body.actor_id or str(getattr(user, "id", "") or getattr(user, "username", "") or "") + tenant = body.tenant_id or service.tenant_id() + decision = engine.evaluate( + PolicyRequest( + tool_name=body.tool_name, + category=body.category, + action=body.action, + actor_id=actor, + tenant_id=tenant, + args=body.args, + approval_id=body.approval_id, + auto_pause=body.auto_pause, + ) + ) + return decision.to_dict() + + +@router.post("/governance/resolve", summary="Approve or reject a durable governance pause") +async def governance_resolve( + body: GovernanceResolveBody, + server: OctopServer = Depends(get_server), + _user: Any = Depends(require_permission("plugins")), +) -> dict[str, Any]: + from octop.modules.org_os.governance.engine import GovernanceEngine + + service = _service(server) + engine = GovernanceEngine.from_home(service.home, sidecar_url=service.sidecar_url()) + decision = engine.approve(body.pause_id) if body.approve else engine.reject(body.pause_id) + return decision.to_dict() + + +@router.get("/governance/audit", summary="Tail local governance audit JSONL") +async def governance_audit( + limit: int = 50, + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + from octop.modules.org_os.governance.engine import GovernanceEngine + + service = _service(server) + engine = GovernanceEngine.from_home(service.home, sidecar_url=service.sidecar_url()) + return {"events": engine.store.tail_audit(limit)} + + +@router.post("/skills/generate", summary="Generate FreeOS skills from the openXYOS catalog") +async def skills_generate( + body: SkillsGenerateBody, + server: OctopServer = Depends(get_server), + _user: Any = Depends(require_permission("plugins")), +) -> dict[str, Any]: + from pathlib import Path + + from octop.modules.org_os.skill_bridge.generate import generate_module_skills + + service = _service(server) + out = Path(body.out_dir) if body.out_dir else service.home / "org-skills" + generated = generate_module_skills(out, module_keys=body.modules) + return {"out_dir": str(out), "skills": [item.to_dict() for item in generated]} + + +@router.post("/skills/publish", summary="Publish a skill as a tenant-toggleable plugin draft") +async def skills_publish( + body: SkillsPublishBody, + server: OctopServer = Depends(get_server), + _user: Any = Depends(require_permission("plugins")), +) -> dict[str, Any]: + from pathlib import Path + + from octop.modules.org_os.skill_bridge.publish import publish_skill + + out = Path(body.out_dir) if body.out_dir else None + try: + draft = publish_skill(Path(body.skill_dir), out_dir=out) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return draft.to_dict() diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index ee5fc4d4..928927c4 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -1,8 +1,9 @@ -"""``freeos org`` / ``octop org`` — organization module controls.""" +"""``freeos org`` / ``octop org`` — organization module, governance, skills.""" from __future__ import annotations import json +from pathlib import Path import click @@ -12,7 +13,7 @@ @click.group() def org() -> None: - """Enable, disable, or inspect the openXYOS organization module.""" + """Enable, inspect, or bridge the openXYOS organization module.""" def _service() -> OrgModuleService: @@ -21,6 +22,13 @@ def _service() -> OrgModuleService: return OrgModuleService(config_path=paths.config, home=paths.root) +def _engine(): + from octop.modules.org_os.governance.engine import GovernanceEngine + + service = _service() + return GovernanceEngine.from_home(service.home, sidecar_url=service.sidecar_url()) + + @org.command("status") @click.pass_context def org_status(ctx: click.Context) -> None: @@ -31,6 +39,8 @@ def org_status(ctx: click.Context) -> None: return click.echo(f"enabled: {status.enabled}") click.echo(f"home: {status.home}") + click.echo(f"tenant_id: {status.tenant_id or '(unset)'}") + click.echo(f"governance: {status.governance_enabled}") click.echo(f"sidecar: {status.sidecar.url} ({status.sidecar.detail or 'unknown'})") click.echo(f"catalog: {', '.join(status.catalog_keys)}") click.echo(f"start: {status.start_command}") @@ -50,3 +60,114 @@ def org_disable() -> None: """Turn off the organization module.""" _service().set_enabled(False) click.echo("Organization module disabled.") + + +@org.group("governance") +def org_governance() -> None: + """xyos-governance-mcp — default-deny high-risk tools, durable HITL.""" + + +@org_governance.command("enable") +@click.option("--tenant-id", default="", help="Hint written to config and MCP env.") +def governance_enable(tenant_id: str) -> None: + """Enable the governance gate and write the stdio MCP connector snippet.""" + from octop.modules.org_os.governance.mcp_server import write_connector_snippet + + service = _service() + service.set_governance_enabled(True, tenant_id=tenant_id or None) + snippet = write_connector_snippet() + click.echo("Governance gate enabled (default-deny high-risk tools).") + click.echo(f"MCP snippet: {snippet}") + click.echo("Run: uv run xyos-governance-mcp") + click.echo("See src/octop/modules/org_os/governance/README.md") + + +@org_governance.command("disable") +def governance_disable() -> None: + """Disable the in-process interceptor (MCP server can still be run manually).""" + _service().set_governance_enabled(False) + click.echo("Governance interceptor disabled.") + + +@org_governance.command("check") +@click.option("--tool", "tool_name", required=True) +@click.option("--category", default="") +@click.option("--action", default="") +@click.option("--approval-id", default="") +@click.option("--auto-pause/--no-auto-pause", default=True) +def governance_check( + tool_name: str, category: str, action: str, approval_id: str, auto_pause: bool +) -> None: + """Policy-check a tool. High-risk unmatched rules default-deny.""" + from octop.modules.org_os.governance.types import PolicyRequest + + service = _service() + decision = _engine().evaluate( + PolicyRequest( + tool_name=tool_name, + category=category, + action=action, + tenant_id=service.tenant_id(), + approval_id=approval_id, + auto_pause=auto_pause, + ) + ) + click.echo(json.dumps(decision.to_dict(), ensure_ascii=False, indent=2)) + if not decision.execute: + raise SystemExit(3) + + +@org_governance.command("approve") +@click.argument("pause_id") +def governance_approve(pause_id: str) -> None: + """Approve a durable pause. The agent must re-check before executing.""" + click.echo(json.dumps(_engine().approve(pause_id).to_dict(), indent=2)) + + +@org_governance.command("reject") +@click.argument("pause_id") +def governance_reject(pause_id: str) -> None: + """Reject a durable pause. execute stays false.""" + click.echo(json.dumps(_engine().reject(pause_id).to_dict(), indent=2)) + + +@org_governance.command("audit") +@click.option("--limit", default=20, type=int) +def governance_audit(limit: int) -> None: + """Print recent local audit events (not sidecar SQL.js).""" + click.echo(json.dumps(_engine().store.tail_audit(limit), indent=2, default=str)) + + +@org.group("skills") +def org_skills() -> None: + """Module ↔ skill bridge — generate from catalog, publish as tenant plugin.""" + + +@org_skills.command("generate") +@click.option("--module", "modules", multiple=True, help="Catalog key (repeatable).") +@click.option("--out", "out_dir", type=click.Path(path_type=Path), default=None) +def skills_generate(modules: tuple[str, ...], out_dir: Path | None) -> None: + """Write SKILL.md trees that call real /api routes with tenant headers.""" + from octop.modules.org_os.skill_bridge.generate import generate_module_skills + + service = _service() + dest = out_dir or (service.home / "org-skills") + keys = list(modules) if modules else None + generated = generate_module_skills(dest, module_keys=keys) + click.echo(f"wrote {len(generated)} skill(s) under {dest}") + for item in generated: + click.echo(f" {item.slug} → {item.skill_md}") + click.echo("See src/octop/modules/org_os/skill_bridge/README.md") + + +@org_skills.command("publish") +@click.argument("skill_dir", type=click.Path(path_type=Path, exists=True)) +@click.option("--out", "out_dir", type=click.Path(path_type=Path), default=None) +def skills_publish(skill_dir: Path, out_dir: Path | None) -> None: + """Publish a polished skill as a tenant-toggleable plugin draft (not auto-on).""" + from octop.modules.org_os.skill_bridge.publish import publish_skill + + if skill_dir.is_file() and skill_dir.name == "SKILL.md": + skill_dir = skill_dir.parent + draft = publish_skill(skill_dir, out_dir=out_dir) + click.echo(json.dumps(draft.to_dict(), indent=2)) diff --git a/src/octop/cli/registry.py b/src/octop/cli/registry.py index 85bd86f6..c2bda4b3 100644 --- a/src/octop/cli/registry.py +++ b/src/octop/cli/registry.py @@ -28,5 +28,9 @@ "backup": (".commands.backup", "backup", "Export and restore Octop backups."), "acp": (".commands.acp", "acp_cmd", "Run Octop agent as ACP server (stdio)."), "plugin": (".commands.plugin", "plugin", "Install and manage plugins."), - "org": (".commands.org", "org", "Enable or inspect the organization OS module."), + "org": ( + ".commands.org", + "org", + "Organization module, governance MCP, and module↔skill bridge.", + ), } diff --git a/src/octop/infra/agents/plugins/bundled/org-os/main.py b/src/octop/infra/agents/plugins/bundled/org-os/main.py index 786dec97..40b3f140 100644 --- a/src/octop/infra/agents/plugins/bundled/org-os/main.py +++ b/src/octop/infra/agents/plugins/bundled/org-os/main.py @@ -48,6 +48,31 @@ def org_os_status() -> str: ) +def org_os_governance_check(tool_name: str, category: str = "", approval_id: str = "") -> str: + """Hard-stop policy check. Do not run the named tool unless execute is true.""" + from octop.modules.org_os.governance.types import PolicyRequest + + paths = PathLayout.from_env() + service = OrgModuleService(config_path=paths.config, home=paths.root) + from octop.modules.org_os.governance.engine import GovernanceEngine + + decision = GovernanceEngine.from_home(paths.root, sidecar_url=service.sidecar_url()).evaluate( + PolicyRequest( + tool_name=tool_name, + category=category, + tenant_id=service.tenant_id(), + approval_id=approval_id, + auto_pause=True, + ) + ) + payload = decision.to_dict() + payload["instruction"] = ( + "If execute is false, stop. Do not call the tool. Ask a human to " + "approve the pause_id via FreeOS/Octop IM or `freeos org governance approve`." + ) + return _payload(payload, payload["instruction"] + " " + decision.reason) + + def setup(ctx: PluginContext) -> None: ctx.tool( "org_os_status", @@ -57,3 +82,11 @@ def setup(ctx: PluginContext) -> None: "catalog (organization, employees, governance, …)." ), ) + ctx.tool( + "org_os_governance_check", + org_os_governance_check, + description=( + "Insert before high-risk org/tool calls (outbound/delete/pay/prod). " + "Default-denies unmatched rules; execute stays false until human approval." + ), + ) diff --git a/src/octop/infra/utils/paths.py b/src/octop/infra/utils/paths.py index 74fc4a84..a7946f8a 100644 --- a/src/octop/infra/utils/paths.py +++ b/src/octop/infra/utils/paths.py @@ -123,6 +123,26 @@ def tool_guard_rules_dir(self) -> Path: def tool_guard_rules_file(self) -> Path: return self.tool_guard_rules_dir / "dangerous_shell_commands.yaml" + @property + def governance_dir(self) -> Path: + """Durable governance pauses + audit: ``~/.freeos/governance/``.""" + return self.root / "governance" + + def ensure_governance_dir(self) -> Path: + out = self.governance_dir + out.mkdir(parents=True, exist_ok=True) + return out + + @property + def org_skills_dir(self) -> Path: + """Generated openXYOS module skills: ``~/.freeos/org-skills/``.""" + return self.root / "org-skills" + + def ensure_org_skills_dir(self) -> Path: + out = self.org_skills_dir + out.mkdir(parents=True, exist_ok=True) + return out + @property def backups_dir(self) -> Path: """Stored system backup archives: ``~/.octop/backups/``.""" diff --git a/src/octop/modules/org_os/__init__.py b/src/octop/modules/org_os/__init__.py index 3460d5da..e221f99c 100644 --- a/src/octop/modules/org_os/__init__.py +++ b/src/octop/modules/org_os/__init__.py @@ -1,4 +1,4 @@ -"""openXYOS organization module bridge (plugin + sidecar BFF).""" +"""openXYOS organization module bridge (plugin + sidecar BFF + Phase A).""" from octop.modules.org_os.catalog import OPENXYOS_MODULES, load_upstream_catalog_keys from octop.modules.org_os.service import ( diff --git a/src/octop/modules/org_os/governance/README.md b/src/octop/modules/org_os/governance/README.md new file mode 100644 index 00000000..7fc48a7d --- /dev/null +++ b/src/octop/modules/org_os/governance/README.md @@ -0,0 +1,79 @@ +# xyos-governance-mcp (Direction 2) + +Wraps openXYOS governance as a FreeOS/Octop **stdio MCP** and a hard +in-process interceptor. High-risk tools (`outbound`, `delete`, `pay`, +`prod`) are **default-denied**. Human approval is a durable pause on +disk. `execute` stays false until the same tool + args digest is +re-checked with the approval id. + +This is the data-plane gate. It does **not** replace Octop HITL or the +agent runtime. openXYOS `GovernanceEngine.validateAction` is consulted +when the sidecar is up; a down sidecar **fails closed**. + +## Enable + +```bash +# 1. Organization module + sidecar (control plane, optional but recommended) +uv run freeos org enable +bash scripts/run-org-sidecar.sh + +# 2. Turn on the governance gate and write the MCP connector snippet +uv run freeos org governance enable +``` + +That writes: + +- `config.json` → `modules.org_os.governance.enabled = true` +- `{FREEOS_HOME}/governance/xyos-governance-mcp.json` — paste into the + agent’s MCP / connector stdio list + +Or run the server directly: + +```bash +FREEOS_HOME=~/.freeos FREEOS_ORG_TENANT_ID=1 uv run xyos-governance-mcp +``` + +Octop overlays stdio env via `overlay_stdio_spec_env` (spec env wins; +`OCTOP_*` keys are protected). Prefer `FREEOS_HOME` and +`FREEOS_ORG_TENANT_ID` in the snippet. + +## Tools + +| MCP tool | Purpose | +|---|---| +| `classify` | outbound / delete / pay / prod / low | +| `check_policy` | default-deny + optional durable pause; `execute` is the only green light | +| `resolve_approval` | human approve/reject (does **not** run the tool) | +| `audit_tail` | local `{FREEOS_HOME}/governance/audit.jsonl` | + +CLI equivalents: + +```bash +uv run freeos org governance check --tool delete_employee --category delete +uv run freeos org governance approve +uv run freeos org governance reject +uv run freeos org governance audit +``` + +HTTP (host JWT): `POST /api/org-module/governance/check`. + +## Approval must block + +1. Agent calls `check_policy` (or `gate_tool_call` / `enforce`). +2. High-risk + no matching allow rule → `status=pending`, `execute=false`. +3. Pause file: `{FREEOS_HOME}/governance/pauses/.json`. +4. Human approves via IM (`/approve` once wired) or the CLI. +5. Agent **re-checks** with `approval_id`. Only then is `execute=true`. +6. Restarts do not resume: in-memory HITL is not the source of truth. + +If you catch `GovernanceBlockedError` and still run the tool, governance +is cosmetic — do not do that. + +## Isolation + +One tenant = one FreeOS workspace/sandbox. Set `FREEOS_ORG_TENANT_ID` +per instance. Do not share a workspace across tenants and “prompt” the +model to stay isolated. + +SQL.js in openXYOS is the contract/sidecar store, not the production +audit of record. Local `audit.jsonl` is. diff --git a/src/octop/modules/org_os/governance/__init__.py b/src/octop/modules/org_os/governance/__init__.py new file mode 100644 index 00000000..5866b438 --- /dev/null +++ b/src/octop/modules/org_os/governance/__init__.py @@ -0,0 +1,32 @@ +"""xyos-governance-mcp — openXYOS governance as a FreeOS data-plane gate. + +High-risk tools (outbound / delete / pay / prod) are default-denied until a +durable human approval exists. A pending or deny decision never executes. +""" + +from octop.modules.org_os.governance.engine import GovernanceEngine +from octop.modules.org_os.governance.interceptor import ( + GovernanceBlockedError, + GovernanceInterceptor, + gate_tool_call, +) +from octop.modules.org_os.governance.policy import ( + HIGH_RISK_CATEGORIES, + classify_tool, + is_high_risk, +) +from octop.modules.org_os.governance.store import DurableGovernanceStore +from octop.modules.org_os.governance.types import PolicyDecision, PolicyRequest + +__all__ = [ + "HIGH_RISK_CATEGORIES", + "DurableGovernanceStore", + "GovernanceBlockedError", + "GovernanceEngine", + "GovernanceInterceptor", + "PolicyDecision", + "PolicyRequest", + "classify_tool", + "gate_tool_call", + "is_high_risk", +] diff --git a/src/octop/modules/org_os/governance/engine.py b/src/octop/modules/org_os/governance/engine.py new file mode 100644 index 00000000..bab999fa --- /dev/null +++ b/src/octop/modules/org_os/governance/engine.py @@ -0,0 +1,276 @@ +"""Policy engine: sidecar validate when reachable, otherwise default-deny.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import httpx + +from octop.modules.org_os.governance.policy import ( + HIGH_RISK_CATEGORIES, + args_digest, + classify_tool, +) +from octop.modules.org_os.governance.store import DurableGovernanceStore +from octop.modules.org_os.governance.types import PolicyDecision, PolicyRequest + +logger = logging.getLogger(__name__) + + +class GovernanceEngine: + """Default-deny high-risk actions unless a durable approval matches. + + Mirrors openXYOS ``GovernanceEngine.validateAction`` (unmatched rule → + deny) and writes a local audit even when the sidecar is down. SQL.js in + the sidecar is never treated as the database of record. + """ + + def __init__( + self, + store: DurableGovernanceStore, + *, + sidecar_url: str = "", + sidecar_timeout: float = 2.0, + ) -> None: + self.store = store + self.sidecar_url = (sidecar_url or "").rstrip("/") + self.sidecar_timeout = sidecar_timeout + + @classmethod + def from_home( + cls, + home: Path, + *, + sidecar_url: str = "", + ) -> GovernanceEngine: + return cls(DurableGovernanceStore(home / "governance"), sidecar_url=sidecar_url) + + def evaluate(self, request: PolicyRequest) -> PolicyDecision: + category = classify_tool(request.tool_name, request.category, request.action) + digest = args_digest(request.args) + if category not in HIGH_RISK_CATEGORIES: + decision = PolicyDecision( + status="allow", + execute=True, + blocked=False, + reason="not a high-risk class", + category=category, + rule_source="classifier", + ) + return self._audit(request, decision, digest) + + if request.approval_id and self.store.is_approved_for( + request.approval_id, args_digest=digest, tool_name=request.tool_name + ): + decision = PolicyDecision( + status="allow", + execute=True, + blocked=False, + reason="durable human approval matches this action", + category=category, + pause_id=request.approval_id, + rule_source="durable-approval", + ) + return self._audit(request, decision, digest) + + sidecar = self._sidecar_validate(request) + if sidecar is not None: + if sidecar.get("allowed") is False: + decision = PolicyDecision( + status="deny", + execute=False, + blocked=True, + reason=str(sidecar.get("reason") or "sidecar denied"), + category=category, + rule_source="sidecar-validate", + sidecar_reached=True, + ) + return self._audit(request, decision, digest) + # Sidecar allow still requires human approval for high-risk + # classes — otherwise governance is a rubber stamp. + reason = str(sidecar.get("reason") or "sidecar matched; human approval required") + return self._pending_or_deny(request, category, digest, reason, sidecar_reached=True) + + # Unmatched / sidecar down → default deny (never fail open). + return self._pending_or_deny( + request, + category, + digest, + "no matching governance rule; default deny", + sidecar_reached=False, + ) + + def approve(self, pause_id: str) -> PolicyDecision: + record = self.store.resolve(pause_id, "approved") + if record is None: + return PolicyDecision( + status="deny", + execute=False, + blocked=True, + reason="unknown pause id", + pause_id=pause_id, + ) + if record.status != "approved": + return PolicyDecision( + status="deny", + execute=False, + blocked=True, + reason=f"pause is {record.status}, not approved", + pause_id=pause_id, + category=record.category, + ) + decision = PolicyDecision( + status="allow", + execute=False, + blocked=True, + reason="approved; re-check the original tool with this approval_id to execute", + category=record.category, + pause_id=pause_id, + rule_source="durable-approval", + ) + # execute stays false on the approve call itself — the tool must + # present the approval_id so the digest is re-bound. + self.store.append_audit( + { + "event": "approve", + "pause_id": pause_id, + "tool_name": record.tool_name, + "result": "approved", + } + ) + return decision + + def reject(self, pause_id: str) -> PolicyDecision: + record = self.store.resolve(pause_id, "rejected") + if record is None: + return PolicyDecision( + status="deny", + execute=False, + blocked=True, + reason="unknown pause id", + pause_id=pause_id, + ) + self.store.append_audit( + { + "event": "reject", + "pause_id": pause_id, + "tool_name": record.tool_name, + "result": "rejected", + } + ) + return PolicyDecision( + status="deny", + execute=False, + blocked=True, + reason="human rejected this action", + category=record.category, + pause_id=pause_id, + rule_source="durable-rejection", + ) + + def _pending_or_deny( + self, + request: PolicyRequest, + category: str, + digest: str, + reason: str, + *, + sidecar_reached: bool, + ) -> PolicyDecision: + if not request.auto_pause: + decision = PolicyDecision( + status="deny", + execute=False, + blocked=True, + reason=reason, + category=category, + rule_source="local-default-deny", + sidecar_reached=sidecar_reached, + ) + return self._audit(request, decision, digest) + pause = self.store.create_pause( + tool_name=request.tool_name, + category=category, + action=request.action, + actor_id=request.actor_id, + tenant_id=request.tenant_id, + args_digest=digest, + reason=reason, + ) + decision = PolicyDecision( + status="pending", + execute=False, + blocked=True, + reason=f"{reason}; paused for human approval via FreeOS/Octop IM or " + f"`freeos org governance approve {pause.pause_id}`", + category=category, + pause_id=pause.pause_id, + rule_source="local-default-deny", + sidecar_reached=sidecar_reached, + ) + return self._audit(request, decision, digest) + + def _sidecar_validate(self, request: PolicyRequest) -> dict[str, Any] | None: + if not self.sidecar_url: + return None + parsed = urlparse(self.sidecar_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return None + url = f"{self.sidecar_url}/api/governance/validate" + payload = { + "actor_level": request.actor_level, + "action_type": request.action or request.category or request.tool_name, + "target_type": request.target_type or None, + } + try: + with httpx.Client(timeout=self.sidecar_timeout, follow_redirects=True) as client: + response = client.post(url, json=payload) + except httpx.HTTPError: + logger.info("governance sidecar unreachable; fail closed") + return None + if response.status_code >= 400: + return None + try: + body = response.json() + except ValueError: + return None + if not isinstance(body, dict): + return None + data = body.get("data") if isinstance(body.get("data"), dict) else body + if not isinstance(data, dict) or "allowed" not in data: + return None + return data + + def _audit( + self, request: PolicyRequest, decision: PolicyDecision, digest: str + ) -> PolicyDecision: + audit_id = self.store.append_audit( + { + "event": "check", + "tool_name": request.tool_name, + "category": decision.category, + "action": request.action, + "actor_id": request.actor_id, + "tenant_id": request.tenant_id, + "args_digest": digest, + "result": decision.status, + "execute": decision.execute, + "reason": decision.reason, + "pause_id": decision.pause_id, + "rule_source": decision.rule_source, + } + ) + return PolicyDecision( + status=decision.status, + execute=decision.execute, + blocked=decision.blocked, + reason=decision.reason, + category=decision.category, + pause_id=decision.pause_id, + rule_source=decision.rule_source, + sidecar_reached=decision.sidecar_reached, + audit_id=audit_id, + ) diff --git a/src/octop/modules/org_os/governance/interceptor.py b/src/octop/modules/org_os/governance/interceptor.py new file mode 100644 index 00000000..29bc27bc --- /dev/null +++ b/src/octop/modules/org_os/governance/interceptor.py @@ -0,0 +1,85 @@ +"""Hard gate: pending/deny raises. Callers must not catch-and-continue.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from octop.modules.org_os.governance.engine import GovernanceEngine +from octop.modules.org_os.governance.types import PolicyDecision, PolicyRequest + + +class GovernanceBlockedError(RuntimeError): + """Raised when a high-risk tool must not run. + + Catching this and executing the tool anyway makes governance cosmetic. + """ + + def __init__(self, decision: PolicyDecision) -> None: + super().__init__(decision.reason) + self.decision = decision + + +@dataclass +class GovernanceInterceptor: + engine: GovernanceEngine + enabled: bool = True + + @classmethod + def from_home( + cls, + home: Path, + *, + sidecar_url: str = "", + enabled: bool = True, + ) -> GovernanceInterceptor: + return cls(GovernanceEngine.from_home(home, sidecar_url=sidecar_url), enabled=enabled) + + def check(self, request: PolicyRequest) -> PolicyDecision: + if not self.enabled: + return PolicyDecision( + status="allow", + execute=True, + blocked=False, + reason="governance interceptor disabled", + category="low", + rule_source="disabled", + ) + return self.engine.evaluate(request) + + def enforce(self, request: PolicyRequest) -> PolicyDecision: + """Return only when ``execute`` is true; otherwise raise.""" + decision = self.check(request) + if not decision.execute: + raise GovernanceBlockedError(decision) + return decision + + +def gate_tool_call( + *, + home: Path, + tool_name: str, + category: str = "", + action: str = "", + actor_id: str = "", + tenant_id: str = "", + args: dict[str, Any] | None = None, + approval_id: str = "", + sidecar_url: str = "", + enabled: bool = True, +) -> PolicyDecision: + """Library entry used by MCP, CLI, generated skills, and the org-os plugin.""" + interceptor = GovernanceInterceptor.from_home(home, sidecar_url=sidecar_url, enabled=enabled) + return interceptor.enforce( + PolicyRequest( + tool_name=tool_name, + category=category, + action=action, + actor_id=actor_id, + tenant_id=tenant_id, + args=args or {}, + approval_id=approval_id, + auto_pause=True, + ) + ) diff --git a/src/octop/modules/org_os/governance/mcp_server.py b/src/octop/modules/org_os/governance/mcp_server.py new file mode 100644 index 00000000..678804e7 --- /dev/null +++ b/src/octop/modules/org_os/governance/mcp_server.py @@ -0,0 +1,180 @@ +"""stdio MCP server: ``xyos-governance-mcp``. + +Tools wrap :class:`GovernanceEngine`. High-risk checks default-deny and +never set ``execute`` until a durable approval matches. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from octop.infra.utils.paths import PathLayout +from octop.modules.org_os.governance.engine import GovernanceEngine +from octop.modules.org_os.governance.policy import classify_tool +from octop.modules.org_os.governance.types import PolicyRequest +from octop.modules.org_os.service import OrgModuleService + + +def _engine() -> GovernanceEngine: + paths = PathLayout.from_env() + paths.ensure_root() + service = OrgModuleService(config_path=paths.config, home=paths.root) + return GovernanceEngine.from_home(paths.root, sidecar_url=service.sidecar_url()) + + +def _dumps(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2, default=str) + + +def tool_classify(tool_name: str, category: str = "", action: str = "") -> str: + label = classify_tool(tool_name, category, action) + return _dumps({"tool_name": tool_name, "category": label, "high_risk": label != "low"}) + + +def tool_check_policy( + tool_name: str, + category: str = "", + action: str = "", + actor_id: str = "", + tenant_id: str = "", + approval_id: str = "", + args_json: str = "{}", + auto_pause: bool = True, +) -> str: + try: + args = json.loads(args_json) if args_json else {} + except json.JSONDecodeError: + args = {"_raw": args_json} + if not isinstance(args, dict): + args = {"_value": args} + if not tenant_id: + tenant_id = os.environ.get("FREEOS_ORG_TENANT_ID", "") + engine = _engine() + decision = engine.evaluate( + PolicyRequest( + tool_name=tool_name, + category=category, + action=action, + actor_id=actor_id, + tenant_id=tenant_id, + args=args, + approval_id=approval_id, + auto_pause=auto_pause, + ) + ) + payload = decision.to_dict() + payload["instruction"] = ( + "Do not execute the tool unless execute is true. " + "pending/deny is a hard stop; ask a human to approve the pause_id." + ) + return _dumps(payload) + + +def tool_resolve(pause_id: str, approve: bool = True) -> str: + engine = _engine() + decision = engine.approve(pause_id) if approve else engine.reject(pause_id) + return _dumps(decision.to_dict()) + + +def tool_audit_tail(limit: int = 20) -> str: + engine = _engine() + return _dumps(engine.store.tail_audit(limit)) + + +def _register_fastmcp() -> Any: + from mcp.server.fastmcp import FastMCP + + server = FastMCP("xyos-governance-mcp") + + @server.tool() + def classify(tool_name: str, category: str = "", action: str = "") -> str: + """Classify a tool as outbound/delete/pay/prod/low.""" + return tool_classify(tool_name, category, action) + + @server.tool() + def check_policy( + tool_name: str, + category: str = "", + action: str = "", + actor_id: str = "", + tenant_id: str = "", + approval_id: str = "", + args_json: str = "{}", + auto_pause: bool = True, + ) -> str: + """Policy-check a tool. High-risk default-denies; execute is false until approved.""" + return tool_check_policy( + tool_name, + category, + action, + actor_id, + tenant_id, + approval_id, + args_json, + auto_pause, + ) + + @server.tool() + def resolve_approval(pause_id: str, approve: bool = True) -> str: + """Human resolve. Approving does not run the tool; the agent must re-check.""" + return tool_resolve(pause_id, approve) + + @server.tool() + def audit_tail(limit: int = 20) -> str: + """Recent local governance audit events (FreeOS home, not sidecar SQL.js).""" + return tool_audit_tail(limit) + + return server + + +def stdio_spec(command: str = "xyos-governance-mcp") -> dict[str, Any]: + """Octop connector overlay: stdio MCP with tenant/home env.""" + paths = PathLayout.from_env() + env = { + "FREEOS_HOME": str(paths.root), + } + tenant = os.environ.get("FREEOS_ORG_TENANT_ID", "").strip() + if tenant: + env["FREEOS_ORG_TENANT_ID"] = tenant + sidecar = os.environ.get("FREEOS_ORG_SIDECAR_URL", "").strip() + if sidecar: + env["FREEOS_ORG_SIDECAR_URL"] = sidecar + return { + "transport": "stdio", + "command": command, + "args": [], + "env": env, + } + + +def write_connector_snippet(path: Path | None = None) -> Path: + """Write a connector JSON snippet operators can paste into Octop MCP config.""" + paths = PathLayout.from_env() + paths.ensure_root() + target = path or (paths.root / "governance" / "xyos-governance-mcp.json") + target.parent.mkdir(parents=True, exist_ok=True) + spec = {"xyos-governance-mcp": stdio_spec()} + target.write_text(json.dumps(spec, indent=2) + "\n", encoding="utf-8") + return target + + +def main() -> None: + """Entry point for the ``xyos-governance-mcp`` console script.""" + try: + server = _register_fastmcp() + except ImportError as exc: + raise SystemExit( + "xyos-governance-mcp requires the `mcp` package (project dependency). " + "Install with `uv sync` and retry." + ) from exc + run = getattr(server, "run", None) + if run is None: + raise SystemExit("FastMCP server has no run()") + run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/src/octop/modules/org_os/governance/policy.py b/src/octop/modules/org_os/governance/policy.py new file mode 100644 index 00000000..c369dbc4 --- /dev/null +++ b/src/octop/modules/org_os/governance/policy.py @@ -0,0 +1,90 @@ +"""Classify high-risk tools. Unmatched high-risk rules default-deny.""" + +from __future__ import annotations + +import re +from typing import Any + +HIGH_RISK_CATEGORIES = frozenset({"outbound", "delete", "pay", "prod"}) + +# Explicit category aliases operators may pass through MCP / CLI. +_CATEGORY_ALIASES = { + "outbound": "outbound", + "out": "outbound", + "network": "outbound", + "http": "outbound", + "delete": "delete", + "remove": "delete", + "destroy": "delete", + "pay": "pay", + "payment": "pay", + "transfer": "pay", + "prod": "prod", + "production": "prod", + "deploy": "prod", + "low": "low", + "read": "low", +} + +_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ( + "pay", + re.compile( + r"(pay|payment|invoice|transfer|checkout|billing|stripe|alipay)", + re.IGNORECASE, + ), + ), + ( + "delete", + re.compile(r"(delete|unlink|rm\b|drop_|destroy|purge|wipe)", re.IGNORECASE), + ), + ( + "prod", + re.compile( + r"(deploy|kubectl|production|prod[_-]|migrate|terraform\b|helm\b)", + re.IGNORECASE, + ), + ), + ( + "outbound", + re.compile( + r"(http_request|web_fetch|webhook|send_email|im_send|smtp|curl\b)", + re.IGNORECASE, + ), + ), +) + + +def normalize_category(raw: str | None) -> str: + key = (raw or "").strip().lower() + if not key: + return "" + return _CATEGORY_ALIASES.get(key, key if key in HIGH_RISK_CATEGORIES or key == "low" else "") + + +def classify_tool(tool_name: str, category: str = "", action: str = "") -> str: + """Return a risk category. Empty / unknown names are ``low`` unless tagged.""" + explicit = normalize_category(category) + if explicit: + return explicit + haystack = " ".join(part for part in (tool_name, action) if part) + if not haystack.strip(): + return "low" + for label, pattern in _PATTERNS: + if pattern.search(haystack): + return label + return "low" + + +def is_high_risk(tool_name: str, category: str = "", action: str = "") -> bool: + return classify_tool(tool_name, category, action) in HIGH_RISK_CATEGORIES + + +def args_digest(args: dict[str, Any] | None) -> str: + """Stable digest so an approval cannot be reused on a different payload.""" + import hashlib + import json + + payload = args if isinstance(args, dict) else {} + encoded = json.dumps(payload, sort_keys=True, default=str, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:16] diff --git a/src/octop/modules/org_os/governance/store.py b/src/octop/modules/org_os/governance/store.py new file mode 100644 index 00000000..cc077045 --- /dev/null +++ b/src/octop/modules/org_os/governance/store.py @@ -0,0 +1,153 @@ +"""Durable pause/resume + JSONL audit under the FreeOS home directory.""" + +from __future__ import annotations + +import json +import secrets +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Literal + +from octop.modules.org_os.governance.types import PauseStatus + +_DEFAULT_TTL_SECONDS = 30 * 60 + + +@dataclass +class PauseRecord: + pause_id: str + tool_name: str + category: str + action: str + actor_id: str + tenant_id: str + args_digest: str + reason: str + status: PauseStatus = "pending" + created_at: float = field(default_factory=time.time) + resolved_at: float | None = None + ttl_seconds: float = _DEFAULT_TTL_SECONDS + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PauseRecord: + return cls( + pause_id=str(data.get("pause_id") or ""), + tool_name=str(data.get("tool_name") or ""), + category=str(data.get("category") or ""), + action=str(data.get("action") or ""), + actor_id=str(data.get("actor_id") or ""), + tenant_id=str(data.get("tenant_id") or ""), + args_digest=str(data.get("args_digest") or ""), + reason=str(data.get("reason") or ""), + status=data.get("status") or "pending", # type: ignore[arg-type] + created_at=float(data.get("created_at") or time.time()), + resolved_at=data.get("resolved_at"), + ttl_seconds=float(data.get("ttl_seconds") or _DEFAULT_TTL_SECONDS), + ) + + +class DurableGovernanceStore: + """Disk-backed pauses. A restart must not silently resume a high-risk call.""" + + def __init__(self, root: Path, *, ttl_seconds: float = _DEFAULT_TTL_SECONDS) -> None: + self.root = root + self.pauses_dir = root / "pauses" + self.audit_file = root / "audit.jsonl" + self.ttl_seconds = ttl_seconds + self.pauses_dir.mkdir(parents=True, exist_ok=True) + self.root.mkdir(parents=True, exist_ok=True) + + def create_pause( + self, + *, + tool_name: str, + category: str, + action: str, + actor_id: str, + tenant_id: str, + args_digest: str, + reason: str, + ) -> PauseRecord: + pause_id = secrets.token_hex(8) + record = PauseRecord( + pause_id=pause_id, + tool_name=tool_name, + category=category, + action=action, + actor_id=actor_id, + tenant_id=tenant_id, + args_digest=args_digest, + reason=reason, + ttl_seconds=self.ttl_seconds, + ) + self._write_pause(record) + return record + + def get(self, pause_id: str) -> PauseRecord | None: + path = self._pause_path(pause_id) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + record = PauseRecord.from_dict(data) + if record.status == "pending" and time.time() - record.created_at > record.ttl_seconds: + record.status = "expired" + record.resolved_at = time.time() + self._write_pause(record) + return record + + def resolve(self, pause_id: str, status: Literal["approved", "rejected"]) -> PauseRecord | None: + record = self.get(pause_id) + if record is None or record.status != "pending": + return record + record.status = status + record.resolved_at = time.time() + self._write_pause(record) + return record + + def is_approved_for(self, pause_id: str, *, args_digest: str, tool_name: str) -> bool: + record = self.get(pause_id) + if record is None or record.status != "approved": + return False + return record.args_digest == args_digest and record.tool_name == tool_name + + def append_audit(self, event: dict[str, Any]) -> str: + audit_id = str(event.get("audit_id") or secrets.token_hex(8)) + row = dict(event) + row["audit_id"] = audit_id + row.setdefault("ts", time.time()) + self.audit_file.parent.mkdir(parents=True, exist_ok=True) + with self.audit_file.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, ensure_ascii=False, default=str) + "\n") + return audit_id + + def tail_audit(self, limit: int = 50) -> list[dict[str, Any]]: + if not self.audit_file.is_file(): + return [] + lines = self.audit_file.read_text(encoding="utf-8").splitlines() + rows: list[dict[str, Any]] = [] + for line in lines[-max(1, limit) :]: + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(data, dict): + rows.append(data) + return rows + + def _pause_path(self, pause_id: str) -> Path: + safe = Path(pause_id).name + return self.pauses_dir / f"{safe}.json" + + def _write_pause(self, record: PauseRecord) -> None: + path = self._pause_path(record.pause_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(record.to_dict(), indent=2) + "\n", encoding="utf-8") diff --git a/src/octop/modules/org_os/governance/types.py b/src/octop/modules/org_os/governance/types.py new file mode 100644 index 00000000..5f5835c6 --- /dev/null +++ b/src/octop/modules/org_os/governance/types.py @@ -0,0 +1,43 @@ +"""Shared types for the governance gate.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Literal + +DecisionStatus = Literal["allow", "deny", "pending"] +PauseStatus = Literal["pending", "approved", "rejected", "expired"] +RiskCategory = Literal["outbound", "delete", "pay", "prod", "low"] + + +@dataclass(frozen=True) +class PolicyRequest: + tool_name: str + category: str = "" + action: str = "" + actor_id: str = "" + actor_level: int = 0 + tenant_id: str = "" + target_type: str = "" + args: dict[str, Any] = field(default_factory=dict) + approval_id: str = "" + auto_pause: bool = True + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class PolicyDecision: + status: DecisionStatus + execute: bool + blocked: bool + reason: str + category: str = "low" + pause_id: str = "" + rule_source: str = "local-default-deny" + sidecar_reached: bool = False + audit_id: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) diff --git a/src/octop/modules/org_os/proxy.py b/src/octop/modules/org_os/proxy.py index 9b47bc77..8cda3924 100644 --- a/src/octop/modules/org_os/proxy.py +++ b/src/octop/modules/org_os/proxy.py @@ -35,20 +35,24 @@ def sidecar_target(base_url: str, path: str, query: str = "") -> str: return url -def identity_headers(user: Any) -> dict[str, str]: +def identity_headers(user: Any, *, tenant_id: str | None = None) -> dict[str, str]: """Map a FreeOS/Octop user onto sidecar request headers (MVP, not SSO).""" headers: dict[str, str] = {} - if user is None: - return headers - username = getattr(user, "username", None) or getattr(user, "uname", None) - user_id = getattr(user, "id", None) - role = getattr(user, "role", None) - if username: - headers["X-FreeOS-User"] = str(username) - if user_id is not None: - headers["X-FreeOS-User-Id"] = str(user_id) - if role: - headers["X-FreeOS-Role"] = str(role) + if user is not None: + username = getattr(user, "username", None) or getattr(user, "uname", None) + user_id = getattr(user, "id", None) + role = getattr(user, "role", None) + user_tenant = getattr(user, "tenant_id", None) + if username: + headers["X-FreeOS-User"] = str(username) + if user_id is not None: + headers["X-FreeOS-User-Id"] = str(user_id) + if role: + headers["X-FreeOS-Role"] = str(role) + if user_tenant is not None and str(user_tenant).strip(): + headers["X-FreeOS-Tenant-Id"] = str(user_tenant) + if tenant_id and str(tenant_id).strip(): + headers["X-FreeOS-Tenant-Id"] = str(tenant_id).strip() return headers diff --git a/src/octop/modules/org_os/service.py b/src/octop/modules/org_os/service.py index ba048120..1ee20e51 100644 --- a/src/octop/modules/org_os/service.py +++ b/src/octop/modules/org_os/service.py @@ -40,6 +40,8 @@ class OrgModuleStatus: embed_url: str proxy_prefix: str start_command: str + tenant_id: str = "" + governance_enabled: bool = False notes: list[str] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: @@ -107,6 +109,40 @@ def sidecar_url(self) -> str: return normalize_sidecar_url(raw) return DEFAULT_SIDECAR_URL + def tenant_id(self) -> str: + env = os.environ.get("FREEOS_ORG_TENANT_ID", "").strip() + if env: + return env + section = self._section() + raw = section.get("tenant_id") + return str(raw).strip() if raw is not None and str(raw).strip() else "" + + def governance_enabled(self) -> bool: + section = self._section() + gov = section.get("governance") + if isinstance(gov, dict) and "enabled" in gov: + return bool(gov.get("enabled")) + return False + + def set_governance_enabled(self, enabled: bool, *, tenant_id: str | None = None) -> None: + data = _read_json(self.config_path) + modules = data.get("modules") + if not isinstance(modules, dict): + modules = {} + data["modules"] = modules + section = modules.get(_CONFIG_SECTION) + if not isinstance(section, dict): + section = {} + gov = section.get("governance") + if not isinstance(gov, dict): + gov = {} + gov["enabled"] = bool(enabled) + section["governance"] = gov + if tenant_id is not None and tenant_id.strip(): + section["tenant_id"] = tenant_id.strip() + modules[_CONFIG_SECTION] = section + _write_json(self.config_path, data) + def set_enabled(self, enabled: bool, *, sidecar_url: str | None = None) -> None: data = _read_json(self.config_path) modules = data.get("modules") @@ -165,10 +201,13 @@ def status(self) -> OrgModuleStatus: enabled = self.is_enabled() notes = [ "Host identity stays in FreeOS (JWT users under the platform home).", - "openXYOS keeps its own tenants and sessions; MVP proxy forwards " - "X-FreeOS-User* headers only — sign in to the sidecar separately.", + "openXYOS is the control plane; FreeOS/Octop is the data plane. " + "Do not replace the Octop agent runtime with openXYOS chat.", + "Proxy forwards X-FreeOS-User* and X-FreeOS-Tenant-Id; sign in to " + "the sidecar separately for mutating org routes.", "Plugin id org-os is seeded disabled; enable it here or via " "Admin → Plugins / `freeos org enable`.", + "Phase A: `freeos org governance enable` and `freeos org skills generate`.", ] if not sidecar.reachable and enabled: notes.append( @@ -184,6 +223,8 @@ def status(self) -> OrgModuleStatus: embed_url=sidecar.url if sidecar.reachable else "", proxy_prefix="/api/org-module/sidecar", start_command="bash scripts/run-org-sidecar.sh", + tenant_id=self.tenant_id(), + governance_enabled=self.governance_enabled(), notes=notes, ) diff --git a/src/octop/modules/org_os/skill_bridge/README.md b/src/octop/modules/org_os/skill_bridge/README.md new file mode 100644 index 00000000..9973e486 --- /dev/null +++ b/src/octop/modules/org_os/skill_bridge/README.md @@ -0,0 +1,60 @@ +# Module ↔ Skill bridge (Direction 3) + +Bidirectional path between the openXYOS **module catalog** (control plane) +and FreeOS/Octop **skills** (data plane). + +- **Forward:** generate `SKILL.md` + `scripts/call_module.py` that hit + real sidecar `/api/…` routes with tenant headers. +- **Reverse:** publish a polished skill as a **tenant-toggleable** plugin + draft. Nothing is auto-enabled. + +openXYOS chat is not the agent runtime. These skills call APIs; Octop +still runs the agent. + +## Enable / generate + +```bash +uv run freeos org enable +uv run freeos org skills generate +# or one module: +uv run freeos org skills generate --module employees +``` + +Default output: `{FREEOS_HOME}/org-skills/org-/`. + +Each skill: + +- YAML frontmatter (`name`, `description`, `metadata.freeos.module_key`) +- Documents `X-FreeOS-Tenant-Id` / `X-FreeOS-User*` +- Tables real `/api/org`, `/api/employees`, `/api/governance`, … +- Calls the BFF: `/api/org-module/sidecar{path}` +- Sends high-risk verbs through `xyos-governance-mcp` before HTTP + +```bash +FREEOS_ORG_TENANT_ID=1 FREEOS_HOME=~/.freeos \ + python ~/.freeos/org-skills/org-employees/scripts/call_module.py \ + --method GET --path /api/employees --risk low +``` + +## Publish back (tenant toggle) + +```bash +uv run freeos org skills publish ~/.freeos/org-skills/org-employees +``` + +Writes `org-employees.plugin/` next to the skill (or `--out`): + +| File | Role | +|---|---| +| `plugin.yaml` / `main.py` / `ui/` | Octop bundled-plugin shape | +| `publish.json` | Sidecar POST `/api/plugins` + PUT `/api/module-settings` payload | + +`enabled` is always `false` in the draft. Operators enable per tenant. +Do not install one plugin copy into a shared workspace used by multiple +tenants. + +## Isolation and licenses + +- One tenant = one workspace/sandbox instance (not prompt isolation). +- Generated skills are MIT host/bridge code. They call Apache-2.0 + sidecar APIs; they do not rebrand as XYOS. diff --git a/src/octop/modules/org_os/skill_bridge/__init__.py b/src/octop/modules/org_os/skill_bridge/__init__.py new file mode 100644 index 00000000..c8eb8602 --- /dev/null +++ b/src/octop/modules/org_os/skill_bridge/__init__.py @@ -0,0 +1,11 @@ +"""Module ↔ skill bidirectional bridge (Direction 3).""" + +from octop.modules.org_os.skill_bridge.endpoints import MODULE_ENDPOINTS +from octop.modules.org_os.skill_bridge.generate import generate_module_skills +from octop.modules.org_os.skill_bridge.publish import publish_skill + +__all__ = [ + "MODULE_ENDPOINTS", + "generate_module_skills", + "publish_skill", +] diff --git a/src/octop/modules/org_os/skill_bridge/endpoints.py b/src/octop/modules/org_os/skill_bridge/endpoints.py new file mode 100644 index 00000000..5b553706 --- /dev/null +++ b/src/octop/modules/org_os/skill_bridge/endpoints.py @@ -0,0 +1,204 @@ +"""Map openXYOS catalog keys to real sidecar /api routes. + +Paths are relative to the sidecar origin (or the FreeOS BFF prefix +``/api/org-module/sidecar``). Risk tags drive the governance gate. +""" + +from __future__ import annotations + +from typing import TypedDict + + +class ModuleEndpoint(TypedDict): + method: str + path: str + summary: str + risk: str + + +MODULE_ENDPOINTS: dict[str, tuple[ModuleEndpoint, ...]] = { + "workspace": ( + { + "method": "GET", + "path": "/api/dashboard", + "summary": "Organization operations overview", + "risk": "low", + }, + ), + "announcements": ( + { + "method": "GET", + "path": "/api/announcements", + "summary": "List notices", + "risk": "low", + }, + { + "method": "POST", + "path": "/api/announcements", + "summary": "Publish a notice", + "risk": "outbound", + }, + ), + "organization": ( + { + "method": "GET", + "path": "/api/org", + "summary": "Org tree, groups, departments", + "risk": "low", + }, + ), + "employees": ( + { + "method": "GET", + "path": "/api/employees", + "summary": "List people and AI employees", + "risk": "low", + }, + { + "method": "POST", + "path": "/api/employees", + "summary": "Create an employee record", + "risk": "prod", + }, + { + "method": "DELETE", + "path": "/api/employees/{id}", + "summary": "Delete an employee record", + "risk": "delete", + }, + { + "method": "GET", + "path": "/api/talent", + "summary": "Talent market listings", + "risk": "low", + }, + ), + "skills": ( + { + "method": "GET", + "path": "/api/skills", + "summary": "Control-plane skill catalog", + "risk": "low", + }, + { + "method": "POST", + "path": "/api/plugins", + "summary": "Register a tenant-toggleable plugin", + "risk": "prod", + }, + ), + "chat": ( + { + "method": "GET", + "path": "/api/chats", + "summary": "List control-plane rooms (do not replace Octop chat)", + "risk": "low", + }, + ), + "agents": ( + { + "method": "GET", + "path": "/api/ai", + "summary": "Advisor agents / studio listing", + "risk": "low", + }, + ), + "tasks": ( + { + "method": "GET", + "path": "/api/tasks", + "summary": "List tasks", + "risk": "low", + }, + { + "method": "POST", + "path": "/api/tasks", + "summary": "Create a task", + "risk": "prod", + }, + { + "method": "DELETE", + "path": "/api/tasks/{id}", + "summary": "Delete a task", + "risk": "delete", + }, + ), + "knowledge": ( + { + "method": "GET", + "path": "/api/knowledge", + "summary": "List knowledge documents", + "risk": "low", + }, + { + "method": "POST", + "path": "/api/knowledge", + "summary": "Upload / create a knowledge document", + "risk": "prod", + }, + ), + "reflections": ( + { + "method": "GET", + "path": "/api/reflections", + "summary": "List retrospectives", + "risk": "low", + }, + { + "method": "POST", + "path": "/api/reflections", + "summary": "Capture a reflection", + "risk": "prod", + }, + ), + "governance": ( + { + "method": "GET", + "path": "/api/governance/stats", + "summary": "Governance overview", + "risk": "low", + }, + { + "method": "GET", + "path": "/api/governance/permissions", + "summary": "Permission matrix", + "risk": "low", + }, + { + "method": "POST", + "path": "/api/governance/validate", + "summary": "Validate an action against the matrix", + "risk": "low", + }, + { + "method": "POST", + "path": "/api/governance/permissions", + "summary": "Replace permission rules (admin)", + "risk": "prod", + }, + ), + "settings": ( + { + "method": "GET", + "path": "/api/module-settings", + "summary": "Tenant module toggles", + "risk": "low", + }, + { + "method": "PUT", + "path": "/api/module-settings", + "summary": "Update tenant module toggles", + "risk": "prod", + }, + { + "method": "GET", + "path": "/api/settings", + "summary": "Tenant settings", + "risk": "low", + }, + ), +} + + +def endpoints_for(module_key: str) -> tuple[ModuleEndpoint, ...]: + return MODULE_ENDPOINTS.get(module_key, ()) diff --git a/src/octop/modules/org_os/skill_bridge/generate.py b/src/octop/modules/org_os/skill_bridge/generate.py new file mode 100644 index 00000000..6ab27517 --- /dev/null +++ b/src/octop/modules/org_os/skill_bridge/generate.py @@ -0,0 +1,266 @@ +"""Generate FreeOS/Octop SKILL.md trees from the openXYOS module catalog.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from octop.modules.org_os.catalog import OPENXYOS_MODULES, OrgCapability +from octop.modules.org_os.skill_bridge.endpoints import MODULE_ENDPOINTS, ModuleEndpoint + +BFF_PREFIX = "/api/org-module/sidecar" +TENANT_HEADERS = ( + "X-FreeOS-Tenant-Id", + "X-FreeOS-User", + "X-FreeOS-User-Id", + "X-FreeOS-Role", +) + +_CALL_MODULE_PY = '''\ +"""Call an openXYOS module API through the FreeOS BFF (or sidecar). + +High-risk methods are gated by xyos-governance-mcp. A blocked decision +exits non-zero and must not be retried without a human approval_id. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + + +def _home() -> Path: + raw = os.environ.get("FREEOS_HOME") or os.environ.get("OCTOP_HOME") + if raw: + return Path(raw) + freeos = Path.home() / ".freeos" + octop = Path.home() / ".octop" + if freeos.exists(): + return freeos + if octop.exists(): + return octop + return freeos + + +def _gate(method: str, path: str, risk: str, approval_id: str) -> None: + if risk in {"", "low"}: + return + try: + from octop.modules.org_os.governance.interceptor import ( + GovernanceBlockedError, + gate_tool_call, + ) + except ImportError: + print("governance interceptor unavailable; refusing high-risk call", file=sys.stderr) + raise SystemExit(2) from None + sidecar = os.environ.get("FREEOS_ORG_SIDECAR_URL", "") + try: + gate_tool_call( + home=_home(), + tool_name=f"{method} {path}", + category=risk, + action=f"{method} {path}", + tenant_id=os.environ.get("FREEOS_ORG_TENANT_ID", ""), + actor_id=os.environ.get("FREEOS_USER", ""), + args={"method": method, "path": path}, + approval_id=approval_id, + sidecar_url=sidecar, + ) + except GovernanceBlockedError as exc: + print(json.dumps(exc.decision.to_dict(), ensure_ascii=False, indent=2)) + raise SystemExit(3) from exc + + +def _headers() -> dict[str, str]: + mapping = { + "X-FreeOS-Tenant-Id": os.environ.get("FREEOS_ORG_TENANT_ID", ""), + "X-FreeOS-User": os.environ.get("FREEOS_USER", os.environ.get("OCTOP_USER", "")), + "X-FreeOS-User-Id": os.environ.get("FREEOS_USER_ID", ""), + "X-FreeOS-Role": os.environ.get("FREEOS_ROLE", ""), + "Content-Type": "application/json", + "Accept": "application/json", + } + return {key: value for key, value in mapping.items() if value} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Call an openXYOS module API") + parser.add_argument("--method", required=True) + parser.add_argument("--path", required=True, help="Sidecar path, e.g. /api/employees") + parser.add_argument("--risk", default="low") + parser.add_argument("--approval-id", default="") + parser.add_argument("--body", default="") + args = parser.parse_args() + _gate(args.method.upper(), args.path, args.risk, args.approval_id) + + base = os.environ.get("FREEOS_ORG_BFF_URL", "http://127.0.0.1:18900") + sidecar = os.environ.get("FREEOS_ORG_SIDECAR_URL", "http://127.0.0.1:3780") + use_bff = os.environ.get("FREEOS_ORG_USE_BFF", "1") != "0" + if use_bff: + url = base.rstrip("/") + "/api/org-module/sidecar" + args.path + else: + url = sidecar.rstrip("/") + args.path + data = args.body.encode("utf-8") if args.body else None + request = urllib.request.Request(url, data=data, method=args.method.upper(), headers=_headers()) + try: + with urllib.request.urlopen(request, timeout=15) as response: + sys.stdout.write(response.read().decode("utf-8", errors="replace")) + except urllib.error.HTTPError as exc: + sys.stderr.write(exc.read().decode("utf-8", errors="replace")) + raise SystemExit(exc.code) from exc + + +if __name__ == "__main__": + main() +''' + + +@dataclass +class GeneratedSkill: + slug: str + module_key: str + directory: Path + skill_md: Path + script: Path + endpoints: list[ModuleEndpoint] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "slug": self.slug, + "module_key": self.module_key, + "directory": str(self.directory), + "skill_md": str(self.skill_md), + "script": str(self.script), + "endpoints": list(self.endpoints), + } + + +def skill_slug(module_key: str) -> str: + return f"org-{module_key}" + + +def render_skill_md(module: OrgCapability, endpoints: tuple[ModuleEndpoint, ...]) -> str: + slug = skill_slug(module["key"]) + lines = [ + "---", + f"name: {slug}", + f"description: Call the openXYOS {module['label']} module " + f"({module['key']}) through FreeOS tenant headers and real /api routes.", + "metadata:", + " freeos:", + " source: openxyos-module", + f" module_key: {module['key']}", + " label:", + f" en: {module['label']}", + f" zh: {module['label_zh']}", + "---", + "", + f"# {module['label']} (`{module['key']}`)", + "", + module["description"], + "", + "This skill talks to the **openXYOS control plane** via the FreeOS BFF.", + "It does **not** start a second agent chat runtime.", + "", + "## Tenant isolation", + "", + "One tenant = one FreeOS workspace/sandbox. Always send:", + "", + ] + for header in TENANT_HEADERS: + lines.append(f"- `{header}`") + lines.extend( + [ + "", + "Set `FREEOS_ORG_TENANT_ID` on this instance. Never share a workspace", + "across tenants.", + "", + "## API calls", + "", + "Prefer the BFF (host JWT, module must be enabled):", + "", + f"`{{FREEOS_HOST}}{BFF_PREFIX}{{sidecar_path}}`", + "", + "Direct sidecar origin is `FREEOS_ORG_SIDECAR_URL` (default", + "`http://127.0.0.1:3780`). Mutating org routes still need a sidecar", + "session; the BFF forwards identity headers only.", + "", + "| Method | Path | Risk | Summary |", + "|---|---|---|---|", + ] + ) + for item in endpoints: + lines.append( + f"| `{item['method']}` | `{item['path']}` | `{item['risk']}` | {item['summary']} |" + ) + if not endpoints: + lines.append("| — | — | — | No routes mapped yet |") + lines.extend( + [ + "", + "## Run", + "", + "```bash", + "python scripts/call_module.py --method GET --path /api/... --risk low", + "```", + "", + "For `outbound` / `delete` / `pay` / `prod` the script calls", + "`xyos-governance-mcp` / `gate_tool_call` first. If the decision is", + "`pending` or `deny`, **stop**. Ask a human to", + "`freeos org governance approve ` and retry with", + "`--approval-id`. Do not execute the HTTP call yourself.", + "", + "## Publish back", + "", + "When this skill is polished:", + "", + "```bash", + f"uv run freeos org skills publish {slug}", + "```", + "", + "That writes a tenant-toggleable plugin draft. It does not enable", + "the module for every tenant.", + "", + ] + ) + return "\n".join(lines) + + +def generate_module_skills( + out_dir: Path, + *, + module_keys: list[str] | None = None, +) -> list[GeneratedSkill]: + out_dir.mkdir(parents=True, exist_ok=True) + wanted = set(module_keys) if module_keys else None + generated: list[GeneratedSkill] = [] + for module in OPENXYOS_MODULES: + key = module["key"] + if wanted is not None and key not in wanted: + continue + endpoints = MODULE_ENDPOINTS.get(key, ()) + slug = skill_slug(key) + directory = out_dir / slug + scripts = directory / "scripts" + scripts.mkdir(parents=True, exist_ok=True) + skill_md = directory / "SKILL.md" + script = scripts / "call_module.py" + skill_md.write_text(render_skill_md(module, endpoints), encoding="utf-8") + script.write_text(_CALL_MODULE_PY, encoding="utf-8") + generated.append( + GeneratedSkill( + slug=slug, + module_key=key, + directory=directory, + skill_md=skill_md, + script=script, + endpoints=list(endpoints), + ) + ) + return generated diff --git a/src/octop/modules/org_os/skill_bridge/publish.py b/src/octop/modules/org_os/skill_bridge/publish.py new file mode 100644 index 00000000..2c6e8a9c --- /dev/null +++ b/src/octop/modules/org_os/skill_bridge/publish.py @@ -0,0 +1,157 @@ +"""Publish a polished FreeOS skill back as a tenant-toggleable plugin draft.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +_FRONTMATTER_NAME = re.compile(r"^name:\s*([a-z0-9-]+)\s*$", re.MULTILINE) +_MODULE_KEY = re.compile(r"module_key:\s*([a-z0-9-]+)") + + +@dataclass +class PublishDraft: + plugin_id: str + module_key: str + source_skill: Path + output_dir: Path + plugin_yaml: Path + payload_path: Path + notes: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "plugin_id": self.plugin_id, + "module_key": self.module_key, + "source_skill": str(self.source_skill), + "output_dir": str(self.output_dir), + "plugin_yaml": str(self.plugin_yaml), + "payload": str(self.payload_path), + "notes": list(self.notes), + } + + +def _read_skill(skill_dir: Path) -> tuple[str, str, str]: + manifest = skill_dir / "SKILL.md" + if not manifest.is_file(): + raise ValueError(f"{skill_dir} is not a Skill directory (missing SKILL.md)") + text = manifest.read_text(encoding="utf-8") + name_match = _FRONTMATTER_NAME.search(text) + slug = name_match.group(1) if name_match else skill_dir.name + module_match = _MODULE_KEY.search(text) + module_key = module_match.group(1) if module_match else slug.removeprefix("org-") + return slug, module_key, text + + +def _plugin_yaml(plugin_id: str, module_key: str) -> str: + return ( + f"id: {plugin_id}\n" + "version: 0.1.0\n" + f"name: OpenXYOS {module_key} skill\n" + "description: Tenant-toggleable plugin published from a FreeOS module skill\n" + 'icon: "🧩"\n' + "kind: tool\n" + "entry: main.py\n" + "ui:\n" + " entry: ui/index.js\n" + " manifest: ui/manifest.json\n" + ) + + +def _main_py(plugin_id: str, module_key: str) -> str: + return f'''\ +"""Published org skill plugin (draft). Enable per tenant — not globally.""" + +from __future__ import annotations + +import json + +from harness_agent.plugins import PluginContext + + +def {plugin_id.replace("-", "_")}_info() -> str: + return json.dumps( + {{ + "plugin_id": "{plugin_id}", + "module_key": "{module_key}", + "enabled_by_default": False, + "note": "Tenant must toggle this plugin on; isolation is one tenant per workspace.", + }}, + ensure_ascii=False, + ) + + +def setup(ctx: PluginContext) -> None: + ctx.tool( + "{plugin_id.replace("-", "_")}_info", + {plugin_id.replace("-", "_")}_info, + description="Describe the published openXYOS module skill plugin (disabled by default).", + ) +''' + + +def _ui_files() -> tuple[str, str]: + manifest = json.dumps({"renderer": "org_skill_card", "version": 1}, indent=2) + "\n" + index = "export default function render(data) { return String(data && data.text || ''); }\n" + return manifest, index + + +def publish_skill(skill_dir: Path, *, out_dir: Path | None = None) -> PublishDraft: + """Write a plugin draft + sidecar tenant-toggle payload. Does not auto-enable.""" + skill_dir = skill_dir.resolve() + slug, module_key, _text = _read_skill(skill_dir) + plugin_id = slug if slug.startswith("org-") else f"org-{slug}" + dest = (out_dir or skill_dir.parent / f"{plugin_id}.plugin").resolve() + dest.mkdir(parents=True, exist_ok=True) + (dest / "ui").mkdir(parents=True, exist_ok=True) + + plugin_yaml = dest / "plugin.yaml" + plugin_yaml.write_text(_plugin_yaml(plugin_id, module_key), encoding="utf-8") + (dest / "main.py").write_text(_main_py(plugin_id, module_key), encoding="utf-8") + manifest, index = _ui_files() + (dest / "ui" / "manifest.json").write_text(manifest, encoding="utf-8") + (dest / "ui" / "index.js").write_text(index, encoding="utf-8") + + payload = { + "plugin_id": plugin_id, + "module_key": module_key, + "enabled": False, + "tenant_toggleable": True, + "source_skill": str(skill_dir), + "sidecar": { + "method": "POST", + "path": "/api/plugins", + "body": { + "id": plugin_id, + "module_key": module_key, + "enabled": False, + "source": "freeos-skill-bridge", + }, + }, + "module_settings": { + "method": "PUT", + "path": "/api/module-settings", + "note": "Enable only for the target tenant; never broadcast to all tenants.", + }, + } + payload_path = dest / "publish.json" + payload_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + notes = [ + "Draft only — not installed into ~/.freeos/plugins or the sidecar.", + "Copy plugin.yaml into a tenant workspace or POST publish.json to the sidecar.", + "Keep enabled=false until the tenant opts in.", + "One tenant = one workspace/sandbox; do not reuse this plugin across tenants.", + ] + return PublishDraft( + plugin_id=plugin_id, + module_key=module_key, + source_skill=skill_dir, + output_dir=dest, + plugin_yaml=plugin_yaml, + payload_path=payload_path, + notes=notes, + ) diff --git a/tests/unit/test_governance_mcp.py b/tests/unit/test_governance_mcp.py new file mode 100644 index 00000000..c45192c9 --- /dev/null +++ b/tests/unit/test_governance_mcp.py @@ -0,0 +1,175 @@ +"""xyos-governance-mcp: default-deny, durable pause, approval must block.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from octop.modules.org_os.governance.engine import GovernanceEngine +from octop.modules.org_os.governance.interceptor import ( + GovernanceBlockedError, + GovernanceInterceptor, +) +from octop.modules.org_os.governance.policy import classify_tool, is_high_risk +from octop.modules.org_os.governance.store import DurableGovernanceStore +from octop.modules.org_os.governance.types import PolicyRequest + + +def test_classify_high_risk_classes() -> None: + assert classify_tool("delete_employee", "delete") == "delete" + assert classify_tool("http_request") == "outbound" + assert classify_tool("stripe_pay_invoice") == "pay" + assert classify_tool("deploy_production") == "prod" + assert classify_tool("list_employees") == "low" + assert is_high_risk("rm_workspace", "delete") is True + assert is_high_risk("org_os_status") is False + + +def _engine(tmp_path: Path, sidecar_url: str = "") -> GovernanceEngine: + return GovernanceEngine( + DurableGovernanceStore(tmp_path / "governance"), sidecar_url=sidecar_url + ) + + +def test_low_risk_allows(tmp_path: Path) -> None: + decision = _engine(tmp_path).evaluate(PolicyRequest(tool_name="org_os_status")) + assert decision.status == "allow" + assert decision.execute is True + assert decision.blocked is False + + +def test_high_risk_unmatched_default_denies_and_does_not_execute(tmp_path: Path) -> None: + decision = _engine(tmp_path).evaluate( + PolicyRequest(tool_name="delete_employee", category="delete", auto_pause=False) + ) + assert decision.status == "deny" + assert decision.execute is False + assert decision.blocked is True + assert "default deny" in decision.reason + + +def test_high_risk_auto_pause_is_pending_not_executable(tmp_path: Path) -> None: + engine = _engine(tmp_path) + decision = engine.evaluate( + PolicyRequest(tool_name="delete_employee", category="delete", args={"id": "1"}) + ) + assert decision.status == "pending" + assert decision.execute is False + assert decision.pause_id + stored = engine.store.get(decision.pause_id) + assert stored is not None + assert stored.status == "pending" + + +def test_approval_then_recheck_allows_same_digest_only(tmp_path: Path) -> None: + engine = _engine(tmp_path) + first = engine.evaluate( + PolicyRequest(tool_name="delete_employee", category="delete", args={"id": "1"}) + ) + approved = engine.approve(first.pause_id) + assert approved.status == "allow" + assert approved.execute is False # approve itself does not run the tool + + again = engine.evaluate( + PolicyRequest( + tool_name="delete_employee", + category="delete", + args={"id": "1"}, + approval_id=first.pause_id, + auto_pause=False, + ) + ) + assert again.execute is True + assert again.status == "allow" + + other = engine.evaluate( + PolicyRequest( + tool_name="delete_employee", + category="delete", + args={"id": "2"}, + approval_id=first.pause_id, + auto_pause=False, + ) + ) + assert other.execute is False + assert other.status == "deny" + + +def test_durable_pause_survives_new_store_instance(tmp_path: Path) -> None: + engine = _engine(tmp_path) + first = engine.evaluate( + PolicyRequest(tool_name="send_email", category="outbound", args={"to": "a@b"}) + ) + restarted = _engine(tmp_path) + record = restarted.store.get(first.pause_id) + assert record is not None + assert record.status == "pending" + restarted.approve(first.pause_id) + allowed = restarted.evaluate( + PolicyRequest( + tool_name="send_email", + category="outbound", + args={"to": "a@b"}, + approval_id=first.pause_id, + ) + ) + assert allowed.execute is True + + +def test_interceptor_raises_and_must_not_be_treated_as_allow(tmp_path: Path) -> None: + interceptor = GovernanceInterceptor.from_home(tmp_path) + with pytest.raises(GovernanceBlockedError) as excinfo: + interceptor.enforce(PolicyRequest(tool_name="pay_invoice", category="pay")) + assert excinfo.value.decision.execute is False + assert excinfo.value.decision.blocked is True + + +def test_audit_written_locally(tmp_path: Path) -> None: + engine = _engine(tmp_path) + engine.evaluate(PolicyRequest(tool_name="delete_employee", category="delete")) + rows = engine.store.tail_audit(10) + assert rows + assert rows[-1]["result"] in {"pending", "deny"} + assert rows[-1]["execute"] is False + + +def test_sidecar_explicit_deny(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + class _Resp: + status_code = 200 + + def json(self) -> dict[str, object]: + return {"success": True, "data": {"allowed": False, "reason": "matrix deny"}} + + class _Client: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def __enter__(self) -> _Client: + return self + + def __exit__(self, *args: object) -> None: + return None + + def post(self, url: str, json: dict[str, object]) -> _Resp: + assert url.endswith("/api/governance/validate") + return _Resp() + + import httpx + + monkeypatch.setattr(httpx, "Client", _Client) + decision = _engine(tmp_path, sidecar_url="http://127.0.0.1:3780").evaluate( + PolicyRequest(tool_name="delete_employee", category="delete", auto_pause=False) + ) + assert decision.status == "deny" + assert decision.execute is False + assert decision.sidecar_reached is True + assert "matrix deny" in decision.reason + + +def test_stdio_spec_is_stdio() -> None: + from octop.modules.org_os.governance.mcp_server import stdio_spec + + spec = stdio_spec() + assert spec["transport"] == "stdio" + assert spec["command"] == "xyos-governance-mcp" diff --git a/tests/unit/test_org_module.py b/tests/unit/test_org_module.py index e5c49f98..8d938ebd 100644 --- a/tests/unit/test_org_module.py +++ b/tests/unit/test_org_module.py @@ -131,3 +131,23 @@ class _User: assert headers["X-FreeOS-User"] == "ada" assert headers["X-FreeOS-User-Id"] == "7" assert headers["X-FreeOS-Role"] == "admin" + + +def test_identity_headers_tenant_override() -> None: + class _User: + id = 7 + username = "ada" + role = "admin" + tenant_id = 3 + + headers = identity_headers(_User(), tenant_id="99") + assert headers["X-FreeOS-Tenant-Id"] == "99" + + +def test_governance_enable_persists(tmp_path: Path) -> None: + config = tmp_path / "config.json" + service = OrgModuleService(config_path=config, home=tmp_path) + assert service.governance_enabled() is False + service.set_governance_enabled(True, tenant_id="42") + assert service.governance_enabled() is True + assert service.tenant_id() == "42" diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py index 6cd9ed1e..baf0260a 100644 --- a/tests/unit/test_paths.py +++ b/tests/unit/test_paths.py @@ -60,6 +60,14 @@ def test_knowledge_dir(tmp_path: Path) -> None: assert p.knowledge_dir == tmp_path / ".octop" / "knowledge" +def test_governance_and_org_skills_dirs(tmp_path: Path) -> None: + p = PathLayout(tmp_path / ".freeos") + assert p.governance_dir == tmp_path / ".freeos" / "governance" + assert p.org_skills_dir == tmp_path / ".freeos" / "org-skills" + assert p.ensure_governance_dir().is_dir() + assert p.ensure_org_skills_dir().is_dir() + + def test_path_layout_from_env_defaults_to_dot_freeos(monkeypatch, tmp_path: Path) -> None: monkeypatch.delenv("OCTOP_HOME", raising=False) monkeypatch.delenv("FREEOS_HOME", raising=False) diff --git a/tests/unit/test_skill_bridge.py b/tests/unit/test_skill_bridge.py new file mode 100644 index 00000000..63ea3942 --- /dev/null +++ b/tests/unit/test_skill_bridge.py @@ -0,0 +1,60 @@ +"""Module ↔ skill generator and reverse publish path.""" + +from __future__ import annotations + +from pathlib import Path + +from octop.modules.org_os.catalog import catalog_keys +from octop.modules.org_os.skill_bridge.endpoints import MODULE_ENDPOINTS +from octop.modules.org_os.skill_bridge.generate import ( + TENANT_HEADERS, + generate_module_skills, + skill_slug, +) +from octop.modules.org_os.skill_bridge.publish import publish_skill + + +def test_every_catalog_key_has_endpoint_map() -> None: + assert set(MODULE_ENDPOINTS) == set(catalog_keys()) + + +def test_generate_writes_frontmatter_tenant_headers_and_api_paths(tmp_path: Path) -> None: + generated = generate_module_skills(tmp_path, module_keys=["employees"]) + assert len(generated) == 1 + item = generated[0] + assert item.slug == "org-employees" + text = item.skill_md.read_text(encoding="utf-8") + assert text.startswith("---\n") + assert "name: org-employees" in text + assert "module_key: employees" in text + for header in TENANT_HEADERS: + assert header in text + assert "/api/employees" in text + assert "/api/org-module/sidecar" in text + assert item.script.is_file() + script = item.script.read_text(encoding="utf-8") + assert "X-FreeOS-Tenant-Id" in script + assert "gate_tool_call" in script + + +def test_generate_all_catalog_modules(tmp_path: Path) -> None: + generated = generate_module_skills(tmp_path) + assert [item.module_key for item in generated] == catalog_keys() + for item in generated: + assert (item.directory / "SKILL.md").is_file() + assert item.slug == skill_slug(item.module_key) + + +def test_publish_is_tenant_toggle_draft_not_auto_enabled(tmp_path: Path) -> None: + generated = generate_module_skills(tmp_path, module_keys=["governance"]) + draft = publish_skill(generated[0].directory, out_dir=tmp_path / "out.plugin") + assert draft.plugin_id == "org-governance" + yaml_text = draft.plugin_yaml.read_text(encoding="utf-8") + assert "id: org-governance" in yaml_text + payload = draft.payload_path.read_text(encoding="utf-8") + assert '"enabled": false' in payload + assert "tenant_toggleable" in payload + assert "/api/plugins" in payload + assert "/api/module-settings" in payload + assert (tmp_path / "out.plugin" / "main.py").is_file() + assert (tmp_path / "out.plugin" / "ui" / "manifest.json").is_file() From f058d299f20facb6d50146c05af0845677f6edbd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 00:45:51 +0000 Subject: [PATCH 2/4] Note Phase A CLI surfaces in the unreleased changelog. Co-authored-by: XYAI Labs --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 550687ab..886a8f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ ## [Unreleased] +### 新增 + +- Phase A: `xyos-governance-mcp` default-denies high-risk tools and durable-pauses for human approval (`freeos org governance`) +- Phase A: module ↔ skill bridge generates catalog skills with tenant headers and a tenant-toggle publish draft (`freeos org skills`) + ## [1.0.0] - 2026-09-14 ### 新增 From 07f1dfb44ed99262eb201cf2f38ec8a5a387b651 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 00:56:01 +0000 Subject: [PATCH 3/4] Fix Phase A mypy errors that failed CI typecheck. Annotate the org CLI engine helper and drop the unused type ignore on durable pause status parsing. Co-authored-by: XYAI Labs --- src/octop/cli/commands/org.py | 6 +++++- src/octop/modules/org_os/governance/store.py | 13 ++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index 928927c4..2fd984bf 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -4,12 +4,16 @@ import json from pathlib import Path +from typing import TYPE_CHECKING import click from octop.infra.utils.paths import PathLayout from octop.modules.org_os.service import OrgModuleService +if TYPE_CHECKING: + from octop.modules.org_os.governance.engine import GovernanceEngine + @click.group() def org() -> None: @@ -22,7 +26,7 @@ def _service() -> OrgModuleService: return OrgModuleService(config_path=paths.config, home=paths.root) -def _engine(): +def _engine() -> GovernanceEngine: from octop.modules.org_os.governance.engine import GovernanceEngine service = _service() diff --git a/src/octop/modules/org_os/governance/store.py b/src/octop/modules/org_os/governance/store.py index cc077045..f86db3e6 100644 --- a/src/octop/modules/org_os/governance/store.py +++ b/src/octop/modules/org_os/governance/store.py @@ -11,6 +11,17 @@ from octop.modules.org_os.governance.types import PauseStatus + +def _pause_status(raw: Any) -> PauseStatus: + mapping: dict[str, PauseStatus] = { + "pending": "pending", + "approved": "approved", + "rejected": "rejected", + "expired": "expired", + } + return mapping.get(str(raw or ""), "pending") + + _DEFAULT_TTL_SECONDS = 30 * 60 @@ -43,7 +54,7 @@ def from_dict(cls, data: dict[str, Any]) -> PauseRecord: tenant_id=str(data.get("tenant_id") or ""), args_digest=str(data.get("args_digest") or ""), reason=str(data.get("reason") or ""), - status=data.get("status") or "pending", # type: ignore[arg-type] + status=_pause_status(data.get("status")), created_at=float(data.get("created_at") or time.time()), resolved_at=data.get("resolved_at"), ttl_seconds=float(data.get("ttl_seconds") or _DEFAULT_TTL_SECONDS), From b8809bb43278161810bf5421e98c97e611ff1ab0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 01:01:40 +0000 Subject: [PATCH 4/4] Fix bootstrap CI: FreeOS home in CLI DB tests and OpenAPI branding. Init now writes ~/.freeos, so offline CLI tests must open PathLayout.from_env() instead of a missing ~/.octop (no agents table). Expect the FreeOS API title and uniquify duplicate operationIds from multi-method sidecar proxy routes. Co-authored-by: XYAI Labs --- src/octop/api/openapi_meta.py | 21 +++++++++++++++++++++ tests/integration/test_scalar.py | 10 +++++++++- tests/unit/api/test_openapi_meta.py | 25 +++++++++++++++++++++++++ tests/unit/cli/test_cli_db.py | 6 +++--- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/octop/api/openapi_meta.py b/src/octop/api/openapi_meta.py index 873ee064..3658f904 100644 --- a/src/octop/api/openapi_meta.py +++ b/src/octop/api/openapi_meta.py @@ -166,6 +166,24 @@ } +def uniquify_operation_ids(schema: dict[str, Any]) -> dict[str, Any]: + """Give each HTTP method its own operationId (FastAPI reuses the function name).""" + seen: set[str] = set() + for path_item in schema.get("paths", {}).values(): + if not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + if not isinstance(operation, dict): + continue + op_id = operation.get("operationId") + if not isinstance(op_id, str) or not op_id: + continue + unique = op_id if op_id not in seen else f"{op_id}_{method.lower()}" + seen.add(unique) + operation["operationId"] = unique + return schema + + def configure_openapi(app: FastAPI) -> None: """Attach tag descriptions, API intro, and Bearer security to the OpenAPI schema.""" @@ -182,10 +200,13 @@ def custom_openapi() -> dict[str, Any]: ) components = schema.setdefault("components", {}) components.setdefault("securitySchemes", {})["BearerAuth"] = _BEARER_SCHEME + uniquify_operation_ids(schema) for path, path_item in schema.get("paths", {}).items(): if not path.startswith("/api/") or is_jwt_exempt_path(path): continue + if not isinstance(path_item, dict): + continue for operation in path_item.values(): if isinstance(operation, dict) and "security" not in operation: operation["security"] = [{"BearerAuth": []}] diff --git a/tests/integration/test_scalar.py b/tests/integration/test_scalar.py index 0cd5f3f7..70c4d652 100644 --- a/tests/integration/test_scalar.py +++ b/tests/integration/test_scalar.py @@ -22,7 +22,15 @@ async def test_api_docs_endpoint(tmp_octop_home: Path) -> None: assert "/api/openapi.json" in r.text spec = c.get("/api/openapi.json") assert spec.status_code == 200 - assert spec.json()["info"]["title"] == "Octop API" + assert spec.json()["info"]["title"] == "FreeOS API" + ids = [ + op["operationId"] + for path_item in spec.json().get("paths", {}).values() + if isinstance(path_item, dict) + for op in path_item.values() + if isinstance(op, dict) and op.get("operationId") + ] + assert len(ids) == len(set(ids)) async def test_api_docs_disabled_by_default(tmp_octop_home: Path) -> None: diff --git a/tests/unit/api/test_openapi_meta.py b/tests/unit/api/test_openapi_meta.py index 4b9a598d..2293f307 100644 --- a/tests/unit/api/test_openapi_meta.py +++ b/tests/unit/api/test_openapi_meta.py @@ -27,5 +27,30 @@ async def test_openapi_has_bearer_security_and_descriptions(tmp_octop_home: Path me = spec["paths"]["/api/auth/me"]["get"] assert "BearerAuth" in me["security"][0] assert "security" not in spec["paths"]["/api/setup/status"]["get"] + ids = [ + op["operationId"] + for item in spec.get("paths", {}).values() + if isinstance(item, dict) + for op in item.values() + if isinstance(op, dict) and op.get("operationId") + ] + assert len(ids) == len(set(ids)) finally: await srv.stop() + + +def test_uniquify_operation_ids_suffixes_duplicates() -> None: + from octop.api.openapi_meta import uniquify_operation_ids + + schema = { + "paths": { + "/api/org-module/sidecar/{path}": { + "get": {"operationId": "org_module_proxy"}, + "post": {"operationId": "org_module_proxy"}, + } + } + } + uniquify_operation_ids(schema) + ops = schema["paths"]["/api/org-module/sidecar/{path}"] + assert ops["get"]["operationId"] == "org_module_proxy" + assert ops["post"]["operationId"] == "org_module_proxy_post" diff --git a/tests/unit/cli/test_cli_db.py b/tests/unit/cli/test_cli_db.py index 98eed7a0..995f9363 100644 --- a/tests/unit/cli/test_cli_db.py +++ b/tests/unit/cli/test_cli_db.py @@ -33,7 +33,7 @@ def _bootstrap(fake_home: Path) -> None: def test_list_agents_offline(fake_home: Path) -> None: _bootstrap(fake_home) - paths = PathLayout(fake_home / ".octop") + paths = PathLayout.from_env() db = SqlitePool(paths.db) AgentRepo(db).create(agent_id="ag1", user_id=1, name="Bot") db.close() @@ -47,7 +47,7 @@ def test_list_agents_offline(fake_home: Path) -> None: def test_list_threads_offline(fake_home: Path) -> None: _bootstrap(fake_home) - paths = PathLayout(fake_home / ".octop") + paths = PathLayout.from_env() db = SqlitePool(paths.db) AgentRepo(db).create(agent_id="ag1", user_id=1, name="Bot") tid = new_ulid() @@ -71,7 +71,7 @@ def test_list_threads_offline(fake_home: Path) -> None: def test_agent_list_offline_flag(fake_home: Path) -> None: _bootstrap(fake_home) - paths = PathLayout(fake_home / ".octop") + paths = PathLayout.from_env() db = SqlitePool(paths.db) AgentRepo(db).create(agent_id="ag1", user_id=1, name="Bot") db.close()