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。
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()